mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-23 15:59:47 +00:00
Compare commits
170 Commits
perf/fix-v
...
sno-qa-105
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
05c6e1c0ff | ||
|
|
15fb037a55 | ||
|
|
dbc12a9d6a | ||
|
|
909b75b9d1 | ||
|
|
868d774007 | ||
|
|
69cd6a7628 | ||
|
|
9c39635f16 | ||
|
|
3e957213c8 | ||
|
|
f5d99c9c22 | ||
|
|
09bbd89172 | ||
|
|
0b5613246e | ||
|
|
3a27263ca6 | ||
|
|
6044452b8f | ||
|
|
c4a243060b | ||
|
|
3690e98c79 | ||
|
|
1c40893cfa | ||
|
|
72a28a1e76 | ||
|
|
49c95248e5 | ||
|
|
6bb9d18ca6 | ||
|
|
ca49e9cb1b | ||
|
|
db538c9d76 | ||
|
|
c04b31a0f1 | ||
|
|
5938fdef8d | ||
|
|
27d55f093b | ||
|
|
c1ddb8669e | ||
|
|
57dbf0132d | ||
|
|
b4c49588dc | ||
|
|
00dc10e9e6 | ||
|
|
48414fa1b5 | ||
|
|
e744f101b0 | ||
|
|
9ad8267067 | ||
|
|
cf54ddb6d3 | ||
|
|
fce31cf0bf | ||
|
|
050091abc6 | ||
|
|
548e37b9a5 | ||
|
|
458b2e918c | ||
|
|
63dbe002d1 | ||
|
|
f3d9a8c2e4 | ||
|
|
831718bd50 | ||
|
|
7d3ddbf619 | ||
|
|
a42240cb65 | ||
|
|
c957c0833b | ||
|
|
e01d4aaffc | ||
|
|
3f226467cd | ||
|
|
d4d8772ae7 | ||
|
|
b578f8d7c4 | ||
|
|
d5360ce45c | ||
|
|
0e6d9fd926 | ||
|
|
de810f88a4 | ||
|
|
722b01a253 | ||
|
|
dfd19a3cf9 | ||
|
|
3531e37ae7 | ||
|
|
024b231c05 | ||
|
|
0c22369c60 | ||
|
|
511fdf1b24 | ||
|
|
d756c362e3 | ||
|
|
ba512fd263 | ||
|
|
e1fb782832 | ||
|
|
9c347642ba | ||
|
|
5f8f40b559 | ||
|
|
264e71a9de | ||
|
|
49fa1b3caa | ||
|
|
3142c8ead6 | ||
|
|
4310c5238a | ||
|
|
a7d7d39712 | ||
|
|
24ac6f1566 | ||
|
|
f9a5baba1a | ||
|
|
628f64631b | ||
|
|
bc38b2ce13 | ||
|
|
ca8e4d2a29 | ||
|
|
55ce174c5b | ||
|
|
49176271f8 | ||
|
|
50518449fc | ||
|
|
65aa03b20d | ||
|
|
ce59b6a431 | ||
|
|
203e8c8a60 | ||
|
|
b485d22760 | ||
|
|
2d1088f79e | ||
|
|
1fe0f97aa5 | ||
|
|
178ecc6746 | ||
|
|
20f878f929 | ||
|
|
712c386a69 | ||
|
|
8f6fa738a5 | ||
|
|
387862d8b9 | ||
|
|
ff98eb13e4 | ||
|
|
f4f80d179f | ||
|
|
f758e16b72 | ||
|
|
45e309c5f8 | ||
|
|
79df405733 | ||
|
|
27c64e1092 | ||
|
|
a1307ef35c | ||
|
|
11f02a2645 | ||
|
|
b3bcc3ff4c | ||
|
|
49e904918e | ||
|
|
78e5b1e1b3 | ||
|
|
81e3dc72f8 | ||
|
|
936cf83337 | ||
|
|
1d880bf493 | ||
|
|
da3e6cb4cf | ||
|
|
a39e3054cf | ||
|
|
5a6178e924 | ||
|
|
a11e8a67f8 | ||
|
|
6a836b7c25 | ||
|
|
f91f94f71a | ||
|
|
7502528733 | ||
|
|
0656091959 | ||
|
|
6515170d08 | ||
|
|
25cbe56a34 | ||
|
|
120a531ef9 | ||
|
|
389f6ca6b8 | ||
|
|
6298fc3a58 | ||
|
|
83702c2e87 | ||
|
|
c5d207fa9a | ||
|
|
ab6dff02c9 | ||
|
|
7396d39a6a | ||
|
|
99e6681237 | ||
|
|
ae29224874 | ||
|
|
47e5c39ac9 | ||
|
|
28f530d53a | ||
|
|
282754743d | ||
|
|
0a91ec09e8 | ||
|
|
25fd1b2700 | ||
|
|
b9d5ff0f8d | ||
|
|
746d465912 | ||
|
|
e314a18b90 | ||
|
|
78fb9ef27f | ||
|
|
41999c2e0f | ||
|
|
5c243d16be | ||
|
|
774dcd823e | ||
|
|
b500f826fc | ||
|
|
1cd9e171c6 | ||
|
|
5c0bef9b72 | ||
|
|
94e1388495 | ||
|
|
0268e8f977 | ||
|
|
4f1df7c7ce | ||
|
|
3b2fdc786a | ||
|
|
4d1ad4dcf0 | ||
|
|
7ba1aaed53 | ||
|
|
09a3c10d50 | ||
|
|
2f30fbe060 | ||
|
|
85adbe4878 | ||
|
|
0c707b5deb | ||
|
|
4f98518c22 | ||
|
|
ca394b9ff7 | ||
|
|
51878db99d | ||
|
|
d0534003e7 | ||
|
|
3d0cd72465 | ||
|
|
82849df891 | ||
|
|
1c62c0edc3 | ||
|
|
6ea2ce755d | ||
|
|
8fc4480ee2 | ||
|
|
3515a478fd | ||
|
|
11432992d3 | ||
|
|
e619b0143a | ||
|
|
7d4a008f29 | ||
|
|
a3e65140a9 | ||
|
|
f55ab36dd7 | ||
|
|
698a894b42 | ||
|
|
fbd7f404ef | ||
|
|
d633ce19a7 | ||
|
|
9a7b5f88a0 | ||
|
|
be261a8a86 | ||
|
|
b2c31e785f | ||
|
|
475f9ae5f0 | ||
|
|
c329022e15 | ||
|
|
63d0df9ff0 | ||
|
|
c1593054fb | ||
|
|
a44fa1fdd5 | ||
|
|
cc3acebceb | ||
|
|
23c22e4c52 |
278
.claude/skills/comfy-qa/REPRODUCE.md
Normal file
278
.claude/skills/comfy-qa/REPRODUCE.md
Normal file
@@ -0,0 +1,278 @@
|
||||
---
|
||||
name: reproduce-issue
|
||||
description: 'Reproduce a GitHub issue by researching prerequisites, setting up the environment (custom nodes, workflows, settings), and interactively exploring ComfyUI via playwright-cli until the bug is confirmed. Then records a clean demo video.'
|
||||
---
|
||||
|
||||
# Issue Reproduction Skill
|
||||
|
||||
Reproduce a reported GitHub issue against a running ComfyUI instance. This skill uses an interactive, agent-driven approach — not a static script. You will research, explore, retry, and adapt until the bug is reproduced, then record a clean demo.
|
||||
|
||||
## Architecture
|
||||
|
||||
Two videos are produced:
|
||||
|
||||
1. **Research video** — the full exploration session: installing deps, trying things, failing, retrying, figuring out the bug. Valuable for debugging context.
|
||||
2. **Reproduce video** — a clean, minimal recording of just the reproduction steps. This is the demo you'd attach to the issue.
|
||||
|
||||
```
|
||||
Phase 1: Research → Read issue, understand prerequisites
|
||||
Phase 2: Environment → Install custom nodes, load workflows, configure settings
|
||||
Phase 3: Explore → [VIDEO 1: research] Interactively try to reproduce (retries OK)
|
||||
Phase 4: Record → [VIDEO 2: reproduce] Clean recording of just the minimal repro steps
|
||||
Phase 5: Report → Generate a structured reproduction report
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- ComfyUI server running (ask user for URL, default: `http://127.0.0.1:8188`)
|
||||
- `playwright-cli` installed: `npm install -g @playwright/cli@latest`
|
||||
- `gh` CLI (authenticated, for reading issues)
|
||||
- ComfyUI backend with Python environment (for installing custom nodes)
|
||||
|
||||
## Phase 1: Research the Issue
|
||||
|
||||
1. Fetch the issue details:
|
||||
|
||||
```bash
|
||||
gh issue view <number> --repo Comfy-Org/ComfyUI_frontend --json title,body,comments
|
||||
```
|
||||
|
||||
2. Extract from the issue body:
|
||||
- **Reproduction steps** (the exact sequence)
|
||||
- **Prerequisites**: specific workflows, custom nodes, settings, models
|
||||
- **Environment**: OS, browser, ComfyUI version
|
||||
- **Media**: screenshots or videos showing the bug
|
||||
|
||||
3. Search the codebase for related code:
|
||||
- Find the feature/component mentioned in the issue
|
||||
- Understand how it works currently
|
||||
- Identify what state the UI needs to be in
|
||||
|
||||
## Phase 2: Environment Setup
|
||||
|
||||
Set up everything the issue requires BEFORE attempting reproduction.
|
||||
|
||||
### Custom Nodes
|
||||
|
||||
If the issue mentions custom nodes:
|
||||
|
||||
```bash
|
||||
# Find the custom node repo
|
||||
# Clone into ComfyUI's custom_nodes directory
|
||||
cd <comfyui_path>/custom_nodes
|
||||
git clone <custom_node_repo_url>
|
||||
|
||||
# Install dependencies if needed
|
||||
cd <custom_node_name>
|
||||
pip install -r requirements.txt 2>/dev/null || true
|
||||
|
||||
# Restart ComfyUI server to load the new nodes
|
||||
```
|
||||
|
||||
### Workflows
|
||||
|
||||
If the issue references a specific workflow:
|
||||
|
||||
```bash
|
||||
# Download workflow JSON if a URL is provided
|
||||
curl -L "<workflow_url>" -o /tmp/test-workflow.json
|
||||
|
||||
# Load it via the API
|
||||
curl -X POST http://127.0.0.1:8188/api/workflow \
|
||||
-H "Content-Type: application/json" \
|
||||
-d @/tmp/test-workflow.json
|
||||
```
|
||||
|
||||
Or load via playwright-cli:
|
||||
|
||||
```bash
|
||||
playwright-cli goto "http://127.0.0.1:8188"
|
||||
# Drag-and-drop or use File > Open to load the workflow
|
||||
```
|
||||
|
||||
### Settings
|
||||
|
||||
If the issue requires specific settings:
|
||||
|
||||
```bash
|
||||
# Use playwright-cli to open settings and change them
|
||||
playwright-cli press "Control+,"
|
||||
playwright-cli snapshot
|
||||
# Find and modify the relevant setting
|
||||
```
|
||||
|
||||
## Phase 3: Interactive Exploration — Research Video
|
||||
|
||||
Start recording the **research video** (Video 1). This captures the full exploration — mistakes, retries, dead ends — all valuable context.
|
||||
|
||||
```bash
|
||||
# Open browser and start video recording
|
||||
playwright-cli open "http://127.0.0.1:8188"
|
||||
playwright-cli video-start
|
||||
|
||||
# Take a snapshot to see current state
|
||||
playwright-cli snapshot
|
||||
|
||||
# Interact based on what you see
|
||||
playwright-cli click <ref>
|
||||
playwright-cli fill <ref> "text"
|
||||
playwright-cli press "Control+s"
|
||||
|
||||
# Check results
|
||||
playwright-cli snapshot
|
||||
playwright-cli screenshot --filename=/tmp/qa/research-step-1.png
|
||||
```
|
||||
|
||||
### Key Principles
|
||||
|
||||
- **Observe before acting**: Always `snapshot` before interacting
|
||||
- **Retry and adapt**: If a step fails, try a different approach
|
||||
- **Document what works**: Keep notes on which steps trigger the bug
|
||||
- **Don't give up**: Try multiple approaches if the first doesn't work
|
||||
- **Establish prerequisites**: Many bugs require specific UI state:
|
||||
- Save a workflow first (File > Save)
|
||||
- Make changes to dirty the workflow
|
||||
- Open multiple tabs
|
||||
- Add specific node types
|
||||
- Change settings
|
||||
- Resize the window
|
||||
|
||||
### Common ComfyUI Interactions via playwright-cli
|
||||
|
||||
| Action | Command |
|
||||
| ------------------- | -------------------------------------------------------------- |
|
||||
| Open hamburger menu | `playwright-cli click` on the C logo button |
|
||||
| Navigate menu | `playwright-cli hover <ref>` then `playwright-cli click <ref>` |
|
||||
| Add node | Double-click canvas → type node name → select from results |
|
||||
| Connect nodes | Drag from output slot to input slot |
|
||||
| Save workflow | `playwright-cli press "Control+s"` |
|
||||
| Save As | Menu > File > Save As |
|
||||
| Select node | Click on the node |
|
||||
| Delete node | Select → `playwright-cli press "Delete"` |
|
||||
| Right-click menu | `playwright-cli click <ref> --button right` |
|
||||
| Keyboard shortcut | `playwright-cli press "Control+z"` |
|
||||
|
||||
## Phase 4: Record Clean Demo — Reproduce Video (max 5 minutes)
|
||||
|
||||
Once the bug is confirmed, **stop the research video** and **close the research browser**:
|
||||
|
||||
```bash
|
||||
playwright-cli video-stop
|
||||
playwright-cli close
|
||||
```
|
||||
|
||||
Now start a **fresh browser session** for the clean reproduce video (Video 2).
|
||||
|
||||
**IMPORTANT constraints:**
|
||||
|
||||
- **Max 5 minutes** — the reproduce video must be short and focused
|
||||
- **No environment setup** — server, user, custom nodes are already set up from Phase 3. Just log in and go.
|
||||
- **No exploration** — you already know the exact steps. Execute them quickly and precisely.
|
||||
- **Start video recording immediately**, execute steps, stop. Don't leave the recording running while thinking.
|
||||
|
||||
1. **Open browser and start recording**:
|
||||
|
||||
```bash
|
||||
playwright-cli open "http://127.0.0.1:8188"
|
||||
playwright-cli video-start
|
||||
```
|
||||
|
||||
2. **Execute only the minimal reproduction steps** — no exploration, no mistakes. Just the clean sequence that demonstrates the bug. You already know exactly what works from Phase 3.
|
||||
|
||||
3. **Take key screenshots** at critical moments:
|
||||
|
||||
```bash
|
||||
playwright-cli screenshot --filename=/tmp/qa/before-bug.png
|
||||
# ... trigger the bug ...
|
||||
playwright-cli screenshot --filename=/tmp/qa/bug-visible.png
|
||||
```
|
||||
|
||||
4. **Stop recording and close** immediately after the bug is demonstrated:
|
||||
```bash
|
||||
playwright-cli video-stop
|
||||
playwright-cli close
|
||||
```
|
||||
|
||||
## Phase 5: Generate Report
|
||||
|
||||
Create a reproduction report at `tmp/qa/reproduce-report.md`:
|
||||
|
||||
```markdown
|
||||
# Issue Reproduction Report
|
||||
|
||||
- **Issue**: <issue_url>
|
||||
- **Title**: <issue_title>
|
||||
- **Date**: <today>
|
||||
- **Status**: Reproduced / Not Reproduced / Partially Reproduced
|
||||
|
||||
## Environment
|
||||
|
||||
- ComfyUI Server: <url>
|
||||
- OS: <os>
|
||||
- Custom Nodes Installed: <list or "none">
|
||||
- Settings Changed: <list or "none">
|
||||
|
||||
## Prerequisites
|
||||
|
||||
List everything that had to be set up before the bug could be triggered:
|
||||
|
||||
1. ...
|
||||
2. ...
|
||||
|
||||
## Reproduction Steps
|
||||
|
||||
Minimal steps to reproduce (the clean sequence):
|
||||
|
||||
1. ...
|
||||
2. ...
|
||||
3. ...
|
||||
|
||||
## Expected Behavior
|
||||
|
||||
<from the issue>
|
||||
|
||||
## Actual Behavior
|
||||
|
||||
<what actually happened>
|
||||
|
||||
## Evidence
|
||||
|
||||
- Research video: `research-video/video.webm` (full exploration session)
|
||||
- Reproduce video: `reproduce-video/video.webm` (clean minimal repro)
|
||||
- Screenshots: `before-bug.png`, `bug-visible.png`
|
||||
|
||||
## Root Cause Analysis (if identified)
|
||||
|
||||
<code pointers, hypothesis about what's going wrong>
|
||||
|
||||
## Notes
|
||||
|
||||
<any additional observations, workarounds discovered, related issues>
|
||||
```
|
||||
|
||||
## Handling Failures
|
||||
|
||||
If the bug **cannot be reproduced**:
|
||||
|
||||
1. Document what you tried and why it didn't work
|
||||
2. Check if the issue was already fixed (search git log for related commits)
|
||||
3. Check if it's environment-specific (OS, browser, specific version)
|
||||
4. Set report status to "Not Reproduced" with detailed notes
|
||||
5. The report is still valuable — it saves others from repeating the same investigation
|
||||
|
||||
## CI Integration
|
||||
|
||||
In CI, this skill runs as a Claude Code agent with:
|
||||
|
||||
- `ANTHROPIC_API_KEY` for Claude
|
||||
- `GEMINI_API_KEY` for initial issue analysis (optional)
|
||||
- ComfyUI server pre-started in the container
|
||||
- `playwright-cli` pre-installed
|
||||
|
||||
The CI workflow:
|
||||
|
||||
1. Gemini generates a reproduce guide (markdown) from the issue
|
||||
2. Claude agent receives the guide and runs this skill
|
||||
3. Claude explores interactively, installs dependencies, retries
|
||||
4. Claude records a clean demo once reproduced
|
||||
5. Video and report are uploaded as artifacts
|
||||
361
.claude/skills/comfy-qa/SKILL.md
Normal file
361
.claude/skills/comfy-qa/SKILL.md
Normal file
@@ -0,0 +1,361 @@
|
||||
---
|
||||
name: comfy-qa
|
||||
description: 'Comprehensive QA of ComfyUI frontend. Navigates all routes, tests all interactive features using playwright-cli, generates a report, and submits a draft PR. Works in CI and local environments, cross-platform.'
|
||||
---
|
||||
|
||||
# ComfyUI Frontend QA Skill
|
||||
|
||||
Perform comprehensive quality assurance of the ComfyUI frontend application by navigating all routes, clicking interactive elements, and testing features. Generate a structured report and submit it as a draft PR.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node.js 22+
|
||||
- `pnpm` package manager
|
||||
- `gh` CLI (authenticated)
|
||||
- `playwright-cli` (browser automation): `npm install -g @playwright/cli@latest`
|
||||
|
||||
## Step 1: Environment Detection & Setup
|
||||
|
||||
Detect the runtime environment and ensure the app is accessible.
|
||||
|
||||
### CI Environment
|
||||
|
||||
If `CI=true` is set:
|
||||
|
||||
1. The ComfyUI backend is pre-configured in the CI container (`ghcr.io/comfy-org/comfyui-ci-container`)
|
||||
2. Frontend dist is already built and served by the backend
|
||||
3. Server runs at `http://127.0.0.1:8188`
|
||||
4. Skip user prompts — run fully automated
|
||||
|
||||
### Local Environment
|
||||
|
||||
If `CI` is not set:
|
||||
|
||||
1. **Ask the user**: "Is a ComfyUI server already running? If so, what URL? (default: http://127.0.0.1:8188)"
|
||||
- If yes: use the provided URL
|
||||
- If no: offer to start one:
|
||||
|
||||
```bash
|
||||
# Option A: Use existing ComfyUI installation
|
||||
# Ask for the path to ComfyUI, then:
|
||||
cd <comfyui_path>
|
||||
python main.py --cpu --multi-user --front-end-root <frontend_dist_path> &
|
||||
|
||||
# Option B: Build frontend and use preview server (no backend features)
|
||||
pnpm build && pnpm preview &
|
||||
```
|
||||
|
||||
2. Wait for server readiness by polling the URL (retry with 2s intervals, 60s timeout)
|
||||
|
||||
### Browser Automation Setup
|
||||
|
||||
Use **playwright-cli** for browser interaction via Bash commands:
|
||||
|
||||
```bash
|
||||
playwright-cli open http://127.0.0.1:8188 # open browser and navigate
|
||||
playwright-cli snapshot # capture snapshot with element refs
|
||||
playwright-cli click e1 # click by element ref from snapshot
|
||||
playwright-cli press Tab # keyboard shortcuts
|
||||
playwright-cli screenshot --filename=f.png # save screenshot
|
||||
```
|
||||
|
||||
playwright-cli is headless by default (CI-friendly). Each command outputs the current page snapshot with element references (`e1`, `e2`, …) that you use for subsequent `click`, `fill`, `hover` commands. Always run `snapshot` before interacting to get fresh refs.
|
||||
|
||||
For local dev servers behind proxies, adjust the URL accordingly (e.g., `https://[port].stukivx.xyz` pattern if configured).
|
||||
|
||||
## Step 2: QA Test Plan
|
||||
|
||||
Navigate to the application URL and systematically test each area below. For each test, record:
|
||||
|
||||
- **Status**: pass / fail / skip (with reason)
|
||||
- **Notes**: any issues, unexpected behavior, or visual glitches
|
||||
- **Screenshots**: take screenshots of failures or notable states
|
||||
|
||||
### 2.1 Application Load & Routes
|
||||
|
||||
| Test | Steps |
|
||||
| ----------------- | ------------------------------------------------------------ |
|
||||
| Root route loads | Navigate to `/` — GraphView should render with canvas |
|
||||
| User select route | Navigate to `/user-select` — user selection UI should appear |
|
||||
| Default redirect | If multi-user mode, `/` redirects to `/user-select` first |
|
||||
| 404 handling | Navigate to `/nonexistent` — should handle gracefully |
|
||||
|
||||
### 2.2 Canvas & Graph View
|
||||
|
||||
| Test | Steps |
|
||||
| ------------------------- | -------------------------------------------------------------- |
|
||||
| Canvas renders | The LiteGraph canvas is visible and interactive |
|
||||
| Pan canvas | Click and drag on empty canvas area |
|
||||
| Zoom in/out | Use scroll wheel or Alt+=/Alt+- |
|
||||
| Fit view | Press `.` key — canvas fits to content |
|
||||
| Add node via double-click | Double-click canvas to open search, type "KSampler", select it |
|
||||
| Add node via search | Open search box, find and add a node |
|
||||
| Delete node | Select a node, press Delete key |
|
||||
| Connect nodes | Drag from output slot to input slot |
|
||||
| Disconnect nodes | Right-click a link and remove, or drag from connected slot |
|
||||
| Multi-select | Shift+click or drag-select multiple nodes |
|
||||
| Copy/Paste | Select nodes, Ctrl+C then Ctrl+V |
|
||||
| Undo/Redo | Make changes, Ctrl+Z to undo, Ctrl+Y to redo |
|
||||
| Node context menu | Right-click a node — menu appears with all expected options |
|
||||
| Canvas context menu | Right-click empty canvas — menu appears |
|
||||
|
||||
### 2.3 Node Operations
|
||||
|
||||
| Test | Steps |
|
||||
| ------------------- | ---------------------------------------------------------- |
|
||||
| Bypass node | Select node, Ctrl+B — node shows bypass state |
|
||||
| Mute node | Select node, Ctrl+M — node shows muted state |
|
||||
| Collapse node | Select node, Alt+C — node collapses |
|
||||
| Pin node | Select node, press P — node becomes pinned |
|
||||
| Rename node | Double-click node title — edit mode activates |
|
||||
| Node color | Right-click > Color — color picker works |
|
||||
| Group nodes | Select multiple nodes, Ctrl+G — group created |
|
||||
| Ungroup | Right-click group > Ungroup |
|
||||
| Widget interactions | Toggle checkboxes, adjust sliders, type in text fields |
|
||||
| Combo widget | Click dropdown widgets — options appear and are selectable |
|
||||
|
||||
### 2.4 Sidebar Tabs
|
||||
|
||||
| Test | Steps |
|
||||
| ---------------------- | ------------------------------------------------------ |
|
||||
| Workflows tab | Press W — workflows sidebar opens with saved workflows |
|
||||
| Node Library tab | Press N — node library opens with categories |
|
||||
| Model Library tab | Press M — model library opens |
|
||||
| Assets tab | Press A — assets browser opens |
|
||||
| Tab toggle | Press same key again — sidebar closes |
|
||||
| Search in sidebar | Type in search box — results filter |
|
||||
| Drag node from library | Drag a node from library onto canvas |
|
||||
|
||||
### 2.5 Topbar & Workflow Tabs
|
||||
|
||||
| Test | Steps |
|
||||
| -------------------- | ------------------------------------------------------ |
|
||||
| Workflow tab display | Current workflow name shown in tab bar |
|
||||
| New workflow | Ctrl+N — new blank workflow created |
|
||||
| Rename workflow | Double-click workflow tab |
|
||||
| Tab context menu | Right-click workflow tab — menu with Close/Rename/etc. |
|
||||
| Multiple tabs | Open multiple workflows, switch between them |
|
||||
| Queue button | Click Queue/Run button — prompt queues |
|
||||
| Batch count | Click batch count editor, change value |
|
||||
| Menu hamburger | Click hamburger menu — options appear |
|
||||
|
||||
### 2.6 Settings Dialog
|
||||
|
||||
| Test | Steps |
|
||||
| ---------------- | ---------------------------------------------------- |
|
||||
| Open settings | Press Ctrl+, or click settings button |
|
||||
| Settings tabs | Navigate through all setting categories |
|
||||
| Change a setting | Toggle a boolean setting — it persists after closing |
|
||||
| Search settings | Type in settings search box — results filter |
|
||||
| Keybindings tab | Navigate to keybindings panel |
|
||||
| About tab | Navigate to about panel — version info shown |
|
||||
| Close settings | Press Escape or click close button |
|
||||
|
||||
### 2.7 Bottom Panel
|
||||
|
||||
| Test | Steps |
|
||||
| ------------------- | -------------------------------------- |
|
||||
| Toggle panel | Press Ctrl+` — bottom panel opens |
|
||||
| Logs tab | Logs/terminal tab shows server output |
|
||||
| Shortcuts tab | Shortcuts reference is displayed |
|
||||
| Keybindings display | Press Ctrl+Shift+K — keybindings panel |
|
||||
|
||||
### 2.8 Execution & Queue
|
||||
|
||||
| Test | Steps |
|
||||
| -------------- | ----------------------------------------------------- |
|
||||
| Queue prompt | Load default workflow, click Queue — execution starts |
|
||||
| Queue progress | Progress indicator shows during execution |
|
||||
| Interrupt | Press Ctrl+Alt+Enter during execution — interrupts |
|
||||
| Job history | Open job history sidebar — past executions listed |
|
||||
| Clear history | Clear execution history via menu |
|
||||
|
||||
### 2.9 Workflow File Operations
|
||||
|
||||
| Test | Steps |
|
||||
| --------------- | ------------------------------------------------- |
|
||||
| Save workflow | Ctrl+S — workflow saves (check for prompt if new) |
|
||||
| Open workflow | Ctrl+O — file picker or workflow browser opens |
|
||||
| Export JSON | Menu > Export — workflow JSON downloads |
|
||||
| Import workflow | Drag a .json workflow file onto canvas |
|
||||
| Load default | Menu > Load Default — default workflow loads |
|
||||
| Clear workflow | Menu > Clear — canvas clears (after confirmation) |
|
||||
|
||||
### 2.10 Advanced Features
|
||||
|
||||
| Test | Steps |
|
||||
| --------------- | ------------------------------------------------- |
|
||||
| Minimap | Alt+M — minimap toggle |
|
||||
| Focus mode | Toggle focus mode |
|
||||
| Canvas lock | Press H to lock, V to unlock |
|
||||
| Link visibility | Ctrl+Shift+L — toggle links |
|
||||
| Subgraph | Select nodes > Ctrl+Shift+E — convert to subgraph |
|
||||
|
||||
### 2.11 Error Handling
|
||||
|
||||
| Test | Steps |
|
||||
| --------------------- | -------------------------------------------- |
|
||||
| Missing nodes dialog | Load workflow with non-existent node types |
|
||||
| Missing models dialog | Trigger missing model warning |
|
||||
| Network error | Disconnect backend, verify graceful handling |
|
||||
| Invalid workflow | Try loading malformed JSON |
|
||||
|
||||
### 2.12 Responsive & Accessibility
|
||||
|
||||
| Test | Steps |
|
||||
| ------------------- | ------------------------------------- |
|
||||
| Window resize | Resize browser window — layout adapts |
|
||||
| Keyboard navigation | Tab through interactive elements |
|
||||
| Sidebar resize | Drag sidebar edge to resize |
|
||||
|
||||
## Step 3: Generate Report
|
||||
|
||||
After completing all tests, generate a markdown report file.
|
||||
|
||||
### Report Location
|
||||
|
||||
```
|
||||
docs/qa/YYYY-MM-DD-NNN-report.md
|
||||
```
|
||||
|
||||
Where:
|
||||
|
||||
- `YYYY-MM-DD` is today's date
|
||||
- `NNN` is a zero-padded increment index (001, 002, etc.)
|
||||
|
||||
To determine the increment, check existing files:
|
||||
|
||||
```bash
|
||||
ls docs/qa/ | grep "$(date +%Y-%m-%d)" | wc -l
|
||||
```
|
||||
|
||||
### Report Template
|
||||
|
||||
```markdown
|
||||
# QA Report: ComfyUI Frontend
|
||||
|
||||
**Date**: YYYY-MM-DD
|
||||
**Environment**: CI / Local (OS, Browser)
|
||||
**Frontend Version**: (git sha or version)
|
||||
**Agent**: Claude / Codex / Other
|
||||
**Server URL**: http://...
|
||||
|
||||
## Summary
|
||||
|
||||
| Category | Pass | Fail | Skip | Total |
|
||||
| --------------- | ---- | ---- | ---- | ----- |
|
||||
| Routes & Load | | | | |
|
||||
| Canvas | | | | |
|
||||
| Node Operations | | | | |
|
||||
| Sidebar | | | | |
|
||||
| Topbar | | | | |
|
||||
| Settings | | | | |
|
||||
| Bottom Panel | | | | |
|
||||
| Execution | | | | |
|
||||
| File Operations | | | | |
|
||||
| Advanced | | | | |
|
||||
| Error Handling | | | | |
|
||||
| Responsive | | | | |
|
||||
| **Total** | | | | |
|
||||
|
||||
## Results
|
||||
|
||||
### Routes & Load
|
||||
|
||||
- [x] Root route loads — pass
|
||||
- [ ] ...
|
||||
|
||||
### Canvas & Graph View
|
||||
|
||||
- [x] Canvas renders — pass
|
||||
- [ ] ...
|
||||
|
||||
(repeat for each category)
|
||||
|
||||
## Issues Found
|
||||
|
||||
### Issue 1: [Title]
|
||||
|
||||
- **Severity**: critical / major / minor / cosmetic
|
||||
- **Steps to reproduce**: ...
|
||||
- **Expected**: ...
|
||||
- **Actual**: ...
|
||||
- **Screenshot**: (if available)
|
||||
|
||||
## Notes
|
||||
|
||||
Any additional observations, performance notes, or suggestions.
|
||||
```
|
||||
|
||||
## Step 4: Commit and Push Report
|
||||
|
||||
### In CI (when `CI=true`)
|
||||
|
||||
Save the report directly to `$QA_ARTIFACTS` (the CI workflow uploads this as
|
||||
an artifact and posts results as a PR comment). Do **not** commit, push, or
|
||||
create a new PR.
|
||||
|
||||
### Local / interactive use
|
||||
|
||||
When running locally, create a draft PR after committing:
|
||||
|
||||
```bash
|
||||
# Ensure on a feature branch
|
||||
BRANCH_NAME="qa/$(date +%Y-%m-%d)-$(git rev-parse --short HEAD)"
|
||||
git checkout -b "$BRANCH_NAME" 2>/dev/null || git checkout "$BRANCH_NAME"
|
||||
|
||||
git add docs/qa/
|
||||
git commit -m "docs: add QA report $(date +%Y-%m-%d)
|
||||
|
||||
Automated QA report covering all frontend routes and features."
|
||||
git push -u origin "$BRANCH_NAME"
|
||||
|
||||
# Create draft PR assigned to comfy-pr-bot
|
||||
gh pr create \
|
||||
--draft \
|
||||
--title "QA Report: $(date +%Y-%m-%d)" \
|
||||
--body "## QA Report
|
||||
|
||||
Automated frontend QA run covering all routes and interactive features.
|
||||
|
||||
See \`docs/qa/\` for the full report.
|
||||
|
||||
/cc @comfy-pr-bot" \
|
||||
--assignee comfy-pr-bot
|
||||
```
|
||||
|
||||
## Execution Notes
|
||||
|
||||
### Cross-Platform Considerations
|
||||
|
||||
- **Windows**: Use `pwsh` or `cmd` equivalents for shell commands. `gh` CLI works on all platforms.
|
||||
- **macOS**: Keyboard shortcuts use Cmd instead of Ctrl in the actual app, but Playwright sends OS-appropriate keys.
|
||||
- **Linux**: Primary CI platform. Screenshot baselines are Linux-only.
|
||||
|
||||
### Agent Compatibility
|
||||
|
||||
This skill uses **playwright-cli** (`@playwright/cli`) — a token-efficient CLI designed for coding agents. Install it once with `npm install -g @playwright/cli@latest`, then use `Bash` to run commands.
|
||||
|
||||
The key operations and their playwright-cli equivalents:
|
||||
|
||||
| Action | Command |
|
||||
| ---------------- | ---------------------------------------- |
|
||||
| Navigate to URL | `playwright-cli goto <url>` |
|
||||
| Get element refs | `playwright-cli snapshot` |
|
||||
| Click element | `playwright-cli click <ref>` |
|
||||
| Type text | `playwright-cli fill <ref> <text>` |
|
||||
| Press shortcut | `playwright-cli press <key>` |
|
||||
| Take screenshot | `playwright-cli screenshot --filename=f` |
|
||||
| Hover element | `playwright-cli hover <ref>` |
|
||||
| Select dropdown | `playwright-cli select <ref> <value>` |
|
||||
|
||||
Snapshots return element references (`e1`, `e2`, …). Always run `snapshot` after navigation or major interactions to refresh refs before acting.
|
||||
|
||||
### Tips for Reliable QA
|
||||
|
||||
1. **Wait for page stability** before interacting — check that elements are visible and enabled
|
||||
2. **Take a snapshot after each major navigation** to verify state
|
||||
3. **Don't use fixed timeouts** — poll for expected conditions
|
||||
4. **Record the full page snapshot** at the start for baseline comparison
|
||||
5. **If a test fails**, document it and continue — don't abort the entire QA run
|
||||
6. **Group related tests** — complete one category before moving to the next
|
||||
11
.github/actions/setup-comfyui-server/action.yaml
vendored
11
.github/actions/setup-comfyui-server/action.yaml
vendored
@@ -44,12 +44,17 @@ runs:
|
||||
python -m pip install --upgrade pip
|
||||
pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cpu
|
||||
pip install -r requirements.txt
|
||||
pip install wait-for-it
|
||||
|
||||
- name: Start ComfyUI server
|
||||
if: ${{ inputs.launch_server == 'true' }}
|
||||
shell: bash
|
||||
working-directory: ComfyUI
|
||||
env:
|
||||
EXTRA_SERVER_PARAMS: ${{ inputs.extra_server_params }}
|
||||
run: |
|
||||
python main.py --cpu --multi-user --front-end-root ../dist ${{ inputs.extra_server_params }} &
|
||||
wait-for-it --service 127.0.0.1:8188 -t 600
|
||||
python main.py --cpu --multi-user --front-end-root ../dist $EXTRA_SERVER_PARAMS &
|
||||
for i in $(seq 1 300); do
|
||||
curl -sf http://127.0.0.1:8188/api/system_stats >/dev/null 2>&1 && echo "Server ready" && exit 0
|
||||
sleep 2
|
||||
done
|
||||
echo "::error::ComfyUI server did not start within 600s" && exit 1
|
||||
|
||||
1100
.github/workflows/pr-qa.yaml
vendored
Normal file
1100
.github/workflows/pr-qa.yaml
vendored
Normal file
File diff suppressed because it is too large
Load Diff
5
.gitignore
vendored
5
.gitignore
vendored
@@ -99,4 +99,7 @@ vitest.config.*.timestamp*
|
||||
# Weekly docs check output
|
||||
/output.txt
|
||||
|
||||
.amp
|
||||
.amp
|
||||
.playwright-cli/
|
||||
.playwright/
|
||||
.claude/scheduled_tasks.lock
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"packages/registry-types/src/comfyRegistryTypes.ts",
|
||||
"public/materialdesignicons.min.css",
|
||||
"src/types/generatedManagerTypes.ts",
|
||||
"**/__fixtures__/**/*.json"
|
||||
"**/__fixtures__/**/*.json",
|
||||
"scripts/qa-report-template.html"
|
||||
]
|
||||
}
|
||||
|
||||
2
apps/website/.gitignore
vendored
Normal file
2
apps/website/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
dist/
|
||||
.astro/
|
||||
24
apps/website/astro.config.ts
Normal file
24
apps/website/astro.config.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { defineConfig } from 'astro/config'
|
||||
import vue from '@astrojs/vue'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
|
||||
export default defineConfig({
|
||||
site: 'https://comfy.org',
|
||||
output: 'static',
|
||||
integrations: [vue()],
|
||||
vite: {
|
||||
plugins: [tailwindcss()]
|
||||
},
|
||||
build: {
|
||||
assetsPrefix: process.env.VERCEL_URL
|
||||
? `https://${process.env.VERCEL_URL}`
|
||||
: undefined
|
||||
},
|
||||
i18n: {
|
||||
locales: ['en', 'zh-CN'],
|
||||
defaultLocale: 'en',
|
||||
routing: {
|
||||
prefixDefaultLocale: false
|
||||
}
|
||||
}
|
||||
})
|
||||
80
apps/website/package.json
Normal file
80
apps/website/package.json
Normal file
@@ -0,0 +1,80 @@
|
||||
{
|
||||
"name": "@comfyorg/website",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"build": "astro build",
|
||||
"preview": "astro preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@comfyorg/design-system": "workspace:*",
|
||||
"@vercel/analytics": "catalog:",
|
||||
"vue": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@astrojs/vue": "catalog:",
|
||||
"@tailwindcss/vite": "catalog:",
|
||||
"astro": "catalog:",
|
||||
"tailwindcss": "catalog:",
|
||||
"typescript": "catalog:"
|
||||
},
|
||||
"nx": {
|
||||
"tags": [
|
||||
"scope:website",
|
||||
"type:app"
|
||||
],
|
||||
"targets": {
|
||||
"dev": {
|
||||
"executor": "nx:run-commands",
|
||||
"continuous": true,
|
||||
"options": {
|
||||
"cwd": "apps/website",
|
||||
"command": "astro dev"
|
||||
}
|
||||
},
|
||||
"serve": {
|
||||
"executor": "nx:run-commands",
|
||||
"continuous": true,
|
||||
"options": {
|
||||
"cwd": "apps/website",
|
||||
"command": "astro dev"
|
||||
}
|
||||
},
|
||||
"build": {
|
||||
"executor": "nx:run-commands",
|
||||
"cache": true,
|
||||
"dependsOn": [
|
||||
"^build"
|
||||
],
|
||||
"options": {
|
||||
"cwd": "apps/website",
|
||||
"command": "astro build"
|
||||
},
|
||||
"outputs": [
|
||||
"{projectRoot}/dist"
|
||||
]
|
||||
},
|
||||
"preview": {
|
||||
"executor": "nx:run-commands",
|
||||
"continuous": true,
|
||||
"dependsOn": [
|
||||
"build"
|
||||
],
|
||||
"options": {
|
||||
"cwd": "apps/website",
|
||||
"command": "astro preview"
|
||||
}
|
||||
},
|
||||
"typecheck": {
|
||||
"executor": "nx:run-commands",
|
||||
"cache": true,
|
||||
"options": {
|
||||
"cwd": "apps/website",
|
||||
"command": "astro check"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
BIN
apps/website/public/fonts/inter-latin-italic.woff2
Normal file
BIN
apps/website/public/fonts/inter-latin-italic.woff2
Normal file
Binary file not shown.
BIN
apps/website/public/fonts/inter-latin-normal.woff2
Normal file
BIN
apps/website/public/fonts/inter-latin-normal.woff2
Normal file
Binary file not shown.
1
apps/website/src/env.d.ts
vendored
Normal file
1
apps/website/src/env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="astro/client" />
|
||||
2
apps/website/src/styles/global.css
Normal file
2
apps/website/src/styles/global.css
Normal file
@@ -0,0 +1,2 @@
|
||||
@import 'tailwindcss';
|
||||
@import '@comfyorg/design-system/css/base.css';
|
||||
9
apps/website/tsconfig.json
Normal file
9
apps/website/tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "astro/tsconfigs/strict",
|
||||
"compilerOptions": {
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*", "astro.config.mjs"]
|
||||
}
|
||||
@@ -154,45 +154,6 @@ test.describe('Performance', { tag: ['@perf'] }, () => {
|
||||
)
|
||||
})
|
||||
|
||||
test('large graph viewport pan sweep', async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('large-graph-workflow')
|
||||
|
||||
const canvas = comfyPage.canvas
|
||||
const box = await canvas.boundingBox()
|
||||
if (!box) throw new Error('Canvas bounding box not available')
|
||||
|
||||
// Pan aggressively across the full graph so many nodes cross the
|
||||
// viewport boundary, triggering mount/unmount cycles and GC churn.
|
||||
const centerX = box.x + box.width / 2
|
||||
const centerY = box.y + box.height / 2
|
||||
await comfyPage.page.mouse.move(centerX, centerY)
|
||||
await comfyPage.page.mouse.down({ button: 'middle' })
|
||||
|
||||
await comfyPage.perf.startMeasuring()
|
||||
|
||||
// Sweep right (nodes exit left edge, new nodes enter right edge)
|
||||
for (let i = 0; i < 120; i++) {
|
||||
await comfyPage.page.mouse.move(centerX + i * 8, centerY + i * 3)
|
||||
await comfyPage.nextFrame()
|
||||
}
|
||||
// Sweep back left
|
||||
for (let i = 120; i > 0; i--) {
|
||||
await comfyPage.page.mouse.move(centerX + i * 8, centerY + i * 3)
|
||||
await comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
await comfyPage.page.mouse.up({ button: 'middle' })
|
||||
|
||||
const m = await comfyPage.perf.stopMeasuring('viewport-pan-sweep')
|
||||
recordMeasurement(m)
|
||||
console.log(
|
||||
`Viewport pan sweep: ${m.styleRecalcs} recalcs, ${m.layouts} layouts, ` +
|
||||
`${m.taskDurationMs.toFixed(1)}ms task, ` +
|
||||
`heap Δ${(m.heapDeltaBytes / 1024).toFixed(0)}KB, ` +
|
||||
`${m.domNodes} DOM nodes`
|
||||
)
|
||||
})
|
||||
|
||||
test('subgraph DOM widget clipping during node selection', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
|
||||
73
docs/qa/TROUBLESHOOTING.md
Normal file
73
docs/qa/TROUBLESHOOTING.md
Normal file
@@ -0,0 +1,73 @@
|
||||
# QA Pipeline Troubleshooting
|
||||
|
||||
## Common Failures
|
||||
|
||||
### `set -euo pipefail` + grep with no match
|
||||
**Symptom**: Deploy script crashes silently, badge shows FAILED.
|
||||
**Cause**: `grep -oP` returns exit code 1 when no match. Under `pipefail`, this kills the entire script.
|
||||
**Fix**: Always append `|| true` to grep pipelines in bash scripts.
|
||||
|
||||
### `__name is not defined` in page.evaluate
|
||||
**Symptom**: Recording crashes with `ReferenceError: __name is not defined`.
|
||||
**Cause**: tsx compiles arrow functions inside `page.evaluate()` with `__name` helpers. The browser context doesn't have these.
|
||||
**Fix**: Use `page.addScriptTag({ content: '...' })` with plain JS strings instead of `page.evaluate(() => { ... })` with arrow functions.
|
||||
|
||||
### `Set<string>()` in page.evaluate
|
||||
**Symptom**: Same `__name` error.
|
||||
**Cause**: TypeScript generics like `new Set<string>()` get compiled incorrectly for browser context.
|
||||
**Fix**: Use `new Set()` without type parameter.
|
||||
|
||||
### `zod/v4` import error
|
||||
**Symptom**: `ERR_PACKAGE_PATH_NOT_EXPORTED: Package subpath './v4' is not defined`.
|
||||
**Cause**: claude-agent-sdk depends on `zod/v4` internally, but the project's zod doesn't export it.
|
||||
**Fix**: Import from `zod` (not `zod/v4`) in project code.
|
||||
|
||||
### `ERR_PNPM_LOCKFILE_CONFIG_MISMATCH`
|
||||
**Symptom**: pnpm install fails with frozen lockfile mismatch.
|
||||
**Cause**: Adding a new dependency changes the workspace catalog but lockfile wasn't regenerated.
|
||||
**Fix**: Run `pnpm install` to regenerate lockfile, commit `pnpm-workspace.yaml` + `pnpm-lock.yaml`.
|
||||
|
||||
### `loadDefaultWorkflow` — "Load Default" not found
|
||||
**Symptom**: Menu item "Load Default" not found, canvas stays empty.
|
||||
**Cause**: The menu item name varies by version/locale. Menu navigation is fragile.
|
||||
**Fix**: Use `app.resetToDefaultWorkflow()` JS API via `page.evaluate` instead of menu navigation.
|
||||
|
||||
### Model ID not found (Claude Agent SDK)
|
||||
**Symptom**: `There's an issue with the selected model (claude-sonnet-4-6-20250514)`.
|
||||
**Cause**: Dated model IDs like `claude-sonnet-4-6-20250514` don't exist.
|
||||
**Fix**: Use `claude-sonnet-4-6` (no date suffix).
|
||||
|
||||
### Model not found (Gemini)
|
||||
**Symptom**: 404 from Gemini API.
|
||||
**Cause**: Preview model names like `gemini-2.5-flash-preview-05-20` expire.
|
||||
**Fix**: Use `gemini-3-flash-preview` (latest stable).
|
||||
|
||||
## Badge Mismatches
|
||||
|
||||
### False REPRODUCED
|
||||
**Symptom**: Badge says REPRODUCED but AI review says "could not reproduce".
|
||||
**Root cause**: Grep pattern `reproduc|confirm` matches neutral words like "reproduction steps" or "could not be confirmed".
|
||||
**Fix**: Use structured JSON verdict from AI (`## Verdict` section with `{"verdict": "..."}`) instead of regex matching the prose.
|
||||
|
||||
### INCONCLUSIVE feedback loop
|
||||
**Symptom**: Once an issue gets INCONCLUSIVE, all future runs stay INCONCLUSIVE.
|
||||
**Cause**: QA bot's own previous comments contain "INCONCLUSIVE", which gets fed back into pr-context.txt.
|
||||
**Fix**: Filter out `github-actions[bot]` comments when building pr-context.
|
||||
|
||||
### pressKey with hold prevents event propagation
|
||||
**Symptom**: BEFORE video doesn't show the bug (e.g., Escape doesn't close dialog).
|
||||
**Cause**: `keyboard.down()` + 400ms sleep + `keyboard.up()` changes event timing. Some UI frameworks handle held keys differently than instant presses.
|
||||
**Fix**: Use instant `keyboard.press()` for testing. Show key name via subtitle overlay instead.
|
||||
|
||||
## Cursor Not Visible
|
||||
**Symptom**: No mouse cursor in recorded videos.
|
||||
**Cause**: Headless Chrome doesn't render system cursor. The CSS cursor overlay relies on DOM `mousemove` events which Playwright CDP doesn't reliably trigger.
|
||||
**Fix**: Monkey-patch `page.mouse.move/click/dblclick/down/up` to call `__moveCursor(x,y)` on the injected cursor div. This makes ALL mouse operations update the overlay.
|
||||
|
||||
## Agent Doesn't Perform Steps
|
||||
**Symptom**: Agent opens menus and settings but never interacts with the canvas.
|
||||
**Causes**:
|
||||
1. `loadDefaultWorkflow` failed (no nodes on canvas)
|
||||
2. Agent ran out of turn budget (30 turns / 120s)
|
||||
3. Gemini Flash (old agent) ignores prompt hints
|
||||
**Fix**: Use hybrid agent (Claude Sonnet 4.6 + Gemini vision). Claude's superior reasoning follows instructions precisely.
|
||||
59
docs/qa/backlog.md
Normal file
59
docs/qa/backlog.md
Normal file
@@ -0,0 +1,59 @@
|
||||
# QA Pipeline Backlog
|
||||
|
||||
## Comparison Modes
|
||||
|
||||
### Type A: Same code, different settings (IMPLEMENTED)
|
||||
Agent demonstrates both working (control) and broken (test) states in one session by toggling settings. E.g., Nodes 2.0 OFF → drag works, Nodes 2.0 ON → drag broken.
|
||||
|
||||
### Type B: Different commits
|
||||
For regressions reported as "worked in vX.Y, broken in vX.Z":
|
||||
- `qa-analyze-pr.ts` detects regression markers ("since v1.38", "after PR #1234")
|
||||
- Pipeline checks out the old commit, records control video
|
||||
- Records test video on current main
|
||||
- Side-by-side comparison on report page (reuses PR before/after infra)
|
||||
|
||||
### Type C: Different browsers
|
||||
For browser-specific bugs ("works on Chrome, broken on Firefox"):
|
||||
- Run recording with different Playwright browser contexts
|
||||
- Compare behavior across browsers in one report
|
||||
|
||||
## Agent Improvements
|
||||
|
||||
### TTS Narration
|
||||
- OpenAI TTS (`tts-1`, nova voice) generates audio from agent reasoning
|
||||
- Merged into video via ffmpeg at correct timestamps
|
||||
- Currently in qa-record.ts but needs wiring into hybrid agent path
|
||||
|
||||
### Image/Screenshot Reading
|
||||
- `qa-analyze-pr.ts` already downloads and sends images from issue bodies to Gemini
|
||||
- Could also send them to the Claude agent as context ("the reporter showed this screenshot")
|
||||
|
||||
### Placeholder Page
|
||||
- Deploy a status page immediately when CI starts
|
||||
- Auto-refreshes every 30s until final report replaces it
|
||||
- Shows spinner, CI link, badge
|
||||
|
||||
### Pre-seed Assets
|
||||
- Upload test images via ComfyUI API before recording
|
||||
- Enables reproduction of bugs requiring assets (#10424 zoom button)
|
||||
|
||||
### Environment-Dependent Issues
|
||||
- #7942: needs custom TestNode — could install a test custom node pack in CI
|
||||
- #9101: needs completed generation — could run with a tiny model checkpoint
|
||||
|
||||
## Cost Optimization
|
||||
|
||||
### Lazy A11y Tree
|
||||
- `inspect(selector)` searches tree for specific element (~20 tokens)
|
||||
- `getUIChanges()` diffs against previous snapshot (~100 tokens)
|
||||
- vs dumping full tree every turn (~2000 tokens)
|
||||
|
||||
### Gemini Video vs Images
|
||||
- 30s video clip: ~7,700 tokens (258 tok/s)
|
||||
- 15 screenshots: ~19,500 tokens (1,300 tok/frame)
|
||||
- Video is 2.5x cheaper and shows temporal changes
|
||||
|
||||
### Model Selection
|
||||
- Claude Sonnet 4.6: $3/$15 per 1M in/out — best reasoning
|
||||
- Gemini 2.5 Flash: $0.10/$0.40 per 1M — best vision-per-dollar
|
||||
- Hybrid uses each where it's strongest
|
||||
60
docs/qa/models.md
Normal file
60
docs/qa/models.md
Normal file
@@ -0,0 +1,60 @@
|
||||
# QA Pipeline Model Selection
|
||||
|
||||
## Current Configuration
|
||||
|
||||
| Script | Role | Model | Why |
|
||||
| --------------------- | -------------------------------------- | ------------------------ | --------------------------------------------------------------------------------------------------- |
|
||||
| `qa-analyze-pr.ts` | PR/issue analysis, QA guide generation | `gemini-3.1-pro-preview` | Needs deep reasoning over PR diffs, screenshots, and issue threads |
|
||||
| `qa-record.ts` | Playwright step generation | `gemini-3.1-pro-preview` | Step quality is critical — must understand ComfyUI's canvas UI and produce precise action sequences |
|
||||
| `qa-video-review.ts` | Video comparison review | `gemini-3-flash-preview` | Video analysis with structured output; flash is sufficient and faster |
|
||||
| `qa-generate-test.ts` | Regression test generation | `gemini-3-flash-preview` | Code generation from QA reports; flash handles this well |
|
||||
|
||||
## Model Comparison
|
||||
|
||||
### Gemini 3.1 Pro vs GPT-5.4
|
||||
|
||||
| | Gemini 3.1 Pro Preview | GPT-5.4 |
|
||||
| ----------------- | ---------------------- | ----------------- |
|
||||
| Context window | 1M tokens | 1M tokens |
|
||||
| Max output | 65K tokens | 128K tokens |
|
||||
| Video input | Yes | No |
|
||||
| Image input | Yes | Yes |
|
||||
| Audio input | Yes | No |
|
||||
| Pricing (input) | $2/1M tokens | $2.50/1M tokens |
|
||||
| Pricing (output) | $12/1M tokens | $15/1M tokens |
|
||||
| Function calling | Yes | Yes |
|
||||
| Code execution | Yes | Yes (interpreter) |
|
||||
| Structured output | Yes | Yes |
|
||||
|
||||
**Why Gemini over GPT for QA:**
|
||||
|
||||
- Native video understanding (can review recordings directly)
|
||||
- Lower cost at comparable quality
|
||||
- Native multimodal input (screenshots, videos, audio from issue threads)
|
||||
- Better price/performance for high-volume CI usage
|
||||
|
||||
### Gemini 3 Flash vs GPT-5.4 Mini
|
||||
|
||||
| | Gemini 3 Flash Preview | GPT-5.4 Mini |
|
||||
| ---------------- | ---------------------- | --------------- |
|
||||
| Context window | 1M tokens | 1M tokens |
|
||||
| Pricing (input) | $0.50/1M tokens | $0.40/1M tokens |
|
||||
| Pricing (output) | $3/1M tokens | $1.60/1M tokens |
|
||||
| Video input | Yes | No |
|
||||
|
||||
**Why Gemini Flash for video review:**
|
||||
|
||||
- Video input support is required — GPT models cannot process video files
|
||||
- Good enough quality for structured comparison reports
|
||||
|
||||
## Upgrade History
|
||||
|
||||
| Date | Change | Reason |
|
||||
| ---------- | ---------------------------------------------------------------- | -------------------------------------------------------------------------- |
|
||||
| 2026-03-24 | `gemini-2.5-flash` → `gemini-3.1-pro-preview` (record) | Shallow step generation; pro model needed for complex ComfyUI interactions |
|
||||
| 2026-03-24 | `gemini-2.5-pro` → `gemini-3.1-pro-preview` (analyze) | Keep analysis on latest pro |
|
||||
| 2026-03-24 | `gemini-2.5-flash` → `gemini-3-flash-preview` (review, test-gen) | Latest flash for cost-efficient tasks |
|
||||
|
||||
## Override
|
||||
|
||||
All scripts accept `--model <name>` to override the default. Pass any Gemini model ID.
|
||||
@@ -27,9 +27,20 @@ const config: KnipConfig = {
|
||||
},
|
||||
'packages/ingest-types': {
|
||||
project: ['src/**/*.{js,ts}']
|
||||
},
|
||||
'apps/website': {
|
||||
entry: [
|
||||
'src/pages/**/*.astro',
|
||||
'src/layouts/**/*.astro',
|
||||
'src/components/**/*.vue',
|
||||
'src/styles/global.css',
|
||||
'astro.config.ts'
|
||||
],
|
||||
project: ['src/**/*.{astro,vue,ts}', '*.{js,ts,mjs}'],
|
||||
ignoreDependencies: ['@comfyorg/design-system', '@vercel/analytics']
|
||||
}
|
||||
},
|
||||
ignoreBinaries: ['python3'],
|
||||
ignoreBinaries: ['python3', 'wrangler'],
|
||||
ignoreDependencies: [
|
||||
// Weird importmap things
|
||||
'@iconify-json/lucide',
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
"preinstall": "pnpm dlx only-allow pnpm",
|
||||
"prepare": "husky || true && git config blame.ignoreRevsFile .git-blame-ignore-revs || true",
|
||||
"preview": "nx preview",
|
||||
"qa:video-review": "tsx scripts/qa-video-review.ts",
|
||||
"storybook": "nx storybook",
|
||||
"storybook:desktop": "nx run @comfyorg/desktop-ui:storybook",
|
||||
"stylelint:fix": "stylelint --cache --fix '{apps,packages,src}/**/*.{css,vue}'",
|
||||
@@ -120,7 +121,9 @@
|
||||
"zod-validation-error": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "catalog:",
|
||||
"@eslint/js": "catalog:",
|
||||
"@google/generative-ai": "catalog:",
|
||||
"@intlify/eslint-plugin-vue-i18n": "catalog:",
|
||||
"@lobehub/i18n-cli": "catalog:",
|
||||
"@nx/eslint": "catalog:",
|
||||
|
||||
46
packages/design-system/src/css/base.css
Normal file
46
packages/design-system/src/css/base.css
Normal file
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
* Design System Base — Brand tokens + fonts only.
|
||||
* For marketing sites that don't use PrimeVue or the node editor.
|
||||
* Import the full style.css instead for the desktop app.
|
||||
*/
|
||||
|
||||
@import './fonts.css';
|
||||
|
||||
@theme {
|
||||
/* Font Families */
|
||||
--font-inter: 'Inter', sans-serif;
|
||||
|
||||
/* Palette Colors */
|
||||
--color-charcoal-100: #55565e;
|
||||
--color-charcoal-200: #494a50;
|
||||
--color-charcoal-300: #3c3d42;
|
||||
--color-charcoal-400: #313235;
|
||||
--color-charcoal-500: #2d2e32;
|
||||
--color-charcoal-600: #262729;
|
||||
--color-charcoal-700: #202121;
|
||||
--color-charcoal-800: #171718;
|
||||
|
||||
--color-neutral-550: #636363;
|
||||
|
||||
--color-ash-300: #bbbbbb;
|
||||
--color-ash-500: #828282;
|
||||
--color-ash-800: #444444;
|
||||
|
||||
--color-smoke-100: #f3f3f3;
|
||||
--color-smoke-200: #e9e9e9;
|
||||
--color-smoke-300: #e1e1e1;
|
||||
--color-smoke-400: #d9d9d9;
|
||||
--color-smoke-500: #c5c5c5;
|
||||
--color-smoke-600: #b4b4b4;
|
||||
--color-smoke-700: #a0a0a0;
|
||||
--color-smoke-800: #8a8a8a;
|
||||
|
||||
--color-white: #ffffff;
|
||||
--color-black: #000000;
|
||||
|
||||
/* Brand Colors */
|
||||
--color-electric-400: #f0ff41;
|
||||
--color-sapphire-700: #172dd7;
|
||||
--color-brand-yellow: var(--color-electric-400);
|
||||
--color-brand-blue: var(--color-sapphire-700);
|
||||
}
|
||||
2124
pnpm-lock.yaml
generated
2124
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -4,9 +4,12 @@ packages:
|
||||
|
||||
catalog:
|
||||
'@alloc/quick-lru': ^5.2.0
|
||||
'@anthropic-ai/claude-agent-sdk': ^0.2.85
|
||||
'@astrojs/vue': ^5.0.0
|
||||
'@comfyorg/comfyui-electron-types': 0.6.2
|
||||
'@eslint/js': ^9.39.1
|
||||
'@formkit/auto-animate': ^0.9.0
|
||||
'@google/generative-ai': ^0.24.1
|
||||
'@iconify-json/lucide': ^1.1.178
|
||||
'@iconify/json': ^2.2.380
|
||||
'@iconify/tailwind4': ^1.2.0
|
||||
@@ -50,6 +53,7 @@ catalog:
|
||||
'@types/node': ^24.1.0
|
||||
'@types/semver': ^7.7.0
|
||||
'@types/three': ^0.169.0
|
||||
'@vercel/analytics': ^2.0.1
|
||||
'@vitejs/plugin-vue': ^6.0.0
|
||||
'@vitest/coverage-v8': ^4.0.16
|
||||
'@vitest/ui': ^4.0.16
|
||||
@@ -58,6 +62,7 @@ catalog:
|
||||
'@vueuse/integrations': ^14.2.0
|
||||
'@webgpu/types': ^0.1.66
|
||||
algoliasearch: ^5.21.0
|
||||
astro: ^5.10.0
|
||||
axios: ^1.13.5
|
||||
cross-env: ^10.1.0
|
||||
cva: 1.0.0-beta.4
|
||||
|
||||
347
scripts/qa-agent.ts
Normal file
347
scripts/qa-agent.ts
Normal file
@@ -0,0 +1,347 @@
|
||||
#!/usr/bin/env tsx
|
||||
/**
|
||||
* QA Research Phase — Claude writes & debugs E2E tests to reproduce bugs
|
||||
*
|
||||
* Instead of driving a browser interactively, Claude:
|
||||
* 1. Reads the issue + a11y snapshot of the UI
|
||||
* 2. Writes a Playwright E2E test (.spec.ts) that reproduces the bug
|
||||
* 3. Runs the test → reads errors → rewrites → repeats until it works
|
||||
* 4. Outputs the passing test + verdict
|
||||
*
|
||||
* Tools:
|
||||
* - inspect(selector) — read a11y tree to understand UI state
|
||||
* - writeTest(code) — write a Playwright test file
|
||||
* - runTest() — execute the test and get results
|
||||
* - done(verdict, summary, testCode) — finish with the working test
|
||||
*/
|
||||
|
||||
import type { Page } from '@playwright/test'
|
||||
import { query, tool, createSdkMcpServer } from '@anthropic-ai/claude-agent-sdk'
|
||||
import { z } from 'zod'
|
||||
import { mkdirSync, writeFileSync } from 'fs'
|
||||
import { execSync } from 'child_process'
|
||||
|
||||
// ── Types ──
|
||||
|
||||
interface ResearchOptions {
|
||||
page: Page
|
||||
issueContext: string
|
||||
qaGuide: string
|
||||
outputDir: string
|
||||
serverUrl: string
|
||||
anthropicApiKey?: string
|
||||
maxTurns?: number
|
||||
timeBudgetMs?: number
|
||||
}
|
||||
|
||||
export interface ResearchResult {
|
||||
verdict: 'REPRODUCED' | 'NOT_REPRODUCIBLE' | 'INCONCLUSIVE'
|
||||
summary: string
|
||||
evidence: string
|
||||
testCode: string
|
||||
log: Array<{
|
||||
turn: number
|
||||
timestampMs: number
|
||||
toolName: string
|
||||
toolInput: unknown
|
||||
toolResult: string
|
||||
}>
|
||||
}
|
||||
|
||||
// ── Main research function ──
|
||||
|
||||
export async function runResearchPhase(
|
||||
opts: ResearchOptions
|
||||
): Promise<ResearchResult> {
|
||||
const { page, issueContext, qaGuide, outputDir, serverUrl, anthropicApiKey } =
|
||||
opts
|
||||
const maxTurns = opts.maxTurns ?? 50
|
||||
const timeBudgetMs = opts.timeBudgetMs ?? 600_000 // 10 min for write→run→fix loops
|
||||
|
||||
let agentDone = false
|
||||
let finalVerdict: ResearchResult['verdict'] = 'INCONCLUSIVE'
|
||||
let finalSummary = 'Agent did not complete'
|
||||
let finalEvidence = ''
|
||||
let finalTestCode = ''
|
||||
let turnCount = 0
|
||||
const startTime = Date.now()
|
||||
const researchLog: ResearchResult['log'] = []
|
||||
|
||||
const testDir = `${outputDir}/research`
|
||||
mkdirSync(testDir, { recursive: true })
|
||||
const testPath = `${testDir}/reproduce.spec.ts`
|
||||
|
||||
// Get initial a11y snapshot for context
|
||||
let initialA11y = ''
|
||||
try {
|
||||
initialA11y = await page.locator('body').ariaSnapshot({ timeout: 5000 })
|
||||
initialA11y = initialA11y.slice(0, 3000)
|
||||
} catch {
|
||||
initialA11y = '(could not capture initial a11y snapshot)'
|
||||
}
|
||||
|
||||
// ── Tool: inspect ──
|
||||
const inspectTool = tool(
|
||||
'inspect',
|
||||
'Read the current accessibility tree to understand UI state. Use this to discover element names, roles, and selectors for your test.',
|
||||
{
|
||||
selector: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'Optional filter — only show elements matching this name/role. Omit for full tree.'
|
||||
)
|
||||
},
|
||||
async (args) => {
|
||||
let resultText: string
|
||||
try {
|
||||
const ariaText = await page
|
||||
.locator('body')
|
||||
.ariaSnapshot({ timeout: 5000 })
|
||||
if (args.selector) {
|
||||
const lines = ariaText.split('\n')
|
||||
const matches = lines.filter((l: string) =>
|
||||
l.toLowerCase().includes(args.selector!.toLowerCase())
|
||||
)
|
||||
resultText =
|
||||
matches.length > 0
|
||||
? `Found "${args.selector}":\n${matches.slice(0, 15).join('\n')}`
|
||||
: `"${args.selector}" not found. Full tree:\n${ariaText.slice(0, 2000)}`
|
||||
} else {
|
||||
resultText = ariaText.slice(0, 3000)
|
||||
}
|
||||
} catch (e) {
|
||||
resultText = `inspect failed: ${e instanceof Error ? e.message : e}`
|
||||
}
|
||||
|
||||
researchLog.push({
|
||||
turn: turnCount,
|
||||
timestampMs: Date.now() - startTime,
|
||||
toolName: 'inspect',
|
||||
toolInput: args,
|
||||
toolResult: resultText.slice(0, 500)
|
||||
})
|
||||
|
||||
return { content: [{ type: 'text' as const, text: resultText }] }
|
||||
}
|
||||
)
|
||||
|
||||
// ── Tool: writeTest ──
|
||||
const writeTestTool = tool(
|
||||
'writeTest',
|
||||
'Write a Playwright E2E test file that reproduces the bug. The test should assert the broken behavior exists.',
|
||||
{
|
||||
code: z
|
||||
.string()
|
||||
.describe('Complete Playwright test file content (.spec.ts)')
|
||||
},
|
||||
async (args) => {
|
||||
writeFileSync(testPath, args.code)
|
||||
|
||||
researchLog.push({
|
||||
turn: turnCount,
|
||||
timestampMs: Date.now() - startTime,
|
||||
toolName: 'writeTest',
|
||||
toolInput: { path: testPath, codeLength: args.code.length },
|
||||
toolResult: `Test written to ${testPath} (${args.code.length} chars)`
|
||||
})
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: `Test written to ${testPath}. Use runTest() to execute it.`
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// ── Tool: runTest ──
|
||||
// Place test in browser_tests/ so Playwright config finds fixtures
|
||||
const projectRoot = process.cwd()
|
||||
const browserTestPath = `${projectRoot}/browser_tests/tests/qa-reproduce.spec.ts`
|
||||
|
||||
const runTestTool = tool(
|
||||
'runTest',
|
||||
'Run the Playwright test and get results. Returns stdout/stderr including assertion errors.',
|
||||
{},
|
||||
async () => {
|
||||
turnCount++
|
||||
// Copy the test to browser_tests/tests/ where Playwright expects it
|
||||
const { copyFileSync } = await import('fs')
|
||||
try {
|
||||
copyFileSync(testPath, browserTestPath)
|
||||
} catch {
|
||||
// directory may not exist
|
||||
mkdirSync(`${projectRoot}/browser_tests/tests`, { recursive: true })
|
||||
copyFileSync(testPath, browserTestPath)
|
||||
}
|
||||
|
||||
let resultText: string
|
||||
try {
|
||||
const output = execSync(
|
||||
`cd "${projectRoot}" && npx playwright test browser_tests/tests/qa-reproduce.spec.ts --reporter=list --timeout=30000 --retries=0 --workers=1 2>&1`,
|
||||
{
|
||||
timeout: 90000,
|
||||
encoding: 'utf-8',
|
||||
env: {
|
||||
...process.env,
|
||||
COMFYUI_BASE_URL: serverUrl
|
||||
}
|
||||
}
|
||||
)
|
||||
resultText = `TEST PASSED:\n${output.slice(-1500)}`
|
||||
} catch (e) {
|
||||
const err = e as { stdout?: string; stderr?: string; message?: string }
|
||||
const output = (err.stdout || '') + '\n' + (err.stderr || '')
|
||||
resultText = `TEST FAILED:\n${output.slice(-2000)}`
|
||||
}
|
||||
|
||||
researchLog.push({
|
||||
turn: turnCount,
|
||||
timestampMs: Date.now() - startTime,
|
||||
toolName: 'runTest',
|
||||
toolInput: { testPath },
|
||||
toolResult: resultText.slice(0, 1000)
|
||||
})
|
||||
|
||||
return { content: [{ type: 'text' as const, text: resultText }] }
|
||||
}
|
||||
)
|
||||
|
||||
// ── Tool: done ──
|
||||
const doneTool = tool(
|
||||
'done',
|
||||
'Finish research with verdict and the final test code.',
|
||||
{
|
||||
verdict: z.enum(['REPRODUCED', 'NOT_REPRODUCIBLE', 'INCONCLUSIVE']),
|
||||
summary: z.string().describe('What you found and why'),
|
||||
evidence: z.string().describe('Test output that proves the verdict'),
|
||||
testCode: z
|
||||
.string()
|
||||
.describe(
|
||||
'Final Playwright test code. If REPRODUCED, this test asserts the bug exists and passes.'
|
||||
)
|
||||
},
|
||||
async (args) => {
|
||||
agentDone = true
|
||||
finalVerdict = args.verdict
|
||||
finalSummary = args.summary
|
||||
finalEvidence = args.evidence
|
||||
finalTestCode = args.testCode
|
||||
writeFileSync(testPath, args.testCode)
|
||||
return {
|
||||
content: [
|
||||
{ type: 'text' as const, text: `Research complete: ${args.verdict}` }
|
||||
]
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// ── MCP Server ──
|
||||
const server = createSdkMcpServer({
|
||||
name: 'qa-research',
|
||||
version: '1.0.0',
|
||||
tools: [inspectTool, writeTestTool, runTestTool, doneTool]
|
||||
})
|
||||
|
||||
// ── System prompt ──
|
||||
const systemPrompt = `You are a senior QA engineer who writes Playwright E2E tests to reproduce reported bugs.
|
||||
|
||||
## Your tools
|
||||
- inspect(selector?) — Read the accessibility tree to understand the current UI. Use to discover selectors, element names, and UI state.
|
||||
- writeTest(code) — Write a Playwright test file (.spec.ts)
|
||||
- runTest() — Execute the test and get results (pass/fail + errors)
|
||||
- done(verdict, summary, evidence, testCode) — Finish with the final test
|
||||
|
||||
## Workflow
|
||||
1. Read the issue description carefully
|
||||
2. Use inspect() to understand the current UI state and discover element selectors
|
||||
3. Write a Playwright test that:
|
||||
- Navigates to ${serverUrl}
|
||||
- Performs the exact reproduction steps from the issue
|
||||
- Asserts the BROKEN behavior (the bug) — so the test PASSES when the bug exists
|
||||
4. Run the test with runTest()
|
||||
5. If it fails: read the error, fix the test, run again (max 5 attempts)
|
||||
6. Call done() with the final verdict and test code
|
||||
|
||||
## Test writing guidelines
|
||||
- Import the project fixture: \`import { comfyPageFixture as test } from '../fixtures/ComfyPage'\`
|
||||
- Import expect: \`import { expect } from '@playwright/test'\`
|
||||
- The fixture provides \`comfyPage\` which has:
|
||||
- \`comfyPage.page\` — the Playwright Page object
|
||||
- \`comfyPage.menu.topbar\` — topbar actions (saveWorkflowAs, getTabNames, getWorkflowTab)
|
||||
- \`comfyPage.menu.topbar.triggerTopbarCommand(label)\` — click a menu command
|
||||
- \`comfyPage.workflow\` — workflow helpers (isCurrentWorkflowModified, setupWorkflowsDirectory)
|
||||
- \`comfyPage.canvas\` — canvas element for mouse interactions
|
||||
- \`comfyPage.settings.setSetting(id, value)\` — change settings
|
||||
- \`comfyPage.nextFrame()\` — wait for next render frame
|
||||
- \`comfyPage.loadWorkflow(name)\` — load a named workflow
|
||||
- Use beforeEach to set up settings and workflow directory
|
||||
- Use afterEach to clean up (setupWorkflowsDirectory({}))
|
||||
- If the bug IS present, the test should PASS. If the bug is fixed, the test would FAIL.
|
||||
- Keep tests focused and minimal — test ONLY the reported bug
|
||||
- The test file will be placed in browser_tests/tests/qa-reproduce.spec.ts
|
||||
|
||||
## Current UI state (accessibility tree)
|
||||
${initialA11y}
|
||||
|
||||
${qaGuide ? `## QA Analysis Guide\n${qaGuide}\n` : ''}
|
||||
## Issue to Reproduce
|
||||
${issueContext}`
|
||||
|
||||
// ── Run the agent ──
|
||||
console.warn('Starting research phase (Claude writes E2E tests)...')
|
||||
|
||||
try {
|
||||
for await (const message of query({
|
||||
prompt:
|
||||
'Write a Playwright E2E test that reproduces the reported bug. Use inspect() to discover selectors, writeTest() to write the test, runTest() to execute it. Iterate until it works or you determine the bug cannot be reproduced.',
|
||||
options: {
|
||||
model: 'claude-sonnet-4-6',
|
||||
systemPrompt,
|
||||
...(anthropicApiKey ? { apiKey: anthropicApiKey } : {}),
|
||||
maxTurns,
|
||||
mcpServers: { 'qa-research': server },
|
||||
allowedTools: [
|
||||
'mcp__qa-research__inspect',
|
||||
'mcp__qa-research__writeTest',
|
||||
'mcp__qa-research__runTest',
|
||||
'mcp__qa-research__done'
|
||||
]
|
||||
}
|
||||
})) {
|
||||
if (message.type === 'assistant' && message.message?.content) {
|
||||
for (const block of message.message.content) {
|
||||
if ('text' in block && block.text) {
|
||||
console.warn(` Claude: ${block.text.slice(0, 200)}`)
|
||||
}
|
||||
if ('name' in block) {
|
||||
console.warn(
|
||||
` Tool: ${block.name}(${JSON.stringify(block.input).slice(0, 100)})`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (agentDone) break
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(`Research error: ${e instanceof Error ? e.message : e}`)
|
||||
}
|
||||
|
||||
const result: ResearchResult = {
|
||||
verdict: finalVerdict,
|
||||
summary: finalSummary,
|
||||
evidence: finalEvidence,
|
||||
testCode: finalTestCode,
|
||||
log: researchLog
|
||||
}
|
||||
|
||||
writeFileSync(`${testDir}/research-log.json`, JSON.stringify(result, null, 2))
|
||||
console.warn(
|
||||
`Research complete: ${finalVerdict} (${researchLog.length} tool calls)`
|
||||
)
|
||||
|
||||
return result
|
||||
}
|
||||
84
scripts/qa-analyze-pr.test.ts
Normal file
84
scripts/qa-analyze-pr.test.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { extractMediaUrls } from './qa-analyze-pr'
|
||||
|
||||
describe('extractMediaUrls', () => {
|
||||
it('extracts markdown image URLs', () => {
|
||||
const text = ''
|
||||
expect(extractMediaUrls(text)).toEqual(['https://example.com/image.png'])
|
||||
})
|
||||
|
||||
it('extracts multiple markdown images', () => {
|
||||
const text = [
|
||||
'',
|
||||
'Some text',
|
||||
''
|
||||
].join('\n')
|
||||
expect(extractMediaUrls(text)).toEqual([
|
||||
'https://example.com/before.png',
|
||||
'https://example.com/after.jpg'
|
||||
])
|
||||
})
|
||||
|
||||
it('extracts raw URLs with media extensions', () => {
|
||||
const text = 'Check this: https://cdn.example.com/demo.mp4 for details'
|
||||
expect(extractMediaUrls(text)).toEqual(['https://cdn.example.com/demo.mp4'])
|
||||
})
|
||||
|
||||
it('extracts GitHub user-attachments URLs', () => {
|
||||
const text =
|
||||
'https://github.com/user-attachments/assets/abc12345-6789-0def-1234-567890abcdef'
|
||||
expect(extractMediaUrls(text)).toEqual([
|
||||
'https://github.com/user-attachments/assets/abc12345-6789-0def-1234-567890abcdef'
|
||||
])
|
||||
})
|
||||
|
||||
it('extracts private-user-images URLs', () => {
|
||||
const text =
|
||||
'https://private-user-images.githubusercontent.com/12345/abcdef-1234?jwt=token123'
|
||||
expect(extractMediaUrls(text)).toEqual([
|
||||
'https://private-user-images.githubusercontent.com/12345/abcdef-1234?jwt=token123'
|
||||
])
|
||||
})
|
||||
|
||||
it('extracts URLs with query parameters', () => {
|
||||
const text = 'https://example.com/image.png?w=800&h=600'
|
||||
expect(extractMediaUrls(text)).toEqual([
|
||||
'https://example.com/image.png?w=800&h=600'
|
||||
])
|
||||
})
|
||||
|
||||
it('deduplicates URLs', () => {
|
||||
const text = [
|
||||
'',
|
||||
'',
|
||||
'Also https://example.com/same.png'
|
||||
].join('\n')
|
||||
expect(extractMediaUrls(text)).toEqual(['https://example.com/same.png'])
|
||||
})
|
||||
|
||||
it('returns empty array for empty input', () => {
|
||||
expect(extractMediaUrls('')).toEqual([])
|
||||
})
|
||||
|
||||
it('returns empty array for text with no media URLs', () => {
|
||||
expect(extractMediaUrls('Just some text without any URLs')).toEqual([])
|
||||
})
|
||||
|
||||
it('handles mixed media types', () => {
|
||||
const text = [
|
||||
'',
|
||||
'Video: https://example.com/demo.webm',
|
||||
''
|
||||
].join('\n')
|
||||
const urls = extractMediaUrls(text)
|
||||
expect(urls).toContain('https://example.com/screenshot.png')
|
||||
expect(urls).toContain('https://example.com/demo.webm')
|
||||
expect(urls).toContain('https://example.com/animation.gif')
|
||||
})
|
||||
|
||||
it('ignores non-http URLs in markdown', () => {
|
||||
const text = ''
|
||||
expect(extractMediaUrls(text)).toEqual([])
|
||||
})
|
||||
})
|
||||
799
scripts/qa-analyze-pr.ts
Normal file
799
scripts/qa-analyze-pr.ts
Normal file
@@ -0,0 +1,799 @@
|
||||
#!/usr/bin/env tsx
|
||||
/**
|
||||
* QA PR Analysis Script
|
||||
*
|
||||
* Deeply analyzes a PR using Gemini Pro to generate targeted QA guides
|
||||
* for before/after recording sessions. Fetches PR thread, extracts media,
|
||||
* and produces structured test plans.
|
||||
*
|
||||
* Usage:
|
||||
* pnpm exec tsx scripts/qa-analyze-pr.ts \
|
||||
* --pr-number 10270 \
|
||||
* --repo owner/repo \
|
||||
* --output-dir qa-guides/ \
|
||||
* [--model gemini-3.1-pro-preview]
|
||||
*
|
||||
* Env: GEMINI_API_KEY (required)
|
||||
*/
|
||||
|
||||
import { execSync } from 'node:child_process'
|
||||
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { GoogleGenerativeAI } from '@google/generative-ai'
|
||||
|
||||
// ── Types ──
|
||||
|
||||
interface QaGuideStep {
|
||||
action: string
|
||||
description: string
|
||||
expected_before?: string
|
||||
expected_after?: string
|
||||
}
|
||||
|
||||
interface QaGuide {
|
||||
summary: string
|
||||
test_focus: string
|
||||
prerequisites: string[]
|
||||
steps: QaGuideStep[]
|
||||
visual_checks: string[]
|
||||
}
|
||||
|
||||
interface PrThread {
|
||||
title: string
|
||||
body: string
|
||||
labels: string[]
|
||||
issueComments: string[]
|
||||
reviewComments: string[]
|
||||
reviews: string[]
|
||||
diff: string
|
||||
}
|
||||
|
||||
type TargetType = 'pr' | 'issue'
|
||||
|
||||
interface Options {
|
||||
prNumber: string
|
||||
repo: string
|
||||
outputDir: string
|
||||
model: string
|
||||
apiKey: string
|
||||
mediaBudgetBytes: number
|
||||
maxVideoBytes: number
|
||||
type: TargetType
|
||||
}
|
||||
|
||||
// ── CLI parsing ──
|
||||
|
||||
function parseArgs(): Options {
|
||||
const args = process.argv.slice(2)
|
||||
const opts: Partial<Options> = {
|
||||
model: 'gemini-3.1-pro-preview',
|
||||
apiKey: process.env.GEMINI_API_KEY || '',
|
||||
mediaBudgetBytes: 20 * 1024 * 1024,
|
||||
maxVideoBytes: 10 * 1024 * 1024,
|
||||
type: 'pr'
|
||||
}
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
switch (args[i]) {
|
||||
case '--pr-number':
|
||||
opts.prNumber = args[++i]
|
||||
break
|
||||
case '--repo':
|
||||
opts.repo = args[++i]
|
||||
break
|
||||
case '--output-dir':
|
||||
opts.outputDir = args[++i]
|
||||
break
|
||||
case '--model':
|
||||
opts.model = args[++i]
|
||||
break
|
||||
case '--type':
|
||||
opts.type = args[++i] as TargetType
|
||||
break
|
||||
case '--help':
|
||||
console.warn(
|
||||
'Usage: qa-analyze-pr.ts --pr-number <num> --repo <owner/repo> --output-dir <path> [--model <model>] [--type pr|issue]'
|
||||
)
|
||||
process.exit(0)
|
||||
}
|
||||
}
|
||||
|
||||
if (!opts.prNumber || !opts.repo || !opts.outputDir) {
|
||||
console.error(
|
||||
'Required: --pr-number <num> --repo <owner/repo> --output-dir <path>'
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
if (!opts.apiKey) {
|
||||
console.error('GEMINI_API_KEY environment variable is required')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
return opts as Options
|
||||
}
|
||||
|
||||
// ── PR thread fetching ──
|
||||
|
||||
function ghExec(cmd: string): string {
|
||||
try {
|
||||
return execSync(cmd, {
|
||||
encoding: 'utf-8',
|
||||
timeout: 30_000,
|
||||
stdio: ['pipe', 'pipe', 'pipe']
|
||||
}).trim()
|
||||
} catch (err) {
|
||||
console.warn(`gh command failed: ${cmd}`)
|
||||
console.warn((err as Error).message)
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
function fetchPrThread(prNumber: string, repo: string): PrThread {
|
||||
console.warn('Fetching PR thread...')
|
||||
|
||||
const prView = ghExec(
|
||||
`gh pr view ${prNumber} --repo ${repo} --json title,body,labels`
|
||||
)
|
||||
const prData = prView
|
||||
? JSON.parse(prView)
|
||||
: { title: '', body: '', labels: [] }
|
||||
|
||||
const issueCommentsRaw = ghExec(
|
||||
`gh api repos/${repo}/issues/${prNumber}/comments --paginate`
|
||||
)
|
||||
const issueComments: string[] = issueCommentsRaw
|
||||
? JSON.parse(issueCommentsRaw).map((c: { body: string }) => c.body)
|
||||
: []
|
||||
|
||||
const reviewCommentsRaw = ghExec(
|
||||
`gh api repos/${repo}/pulls/${prNumber}/comments --paginate`
|
||||
)
|
||||
const reviewComments: string[] = reviewCommentsRaw
|
||||
? JSON.parse(reviewCommentsRaw).map((c: { body: string }) => c.body)
|
||||
: []
|
||||
|
||||
const reviewsRaw = ghExec(
|
||||
`gh api repos/${repo}/pulls/${prNumber}/reviews --paginate`
|
||||
)
|
||||
const reviews: string[] = reviewsRaw
|
||||
? JSON.parse(reviewsRaw)
|
||||
.filter((r: { body: string }) => r.body)
|
||||
.map((r: { body: string }) => r.body)
|
||||
: []
|
||||
|
||||
const diff = ghExec(`gh pr diff ${prNumber} --repo ${repo}`)
|
||||
|
||||
console.warn(
|
||||
`PR #${prNumber}: "${prData.title}" | ` +
|
||||
`${issueComments.length} issue comments, ` +
|
||||
`${reviewComments.length} review comments, ` +
|
||||
`${reviews.length} reviews, ` +
|
||||
`diff: ${diff.length} chars`
|
||||
)
|
||||
|
||||
return {
|
||||
title: prData.title || '',
|
||||
body: prData.body || '',
|
||||
labels: (prData.labels || []).map((l: { name: string }) => l.name),
|
||||
issueComments,
|
||||
reviewComments,
|
||||
reviews,
|
||||
diff
|
||||
}
|
||||
}
|
||||
|
||||
interface IssueThread {
|
||||
title: string
|
||||
body: string
|
||||
labels: string[]
|
||||
comments: string[]
|
||||
}
|
||||
|
||||
function fetchIssueThread(issueNumber: string, repo: string): IssueThread {
|
||||
console.warn('Fetching issue thread...')
|
||||
|
||||
const issueView = ghExec(
|
||||
`gh issue view ${issueNumber} --repo ${repo} --json title,body,labels`
|
||||
)
|
||||
const issueData = issueView
|
||||
? JSON.parse(issueView)
|
||||
: { title: '', body: '', labels: [] }
|
||||
|
||||
const commentsRaw = ghExec(
|
||||
`gh api repos/${repo}/issues/${issueNumber}/comments --paginate`
|
||||
)
|
||||
const comments: string[] = commentsRaw
|
||||
? JSON.parse(commentsRaw).map((c: { body: string }) => c.body)
|
||||
: []
|
||||
|
||||
console.warn(
|
||||
`Issue #${issueNumber}: "${issueData.title}" | ` +
|
||||
`${comments.length} comments`
|
||||
)
|
||||
|
||||
return {
|
||||
title: issueData.title || '',
|
||||
body: issueData.body || '',
|
||||
labels: (issueData.labels || []).map((l: { name: string }) => l.name),
|
||||
comments
|
||||
}
|
||||
}
|
||||
|
||||
// ── Media extraction ──
|
||||
|
||||
const MEDIA_EXTENSIONS = /\.(png|jpg|jpeg|gif|webp|mp4|webm|mov)$/i
|
||||
|
||||
const MEDIA_URL_PATTERNS = [
|
||||
// Markdown images: 
|
||||
/!\[[^\]]*\]\(([^)]+)\)/g,
|
||||
// GitHub user-attachments
|
||||
/https:\/\/github\.com\/user-attachments\/assets\/[a-f0-9-]+/g,
|
||||
// Private user images
|
||||
/https:\/\/private-user-images\.githubusercontent\.com\/[^\s)"]+/g,
|
||||
// Raw URLs with media extensions (standalone or in text)
|
||||
/(?<!="|=')https?:\/\/[^\s)<>"]+\.(?:png|jpg|jpeg|gif|webp|mp4|webm|mov)(?:\?[^\s)<>"]*)?/gi
|
||||
]
|
||||
|
||||
export function extractMediaUrls(text: string): string[] {
|
||||
if (!text) return []
|
||||
|
||||
const urls = new Set<string>()
|
||||
|
||||
for (const pattern of MEDIA_URL_PATTERNS) {
|
||||
// Reset lastIndex for global patterns
|
||||
pattern.lastIndex = 0
|
||||
let match: RegExpExecArray | null
|
||||
while ((match = pattern.exec(text)) !== null) {
|
||||
// For markdown images, the URL is in capture group 1
|
||||
const url = match[1] || match[0]
|
||||
// Clean trailing markdown/html artifacts
|
||||
const cleaned = url.replace(/[)>"'\s]+$/, '')
|
||||
if (cleaned.startsWith('http')) {
|
||||
urls.add(cleaned)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [...urls]
|
||||
}
|
||||
|
||||
// ── Media downloading ──
|
||||
|
||||
const ALLOWED_MEDIA_DOMAINS = [
|
||||
'github.com',
|
||||
'raw.githubusercontent.com',
|
||||
'user-images.githubusercontent.com',
|
||||
'private-user-images.githubusercontent.com',
|
||||
'objects.githubusercontent.com',
|
||||
'github.githubassets.com'
|
||||
]
|
||||
|
||||
function isAllowedMediaDomain(url: string): boolean {
|
||||
try {
|
||||
const hostname = new URL(url).hostname
|
||||
return ALLOWED_MEDIA_DOMAINS.some(
|
||||
(domain) => hostname === domain || hostname.endsWith(`.${domain}`)
|
||||
)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadMedia(
|
||||
urls: string[],
|
||||
outputDir: string,
|
||||
budgetBytes: number,
|
||||
maxVideoBytes: number
|
||||
): Promise<Array<{ path: string; mimeType: string }>> {
|
||||
const downloaded: Array<{ path: string; mimeType: string }> = []
|
||||
let totalBytes = 0
|
||||
|
||||
const mediaDir = resolve(outputDir, 'media')
|
||||
mkdirSync(mediaDir, { recursive: true })
|
||||
|
||||
for (const url of urls) {
|
||||
if (totalBytes >= budgetBytes) {
|
||||
console.warn(
|
||||
`Media budget exhausted (${totalBytes} bytes), skipping rest`
|
||||
)
|
||||
break
|
||||
}
|
||||
|
||||
if (!isAllowedMediaDomain(url)) {
|
||||
console.warn(`Skipping non-GitHub URL: ${url.slice(0, 80)}`)
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
headers: { Accept: 'image/*,video/*' },
|
||||
redirect: 'follow'
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
console.warn(`Failed to download ${url}: ${response.status}`)
|
||||
continue
|
||||
}
|
||||
|
||||
const contentLength = response.headers.get('content-length')
|
||||
if (contentLength) {
|
||||
const declaredSize = Number.parseInt(contentLength, 10)
|
||||
if (declaredSize > budgetBytes - totalBytes) {
|
||||
console.warn(
|
||||
`Content-Length ${declaredSize} would exceed budget, skipping ${url}`
|
||||
)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
const contentType = response.headers.get('content-type') || ''
|
||||
const buffer = Buffer.from(await response.arrayBuffer())
|
||||
|
||||
// Skip oversized videos
|
||||
const isVideo =
|
||||
contentType.startsWith('video/') || /\.(mp4|webm|mov)$/i.test(url)
|
||||
if (isVideo && buffer.length > maxVideoBytes) {
|
||||
console.warn(
|
||||
`Skipping large video ${url} (${(buffer.length / 1024 / 1024).toFixed(1)}MB > ${(maxVideoBytes / 1024 / 1024).toFixed(0)}MB cap)`
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
if (totalBytes + buffer.length > budgetBytes) {
|
||||
console.warn(`Would exceed budget, skipping ${url}`)
|
||||
continue
|
||||
}
|
||||
|
||||
const ext = guessExtension(url, contentType)
|
||||
const filename = `media-${downloaded.length}${ext}`
|
||||
const filepath = resolve(mediaDir, filename)
|
||||
writeFileSync(filepath, buffer)
|
||||
totalBytes += buffer.length
|
||||
|
||||
const mimeType = contentType.split(';')[0].trim() || guessMimeType(ext)
|
||||
|
||||
downloaded.push({ path: filepath, mimeType })
|
||||
console.warn(
|
||||
`Downloaded: ${url.slice(0, 80)}... (${(buffer.length / 1024).toFixed(0)}KB)`
|
||||
)
|
||||
} catch (err) {
|
||||
console.warn(`Failed to download ${url}: ${(err as Error).message}`)
|
||||
}
|
||||
}
|
||||
|
||||
console.warn(
|
||||
`Downloaded ${downloaded.length}/${urls.length} media files ` +
|
||||
`(${(totalBytes / 1024 / 1024).toFixed(1)}MB)`
|
||||
)
|
||||
return downloaded
|
||||
}
|
||||
|
||||
function guessExtension(url: string, contentType: string): string {
|
||||
const urlMatch = url.match(MEDIA_EXTENSIONS)
|
||||
if (urlMatch) return urlMatch[0].toLowerCase()
|
||||
|
||||
const typeMap: Record<string, string> = {
|
||||
'image/png': '.png',
|
||||
'image/jpeg': '.jpg',
|
||||
'image/gif': '.gif',
|
||||
'image/webp': '.webp',
|
||||
'video/mp4': '.mp4',
|
||||
'video/webm': '.webm'
|
||||
}
|
||||
return typeMap[contentType.split(';')[0]] || '.bin'
|
||||
}
|
||||
|
||||
function guessMimeType(ext: string): string {
|
||||
const map: Record<string, string> = {
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.gif': 'image/gif',
|
||||
'.webp': 'image/webp',
|
||||
'.mp4': 'video/mp4',
|
||||
'.webm': 'video/webm',
|
||||
'.mov': 'video/quicktime'
|
||||
}
|
||||
return map[ext] || 'application/octet-stream'
|
||||
}
|
||||
|
||||
// ── Gemini analysis ──
|
||||
|
||||
function buildIssueAnalysisPrompt(issue: IssueThread): string {
|
||||
const allText = [
|
||||
`# Issue: ${issue.title}`,
|
||||
'',
|
||||
'## Description',
|
||||
issue.body,
|
||||
'',
|
||||
issue.comments.length > 0
|
||||
? `## Comments\n${issue.comments.join('\n\n---\n\n')}`
|
||||
: ''
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
|
||||
return `You are a senior QA engineer analyzing a bug report for ComfyUI frontend — a node-based visual workflow editor for AI image generation (Vue 3 + TypeScript).
|
||||
|
||||
The UI has:
|
||||
- A large canvas (1280x720 viewport) showing a node graph centered at ~(640, 400)
|
||||
- Nodes are boxes with input/output slots connected by wires
|
||||
- A hamburger menu (top-left C logo) with File, Edit, Help submenus
|
||||
- Sidebars (Workflows, Node Library, Models)
|
||||
- A topbar with workflow tabs and Queue button
|
||||
- The default workflow loads with these nodes (approximate center coordinates):
|
||||
- Load Checkpoint (~150, 300), CLIP Text Encode x2 (~450, 250 and ~450, 450)
|
||||
- Empty Latent Image (~450, 600), KSampler (~750, 350), VAE Decode (~1000, 350), Save Image (~1200, 350)
|
||||
- Right-clicking ON a node shows node actions (Clone, Bypass, Convert, etc.)
|
||||
- Right-clicking on EMPTY canvas shows Add Node menu — different from node context menu
|
||||
|
||||
Your task: Generate a DETAILED reproduction guide (8-15 steps) to trigger this bug on main.
|
||||
|
||||
${allText}
|
||||
|
||||
## Available test actions
|
||||
Each step must use one of these actions:
|
||||
|
||||
### Menu actions
|
||||
- "openMenu" — clicks the Comfy hamburger menu (top-left C logo)
|
||||
- "hoverMenuItem" — hovers a top-level menu item to open submenu (label required)
|
||||
- "clickMenuItem" — clicks an item in the visible submenu (label required)
|
||||
|
||||
### Element actions (by visible text)
|
||||
- "click" — clicks an element by visible text (text required)
|
||||
- "rightClick" — right-clicks an element to open context menu (text required)
|
||||
- "doubleClick" — double-clicks an element or coordinates (text or x,y)
|
||||
- "fillDialog" — fills dialog input and presses Enter (text required)
|
||||
- "pressKey" — presses a keyboard key (key required: Escape, Tab, Delete, Enter, etc.)
|
||||
|
||||
### Canvas actions (by coordinates — viewport is 1280x720)
|
||||
- "clickCanvas" — click at coordinates (x, y required)
|
||||
- "rightClickCanvas" — right-click at coordinates (x, y required)
|
||||
- "doubleClick" — double-click at coordinates to open node search (x, y)
|
||||
- "dragCanvas" — drag from one point to another (fromX, fromY, toX, toY)
|
||||
- "scrollCanvas" — scroll wheel for zoom (x, y, deltaY: negative=zoom in, positive=zoom out)
|
||||
|
||||
### Utility
|
||||
- "wait" — waits briefly (ms required, max 3000)
|
||||
- "screenshot" — takes a screenshot (name required)
|
||||
|
||||
## Common ComfyUI interactions
|
||||
- Right-click a node → context menu with Clone, Bypass, Remove, Colors, etc.
|
||||
- Double-click empty canvas → opens node search dialog
|
||||
- Ctrl+C / Ctrl+V → copy/paste selected nodes
|
||||
- Delete key → remove selected node
|
||||
- Ctrl+G → group selected nodes
|
||||
- Drag from output slot to input slot → create connection
|
||||
- Click a node to select it, Shift+click for multi-select
|
||||
|
||||
## Output format
|
||||
Return a JSON object with exactly one key: "reproduce", containing:
|
||||
{
|
||||
"summary": "One sentence: what bug this issue reports",
|
||||
"test_focus": "Specific behavior to reproduce",
|
||||
"prerequisites": ["e.g. Load default workflow"],
|
||||
"steps": [
|
||||
{
|
||||
"action": "clickCanvas",
|
||||
"description": "Click on first node to select it",
|
||||
"expected_before": "What should happen if the bug is present"
|
||||
}
|
||||
],
|
||||
"visual_checks": ["Specific visual evidence of the bug to look for"]
|
||||
}
|
||||
|
||||
## Rules
|
||||
- Generate 8-15 DETAILED steps that actually trigger the reported bug.
|
||||
- Follow the issue's reproduction steps PRECISELY — translate them into available actions.
|
||||
- Use canvas coordinates for node interactions (nodes are typically in the center area 300-900 x 200-500).
|
||||
- Take screenshots BEFORE and AFTER critical actions to capture the bug state.
|
||||
- Do NOT just open a menu and screenshot — actually perform the full reproduction sequence.
|
||||
- Do NOT include login steps.
|
||||
- Output ONLY valid JSON, no markdown fences or explanation.`
|
||||
}
|
||||
|
||||
function buildAnalysisPrompt(thread: PrThread): string {
|
||||
const allText = [
|
||||
`# PR: ${thread.title}`,
|
||||
'',
|
||||
'## Description',
|
||||
thread.body,
|
||||
'',
|
||||
thread.issueComments.length > 0
|
||||
? `## Issue Comments\n${thread.issueComments.join('\n\n---\n\n')}`
|
||||
: '',
|
||||
thread.reviewComments.length > 0
|
||||
? `## Review Comments\n${thread.reviewComments.join('\n\n---\n\n')}`
|
||||
: '',
|
||||
thread.reviews.length > 0
|
||||
? `## Reviews\n${thread.reviews.join('\n\n---\n\n')}`
|
||||
: '',
|
||||
'',
|
||||
'## Diff (truncated)',
|
||||
'```',
|
||||
thread.diff.slice(0, 8000),
|
||||
'```'
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
|
||||
return `You are a senior QA engineer analyzing a pull request for ComfyUI frontend (a Vue 3 + TypeScript web application for AI image generation workflows).
|
||||
|
||||
Your task: Generate TWO targeted QA test guides — one for BEFORE the PR (main branch) and one for AFTER (PR branch).
|
||||
|
||||
${allText}
|
||||
|
||||
## Available test actions
|
||||
Each step must use one of these actions:
|
||||
- "openMenu" — clicks the Comfy hamburger menu (top-left C logo)
|
||||
- "hoverMenuItem" — hovers a top-level menu item to open submenu (label required)
|
||||
- "clickMenuItem" — clicks an item in the visible submenu (label required)
|
||||
- "fillDialog" — fills dialog input and presses Enter (text required)
|
||||
- "pressKey" — presses a keyboard key (key required)
|
||||
- "click" — clicks an element by visible text (text required)
|
||||
- "wait" — waits briefly (ms required, max 3000)
|
||||
- "screenshot" — takes a screenshot (name required)
|
||||
|
||||
## Output format
|
||||
Return a JSON object with exactly two keys: "before" and "after", each containing:
|
||||
{
|
||||
"summary": "One sentence: what this PR changes",
|
||||
"test_focus": "Specific behaviors to verify in this recording",
|
||||
"prerequisites": ["e.g. Load default workflow"],
|
||||
"steps": [
|
||||
{
|
||||
"action": "openMenu",
|
||||
"description": "Open the main menu to check file options",
|
||||
"expected_before": "Old behavior description (before key only)",
|
||||
"expected_after": "New behavior description (after key only)"
|
||||
}
|
||||
],
|
||||
"visual_checks": ["Specific visual elements to look for"]
|
||||
}
|
||||
|
||||
## Rules
|
||||
- BEFORE guide: 2-4 steps, under 15 seconds. Show OLD/missing behavior.
|
||||
- AFTER guide: 3-6 steps, under 30 seconds. Prove the fix/feature works.
|
||||
- Focus on the SPECIFIC behavior changed by this PR, not generic testing.
|
||||
- Use information from PR description, screenshots, and comments to understand intended behavior.
|
||||
- Include at least one screenshot step in each guide.
|
||||
- Do NOT include login steps.
|
||||
- Menu pattern: openMenu -> hoverMenuItem -> clickMenuItem or screenshot.
|
||||
- Output ONLY valid JSON, no markdown fences or explanation.`
|
||||
}
|
||||
|
||||
async function analyzeWithGemini(
|
||||
thread: PrThread,
|
||||
media: Array<{ path: string; mimeType: string }>,
|
||||
model: string,
|
||||
apiKey: string
|
||||
): Promise<{ before: QaGuide; after: QaGuide }> {
|
||||
const genAI = new GoogleGenerativeAI(apiKey)
|
||||
const geminiModel = genAI.getGenerativeModel({ model })
|
||||
|
||||
const prompt = buildAnalysisPrompt(thread)
|
||||
|
||||
const parts: Array<
|
||||
{ text: string } | { inlineData: { mimeType: string; data: string } }
|
||||
> = [{ text: prompt }]
|
||||
|
||||
// Add media as inline data
|
||||
for (const item of media) {
|
||||
try {
|
||||
const buffer = readFileSync(item.path)
|
||||
parts.push({
|
||||
inlineData: {
|
||||
mimeType: item.mimeType,
|
||||
data: buffer.toString('base64')
|
||||
}
|
||||
})
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
`Failed to read media ${item.path}: ${(err as Error).message}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
console.warn(
|
||||
`Sending to ${model}: ${prompt.length} chars text, ${media.length} media files`
|
||||
)
|
||||
|
||||
const result = await geminiModel.generateContent({
|
||||
contents: [{ role: 'user', parts }],
|
||||
generationConfig: {
|
||||
temperature: 0.2,
|
||||
maxOutputTokens: 8192,
|
||||
responseMimeType: 'application/json'
|
||||
}
|
||||
})
|
||||
|
||||
let text = result.response.text()
|
||||
// Strip markdown fences if present
|
||||
text = text
|
||||
.replace(/^```(?:json)?\n?/gm, '')
|
||||
.replace(/```$/gm, '')
|
||||
.trim()
|
||||
|
||||
console.warn('Gemini response received')
|
||||
console.warn('Raw response (first 500 chars):', text.slice(0, 500))
|
||||
const parsed = JSON.parse(text)
|
||||
|
||||
// Handle different response shapes from Gemini
|
||||
let before: QaGuide
|
||||
let after: QaGuide
|
||||
|
||||
if (Array.isArray(parsed) && parsed.length >= 2) {
|
||||
// Array format: [before, after]
|
||||
before = parsed[0]
|
||||
after = parsed[1]
|
||||
} else if (parsed.before && parsed.after) {
|
||||
// Object format: { before, after }
|
||||
before = parsed.before
|
||||
after = parsed.after
|
||||
} else {
|
||||
// Try nested wrapper keys
|
||||
const inner = parsed.qa_guide ?? parsed.guides ?? parsed
|
||||
if (inner.before && inner.after) {
|
||||
before = inner.before
|
||||
after = inner.after
|
||||
} else {
|
||||
console.warn(
|
||||
'Full response:',
|
||||
JSON.stringify(parsed, null, 2).slice(0, 2000)
|
||||
)
|
||||
throw new Error(
|
||||
`Unexpected response shape. Got keys: ${Object.keys(parsed).join(', ')}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return { before, after }
|
||||
}
|
||||
|
||||
async function analyzeIssueWithGemini(
|
||||
issue: IssueThread,
|
||||
media: Array<{ path: string; mimeType: string }>,
|
||||
model: string,
|
||||
apiKey: string
|
||||
): Promise<QaGuide> {
|
||||
const genAI = new GoogleGenerativeAI(apiKey)
|
||||
const geminiModel = genAI.getGenerativeModel({ model })
|
||||
|
||||
const prompt = buildIssueAnalysisPrompt(issue)
|
||||
|
||||
const parts: Array<
|
||||
{ text: string } | { inlineData: { mimeType: string; data: string } }
|
||||
> = [{ text: prompt }]
|
||||
|
||||
for (const item of media) {
|
||||
try {
|
||||
const buffer = readFileSync(item.path)
|
||||
parts.push({
|
||||
inlineData: {
|
||||
mimeType: item.mimeType,
|
||||
data: buffer.toString('base64')
|
||||
}
|
||||
})
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
`Failed to read media ${item.path}: ${(err as Error).message}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
console.warn(
|
||||
`Sending to ${model}: ${prompt.length} chars text, ${media.length} media files`
|
||||
)
|
||||
|
||||
const result = await geminiModel.generateContent({
|
||||
contents: [{ role: 'user', parts }],
|
||||
generationConfig: {
|
||||
temperature: 0.2,
|
||||
maxOutputTokens: 8192,
|
||||
responseMimeType: 'application/json'
|
||||
}
|
||||
})
|
||||
|
||||
let text = result.response.text()
|
||||
text = text
|
||||
.replace(/^```(?:json)?\n?/gm, '')
|
||||
.replace(/```$/gm, '')
|
||||
.trim()
|
||||
|
||||
console.warn('Gemini response received')
|
||||
console.warn('Raw response (first 500 chars):', text.slice(0, 500))
|
||||
const parsed = JSON.parse(text)
|
||||
|
||||
const guide: QaGuide =
|
||||
parsed.reproduce ?? parsed.qa_guide?.reproduce ?? parsed
|
||||
return guide
|
||||
}
|
||||
|
||||
// ── Main ──
|
||||
|
||||
async function main() {
|
||||
const opts = parseArgs()
|
||||
mkdirSync(opts.outputDir, { recursive: true })
|
||||
|
||||
if (opts.type === 'issue') {
|
||||
await analyzeIssue(opts)
|
||||
} else {
|
||||
await analyzePr(opts)
|
||||
}
|
||||
}
|
||||
|
||||
async function analyzeIssue(opts: Options) {
|
||||
const issue = fetchIssueThread(opts.prNumber, opts.repo)
|
||||
|
||||
const allText = [issue.body, ...issue.comments].join('\n')
|
||||
const mediaUrls = extractMediaUrls(allText)
|
||||
console.warn(`Found ${mediaUrls.length} media URLs`)
|
||||
|
||||
const media = await downloadMedia(
|
||||
mediaUrls,
|
||||
opts.outputDir,
|
||||
opts.mediaBudgetBytes,
|
||||
opts.maxVideoBytes
|
||||
)
|
||||
|
||||
const guide = await analyzeIssueWithGemini(
|
||||
issue,
|
||||
media,
|
||||
opts.model,
|
||||
opts.apiKey
|
||||
)
|
||||
|
||||
const beforePath = resolve(opts.outputDir, 'qa-guide-before.json')
|
||||
writeFileSync(beforePath, JSON.stringify(guide, null, 2))
|
||||
|
||||
console.warn(`Wrote QA guide:`)
|
||||
console.warn(` Reproduce: ${beforePath}`)
|
||||
}
|
||||
|
||||
async function analyzePr(opts: Options) {
|
||||
const thread = fetchPrThread(opts.prNumber, opts.repo)
|
||||
|
||||
const allText = [
|
||||
thread.body,
|
||||
...thread.issueComments,
|
||||
...thread.reviewComments,
|
||||
...thread.reviews
|
||||
].join('\n')
|
||||
const mediaUrls = extractMediaUrls(allText)
|
||||
console.warn(`Found ${mediaUrls.length} media URLs`)
|
||||
|
||||
const media = await downloadMedia(
|
||||
mediaUrls,
|
||||
opts.outputDir,
|
||||
opts.mediaBudgetBytes,
|
||||
opts.maxVideoBytes
|
||||
)
|
||||
|
||||
const guides = await analyzeWithGemini(thread, media, opts.model, opts.apiKey)
|
||||
|
||||
const beforePath = resolve(opts.outputDir, 'qa-guide-before.json')
|
||||
const afterPath = resolve(opts.outputDir, 'qa-guide-after.json')
|
||||
writeFileSync(beforePath, JSON.stringify(guides.before, null, 2))
|
||||
writeFileSync(afterPath, JSON.stringify(guides.after, null, 2))
|
||||
|
||||
console.warn(`Wrote QA guides:`)
|
||||
console.warn(` Before: ${beforePath}`)
|
||||
console.warn(` After: ${afterPath}`)
|
||||
}
|
||||
|
||||
function isExecutedAsScript(metaUrl: string): boolean {
|
||||
const modulePath = fileURLToPath(metaUrl)
|
||||
const scriptPath = process.argv[1] ? resolve(process.argv[1]) : ''
|
||||
return modulePath === scriptPath
|
||||
}
|
||||
|
||||
if (isExecutedAsScript(import.meta.url)) {
|
||||
main().catch((err) => {
|
||||
console.error('PR analysis failed:', err)
|
||||
process.exit(1)
|
||||
})
|
||||
}
|
||||
176
scripts/qa-batch.sh
Executable file
176
scripts/qa-batch.sh
Executable file
@@ -0,0 +1,176 @@
|
||||
#!/usr/bin/env bash
|
||||
# Batch-trigger QA runs by creating and pushing sno-qa-* branches.
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/qa-batch.sh 10394 10238 9996 # Trigger specific numbers
|
||||
# ./scripts/qa-batch.sh --from tmp/issues.md --top 5 # From triage file
|
||||
# ./scripts/qa-batch.sh --dry-run 10394 10238 # Preview only
|
||||
# ./scripts/qa-batch.sh --cleanup # Delete old sno-qa-* branches
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
DELAY=5
|
||||
DRY_RUN=false
|
||||
CLEANUP=false
|
||||
FROM_FILE=""
|
||||
TOP_N=0
|
||||
NUMBERS=()
|
||||
|
||||
die() { echo "error: $*" >&2; exit 1; }
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage: qa-batch.sh [options] [numbers...]
|
||||
|
||||
Options:
|
||||
--from <file> Extract numbers from a triage markdown file
|
||||
--top <N> Take first N entries from Tier 1 (requires --from)
|
||||
--dry-run Print what would happen without pushing
|
||||
--cleanup Delete all sno-qa-* remote branches
|
||||
--delay <secs> Seconds between pushes (default: 5)
|
||||
-h, --help Show this help
|
||||
EOF
|
||||
exit 0
|
||||
}
|
||||
|
||||
# --- Parse args ---
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--from) FROM_FILE="$2"; shift 2 ;;
|
||||
--top) TOP_N="$2"; shift 2 ;;
|
||||
--dry-run) DRY_RUN=true; shift ;;
|
||||
--cleanup) CLEANUP=true; shift ;;
|
||||
--delay) DELAY="$2"; shift 2 ;;
|
||||
-h|--help) usage ;;
|
||||
-*) die "unknown option: $1" ;;
|
||||
*) NUMBERS+=("$1"); shift ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# --- Cleanup mode ---
|
||||
if $CLEANUP; then
|
||||
echo "Fetching remote sno-qa-* branches..."
|
||||
branches=$(git ls-remote --heads origin 'refs/heads/sno-qa-*' | awk '{print $2}' | sed 's|refs/heads/||')
|
||||
|
||||
if [[ -z "$branches" ]]; then
|
||||
echo "No sno-qa-* branches found on remote."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Found branches:"
|
||||
while IFS= read -r b; do echo " $b"; done <<< "$branches"
|
||||
echo
|
||||
|
||||
if $DRY_RUN; then
|
||||
echo "[dry-run] Would delete the above branches."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
read -rp "Delete all of the above? [y/N] " confirm
|
||||
if [[ "$confirm" != "y" && "$confirm" != "Y" ]]; then
|
||||
echo "Aborted."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
for branch in $branches; do
|
||||
echo "Deleting origin/$branch..."
|
||||
git push origin --delete "$branch"
|
||||
done
|
||||
echo "Done. Cleaned up $(echo "$branches" | wc -l | tr -d ' ') branches."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# --- Extract numbers from markdown ---
|
||||
if [[ -n "$FROM_FILE" ]]; then
|
||||
[[ -f "$FROM_FILE" ]] || die "file not found: $FROM_FILE"
|
||||
[[ "$TOP_N" -gt 0 ]] || die "--top N required with --from"
|
||||
|
||||
# Extract Tier 1 table rows: | N | [#NNNNN](...) | ...
|
||||
# Stop at the next ## heading after Tier 1
|
||||
extracted=$(awk '/^## Tier 1/,/^## Tier [^1]/' "$FROM_FILE" \
|
||||
| grep -oP '\[#\K\d+' \
|
||||
| head -n "$TOP_N")
|
||||
|
||||
if [[ -z "$extracted" ]]; then
|
||||
die "no numbers found in $FROM_FILE"
|
||||
fi
|
||||
|
||||
while IFS= read -r num; do
|
||||
NUMBERS+=("$num")
|
||||
done <<< "$extracted"
|
||||
fi
|
||||
|
||||
[[ ${#NUMBERS[@]} -gt 0 ]] || die "no numbers specified. Use positional args or --from/--top."
|
||||
|
||||
# --- Validate ---
|
||||
for num in "${NUMBERS[@]}"; do
|
||||
[[ "$num" =~ ^[0-9]+$ ]] || die "invalid number: $num"
|
||||
done
|
||||
|
||||
# Deduplicate
|
||||
# shellcheck disable=SC2207 # mapfile not available on macOS default bash
|
||||
NUMBERS=($(printf '%s\n' "${NUMBERS[@]}" | sort -un))
|
||||
|
||||
# --- Push branches ---
|
||||
echo "Triggering QA for: ${NUMBERS[*]}"
|
||||
if $DRY_RUN; then
|
||||
echo "[dry-run]"
|
||||
fi
|
||||
echo
|
||||
|
||||
pushed=()
|
||||
skipped=()
|
||||
|
||||
# Fetch remote refs once
|
||||
remote_refs=$(git ls-remote --heads origin 'refs/heads/sno-qa-*' 2>/dev/null | awk '{print $2}' | sed 's|refs/heads/||')
|
||||
|
||||
for num in "${NUMBERS[@]}"; do
|
||||
branch="sno-qa-$num"
|
||||
|
||||
# Check if already exists on remote
|
||||
if echo "$remote_refs" | grep -qx "$branch"; then
|
||||
echo " skip: $branch (already exists on remote)"
|
||||
skipped+=("$num")
|
||||
continue
|
||||
fi
|
||||
|
||||
if $DRY_RUN; then
|
||||
echo " would push: $branch"
|
||||
pushed+=("$num")
|
||||
continue
|
||||
fi
|
||||
|
||||
# Create branch at current HEAD and push
|
||||
git branch -f "$branch" HEAD
|
||||
git push origin "$branch"
|
||||
pushed+=("$num")
|
||||
echo " pushed: $branch"
|
||||
|
||||
# Clean up local branch
|
||||
git branch -D "$branch" 2>/dev/null || true
|
||||
|
||||
# Delay between pushes to avoid CI concurrency storm
|
||||
if [[ "$num" != "${NUMBERS[-1]}" ]]; then
|
||||
echo " waiting ${DELAY}s..."
|
||||
sleep "$DELAY"
|
||||
fi
|
||||
done
|
||||
|
||||
# --- Summary ---
|
||||
echo
|
||||
echo "=== Summary ==="
|
||||
echo "Triggered: ${#pushed[@]}"
|
||||
echo "Skipped: ${#skipped[@]}"
|
||||
|
||||
if [[ ${#pushed[@]} -gt 0 ]]; then
|
||||
echo
|
||||
echo "Triggered numbers: ${pushed[*]}"
|
||||
repo_url=$(git remote get-url origin | sed 's/\.git$//' | sed 's|git@github.com:|https://github.com/|')
|
||||
echo "Actions: ${repo_url}/actions"
|
||||
fi
|
||||
|
||||
if [[ ${#skipped[@]} -gt 0 ]]; then
|
||||
echo
|
||||
echo "Skipped (already exist): ${skipped[*]}"
|
||||
echo "Use --cleanup first to remove old branches."
|
||||
fi
|
||||
358
scripts/qa-deploy-pages.sh
Executable file
358
scripts/qa-deploy-pages.sh
Executable file
@@ -0,0 +1,358 @@
|
||||
#!/usr/bin/env bash
|
||||
# Deploy QA report to Cloudflare Pages.
|
||||
# Expected env vars: CLOUDFLARE_API_TOKEN, CLOUDFLARE_ACCOUNT_ID, RAW_BRANCH,
|
||||
# BEFORE_SHA, AFTER_SHA, TARGET_NUM, TARGET_TYPE, REPO, RUN_ID
|
||||
# Writes outputs to GITHUB_OUTPUT: badge_status, url
|
||||
set -euo pipefail
|
||||
|
||||
npm install -g wrangler@4.74.0 >/dev/null 2>&1
|
||||
|
||||
DEPLOY_DIR=$(mktemp -d)
|
||||
mkdir -p "$DEPLOY_DIR"
|
||||
|
||||
for os in Linux macOS Windows; do
|
||||
DIR="qa-artifacts/qa-report-${os}-${RUN_ID}"
|
||||
for prefix in qa qa-before; do
|
||||
VID="${DIR}/${prefix}-session.mp4"
|
||||
if [ -f "$VID" ]; then
|
||||
DEST="$DEPLOY_DIR/${prefix}-${os}.mp4"
|
||||
cp "$VID" "$DEST"
|
||||
echo "Found ${prefix} ${os} video ($(du -h "$VID" | cut -f1))"
|
||||
fi
|
||||
done
|
||||
# Copy multi-pass session videos (qa-session-1, qa-session-2, etc.)
|
||||
for numbered in "$DIR"/qa-session-[0-9].mp4; do
|
||||
[ -f "$numbered" ] || continue
|
||||
NUM=$(basename "$numbered" | sed 's/qa-session-\([0-9]\).mp4/\1/')
|
||||
DEST="$DEPLOY_DIR/qa-${os}-pass${NUM}.mp4"
|
||||
cp "$numbered" "$DEST"
|
||||
echo "Found pass ${NUM} ${os} video ($(du -h "$numbered" | cut -f1))"
|
||||
done
|
||||
# Generate GIF thumbnail from after video (or first pass)
|
||||
THUMB_SRC="$DEPLOY_DIR/qa-${os}.mp4"
|
||||
[ ! -f "$THUMB_SRC" ] && THUMB_SRC="$DEPLOY_DIR/qa-${os}-pass1.mp4"
|
||||
if [ -f "$THUMB_SRC" ]; then
|
||||
ffmpeg -y -ss 10 -i "$THUMB_SRC" -t 8 \
|
||||
-vf "fps=8,scale=480:-1:flags=lanczos,split[s0][s1];[s0]palettegen=max_colors=64[p];[s1][p]paletteuse=dither=bayer" \
|
||||
-loop 0 "$DEPLOY_DIR/qa-${os}-thumb.gif" 2>/dev/null \
|
||||
|| echo "GIF generation failed for ${os} (non-fatal)"
|
||||
fi
|
||||
done
|
||||
|
||||
# Build video cards and report sections
|
||||
CARDS=""
|
||||
# shellcheck disable=SC2034 # accessed via eval
|
||||
ICONS_Linux="🐧" ICONS_macOS="🍎" ICONS_Windows="🪟"
|
||||
CARD_COUNT=0
|
||||
DL_ICON="<svg width=14 height=14 viewBox='0 0 24 24' fill=none stroke=currentColor stroke-width=2><path d='M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4'/><polyline points='7 10 12 15 17 10'/><line x1=12 y1=15 x2=12 y2=3'/></svg>"
|
||||
|
||||
for os in Linux macOS Windows; do
|
||||
eval "ICON=\$ICONS_${os}"
|
||||
OS_LOWER=$(echo "$os" | tr '[:upper:]' '[:lower:]')
|
||||
HAS_BEFORE=$([ -f "$DEPLOY_DIR/qa-before-${os}.mp4" ] && echo 1 || echo 0)
|
||||
HAS_AFTER=$( { [ -f "$DEPLOY_DIR/qa-${os}.mp4" ] || [ -f "$DEPLOY_DIR/qa-${os}-pass1.mp4" ]; } && echo 1 || echo 0)
|
||||
[ "$HAS_AFTER" = "0" ] && continue
|
||||
|
||||
# Collect all reports for this platform (single + multi-pass)
|
||||
REPORT_FILES=""
|
||||
REPORT_LINK=""
|
||||
REPORT_HTML=""
|
||||
for rpt in "video-reviews/${OS_LOWER}-qa-video-report.md" "video-reviews/${OS_LOWER}-pass"*-qa-video-report.md; do
|
||||
[ -f "$rpt" ] && REPORT_FILES="${REPORT_FILES} ${rpt}"
|
||||
done
|
||||
|
||||
if [ -n "$REPORT_FILES" ]; then
|
||||
# Concatenate all reports into one combined report file
|
||||
COMBINED_MD=""
|
||||
for rpt in $REPORT_FILES; do
|
||||
cp "$rpt" "$DEPLOY_DIR/$(basename "$rpt")"
|
||||
RPT_MD=$(sed 's/&/\&/g; s/</\</g; s/>/\>/g' "$rpt")
|
||||
[ -n "$COMBINED_MD" ] && COMBINED_MD="${COMBINED_MD} --- "
|
||||
COMBINED_MD="${COMBINED_MD}${RPT_MD}"
|
||||
done
|
||||
FIRST_REPORT=$(echo "$REPORT_FILES" | awk '{print $1}')
|
||||
FIRST_BASENAME=$(basename "$FIRST_REPORT")
|
||||
REPORT_LINK="<a class=dl href=${FIRST_BASENAME}><svg width=14 height=14 viewBox='0 0 24 24' fill=none stroke=currentColor stroke-width=2><path d='M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z'/><polyline points='14 2 14 8 20 8'/><line x1=16 y1=13 x2=8 y2=13/><line x1=16 y1=17 x2=8 y2=17'/></svg>Report</a>"
|
||||
REPORT_HTML="<details class=report open><summary><svg width=14 height=14 viewBox='0 0 24 24' fill=none stroke=currentColor stroke-width=2><circle cx=12 cy=12 r=10/><line x1=12 y1=16 x2=12 y2=12/><line x1=12 y1=8 x2=12.01 y2=8'/></svg> AI Comparative Review</summary><div class=report-body data-md>${COMBINED_MD}</div></details>"
|
||||
fi
|
||||
|
||||
if [ "$HAS_BEFORE" = "1" ]; then
|
||||
CARDS="${CARDS}<div class='card reveal' style='--i:${CARD_COUNT}'><div class=card-header><span class=platform><span class=icon>${ICON}</span>${os}</span><span class=links>${REPORT_LINK}</span></div><div class=comparison><div class=comp-panel><div class=comp-label>Before <span class=comp-tag>main</span></div><div class=video-wrap><video controls muted preload=auto><source src=qa-before-${os}.mp4 type=video/mp4></video></div><div class=comp-dl><a class=dl href=qa-before-${os}.mp4 download>${DL_ICON}Before</a></div></div><div class=comp-panel><div class=comp-label>After <span class=comp-tag>PR</span></div><div class=video-wrap><video controls muted preload=auto><source src=qa-${os}.mp4 type=video/mp4></video></div><div class=comp-dl><a class=dl href=qa-${os}.mp4 download>${DL_ICON}After</a></div></div></div>${REPORT_HTML}</div>"
|
||||
elif [ -f "$DEPLOY_DIR/qa-${os}.mp4" ]; then
|
||||
CARDS="${CARDS}<div class='card reveal' style='--i:${CARD_COUNT}'><div class=video-wrap><video controls muted preload=auto><source src=qa-${os}.mp4 type=video/mp4></video></div><div class=card-body><span class=platform><span class=icon>${ICON}</span>${os}</span><span class=links><a class=dl href=qa-${os}.mp4 download>${DL_ICON}Download</a>${REPORT_LINK}</span></div>${REPORT_HTML}</div>"
|
||||
else
|
||||
PASS_VIDEOS=""
|
||||
for pass_vid in "$DEPLOY_DIR/qa-${os}-pass"[0-9].mp4; do
|
||||
[ -f "$pass_vid" ] || continue
|
||||
PASS_NUM=$(basename "$pass_vid" | sed "s/qa-${os}-pass\([0-9]\).mp4/\1/")
|
||||
PASS_VIDEOS="${PASS_VIDEOS}<div class=comp-panel><div class=comp-label>Pass ${PASS_NUM}</div><div class=video-wrap><video controls muted preload=auto><source src=qa-${os}-pass${PASS_NUM}.mp4 type=video/mp4></video></div><div class=comp-dl><a class=dl href=qa-${os}-pass${PASS_NUM}.mp4 download>${DL_ICON}Pass ${PASS_NUM}</a></div></div>"
|
||||
done
|
||||
CARDS="${CARDS}<div class='card reveal' style='--i:${CARD_COUNT}'><div class=card-header><span class=platform><span class=icon>${ICON}</span>${os}</span><span class=links>${REPORT_LINK}</span></div><div class=comparison>${PASS_VIDEOS}</div>${REPORT_HTML}</div>"
|
||||
fi
|
||||
CARD_COUNT=$((CARD_COUNT + 1))
|
||||
done
|
||||
|
||||
# Build commit info and target link for the report header
|
||||
COMMIT_HTML=""
|
||||
REPO_URL="https://github.com/${REPO}"
|
||||
if [ -n "${TARGET_NUM:-}" ]; then
|
||||
if [ "$TARGET_TYPE" = "issue" ]; then
|
||||
COMMIT_HTML="<a href=${REPO_URL}/issues/${TARGET_NUM} class=sha title='Issue'>Issue #${TARGET_NUM}</a>"
|
||||
else
|
||||
COMMIT_HTML="<a href=${REPO_URL}/pull/${TARGET_NUM} class=sha title='Pull Request'>PR #${TARGET_NUM}</a>"
|
||||
fi
|
||||
fi
|
||||
if [ -n "${BEFORE_SHA:-}" ]; then
|
||||
SHORT_BEFORE="${BEFORE_SHA:0:7}"
|
||||
COMMIT_HTML="${COMMIT_HTML:+${COMMIT_HTML} · }<a href=${REPO_URL}/commit/${BEFORE_SHA} class=sha title='main branch'>main @ ${SHORT_BEFORE}</a>"
|
||||
fi
|
||||
if [ -n "${AFTER_SHA:-}" ]; then
|
||||
SHORT_AFTER="${AFTER_SHA:0:7}"
|
||||
AFTER_LABEL="PR"
|
||||
[ -n "${TARGET_NUM:-}" ] && AFTER_LABEL="#${TARGET_NUM}"
|
||||
COMMIT_HTML="${COMMIT_HTML:+${COMMIT_HTML} · }<a href=${REPO_URL}/commit/${AFTER_SHA} class=sha title='PR head commit'>${AFTER_LABEL} @ ${SHORT_AFTER}</a>"
|
||||
fi
|
||||
if [ -n "${PIPELINE_SHA:-}" ]; then
|
||||
SHORT_PIPE="${PIPELINE_SHA:0:7}"
|
||||
COMMIT_HTML="${COMMIT_HTML:+${COMMIT_HTML} · }<a href=${REPO_URL}/commit/${PIPELINE_SHA} class=sha title='QA pipeline version'>QA @ ${SHORT_PIPE}</a>"
|
||||
fi
|
||||
[ -n "$COMMIT_HTML" ] && COMMIT_HTML=" · ${COMMIT_HTML}"
|
||||
|
||||
RUN_LINK=""
|
||||
if [ -n "${RUN_URL:-}" ]; then
|
||||
RUN_LINK=" · <a href=\"${RUN_URL}\" class=sha title=\"GitHub Actions run\">CI Job</a>"
|
||||
fi
|
||||
|
||||
# Timing info
|
||||
DEPLOY_TIME=$(date -u '+%Y-%m-%d %H:%M UTC')
|
||||
TIMING_HTML=""
|
||||
if [ -n "${RUN_START_TIME:-}" ]; then
|
||||
TIMING_HTML=" · <span class=sha title='Pipeline timing'>${RUN_START_TIME} → ${DEPLOY_TIME}</span>"
|
||||
fi
|
||||
|
||||
# Generate index.html from template
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
TEMPLATE="$SCRIPT_DIR/qa-report-template.html"
|
||||
|
||||
# Write dynamic content to temp files for safe substitution
|
||||
# Cloudflare Pages _headers file — enable range requests for video seeking
|
||||
cat > "$DEPLOY_DIR/_headers" <<'HEADERSEOF'
|
||||
/*.mp4
|
||||
Accept-Ranges: bytes
|
||||
Cache-Control: public, max-age=86400
|
||||
HEADERSEOF
|
||||
|
||||
# Build purpose description from pr-context.txt
|
||||
PURPOSE_HTML=""
|
||||
if [ -f pr-context.txt ]; then
|
||||
# Extract title line and first paragraph of description
|
||||
PR_TITLE=$(grep -m1 '^Title:' pr-context.txt | sed 's/^Title: //')
|
||||
if [ "$TARGET_TYPE" = "issue" ]; then
|
||||
PURPOSE_LABEL="Issue #${TARGET_NUM}"
|
||||
PURPOSE_VERB="reports"
|
||||
else
|
||||
PURPOSE_LABEL="PR #${TARGET_NUM}"
|
||||
PURPOSE_VERB="aims to"
|
||||
fi
|
||||
# Get first ~300 chars of description body (after "Description:" line)
|
||||
PR_DESC=$(sed -n '/^Description:/,/^###/p' pr-context.txt | grep -v '^Description:\|^###' | head -5 | sed 's/&/\&/g; s/</\</g; s/>/\>/g' | tr '\n' ' ' | head -c 400)
|
||||
[ -z "$PR_DESC" ] && PR_DESC=$(sed -n '3,8p' pr-context.txt | sed 's/&/\&/g; s/</\</g; s/>/\>/g' | tr '\n' ' ' | head -c 400)
|
||||
# Build requirements from QA guide JSON
|
||||
REQS_HTML=""
|
||||
QA_GUIDE=$(ls qa-guides/qa-guide-*.json 2>/dev/null | head -1)
|
||||
if [ -f "$QA_GUIDE" ]; then
|
||||
PREREQS=$(python3 -c "
|
||||
import json, sys, html
|
||||
try:
|
||||
g = json.load(open(sys.argv[1]))
|
||||
prereqs = g.get('prerequisites', [])
|
||||
steps = g.get('steps', [])
|
||||
focus = g.get('test_focus', '')
|
||||
parts = []
|
||||
if focus:
|
||||
parts.append('<strong>Test focus:</strong> ' + html.escape(focus))
|
||||
if prereqs:
|
||||
parts.append('<strong>Prerequisites:</strong> ' + ', '.join(html.escape(p) for p in prereqs))
|
||||
if steps:
|
||||
parts.append('<strong>Steps:</strong> ' + ' → '.join(html.escape(s.get('description', str(s))) for s in steps[:6]))
|
||||
if len(steps) > 6:
|
||||
parts[-1] += ' → ...'
|
||||
print('<br>'.join(parts))
|
||||
except: pass
|
||||
" "$QA_GUIDE" 2>/dev/null)
|
||||
[ -n "$PREREQS" ] && REQS_HTML="<div class=purpose-reqs>${PREREQS}</div>"
|
||||
fi
|
||||
|
||||
PURPOSE_HTML="<div class=purpose><div class=purpose-label>${PURPOSE_LABEL} ${PURPOSE_VERB}</div><strong>${PR_TITLE}</strong><br>${PR_DESC}${REQS_HTML}</div>"
|
||||
fi
|
||||
|
||||
echo -n "$COMMIT_HTML" > "$DEPLOY_DIR/.commit_html"
|
||||
echo -n "$CARDS" > "$DEPLOY_DIR/.cards_html"
|
||||
echo -n "$RUN_LINK" > "$DEPLOY_DIR/.run_link"
|
||||
# Badge HTML with copy button (placeholder URL filled after deploy)
|
||||
echo -n '<div class="badge-bar"><img src="badge.svg" alt="QA Badge" class="badge-img"/><button class="copy-badge" title="Copy badge markdown" onclick="copyBadge()"><svg width=14 height=14 viewBox="0 0 24 24" fill=none stroke=currentColor stroke-width=2><rect x=9 y=9 width=13 height=13 rx=2/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg></button></div>' > "$DEPLOY_DIR/.badge_html"
|
||||
echo -n "${TIMING_HTML:-}" > "$DEPLOY_DIR/.timing_html"
|
||||
echo -n "$PURPOSE_HTML" > "$DEPLOY_DIR/.purpose_html"
|
||||
python3 -c "
|
||||
import sys, pathlib
|
||||
d = pathlib.Path(sys.argv[1])
|
||||
t = pathlib.Path(sys.argv[2]).read_text()
|
||||
t = t.replace('{{COMMIT_HTML}}', (d / '.commit_html').read_text())
|
||||
t = t.replace('{{CARDS}}', (d / '.cards_html').read_text())
|
||||
t = t.replace('{{RUN_LINK}}', (d / '.run_link').read_text())
|
||||
t = t.replace('{{BADGE_HTML}}', (d / '.badge_html').read_text())
|
||||
t = t.replace('{{TIMING_HTML}}', (d / '.timing_html').read_text())
|
||||
t = t.replace('{{PURPOSE_HTML}}', (d / '.purpose_html').read_text())
|
||||
sys.stdout.write(t)
|
||||
" "$DEPLOY_DIR" "$TEMPLATE" > "$DEPLOY_DIR/index.html"
|
||||
rm -f "$DEPLOY_DIR/.commit_html" "$DEPLOY_DIR/.cards_html" "$DEPLOY_DIR/.run_link" "$DEPLOY_DIR/.badge_html" "$DEPLOY_DIR/.timing_html" "$DEPLOY_DIR/.purpose_html"
|
||||
|
||||
cat > "$DEPLOY_DIR/404.html" <<'ERROREOF'
|
||||
<!DOCTYPE html><html lang=en><head><meta charset=utf-8><title>404</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600&display=swap" rel=stylesheet>
|
||||
<style>:root{--bg:oklch(8% 0.02 265);--fg:oklch(45% 0.01 265);--err:oklch(62% 0.22 25)}*{margin:0;padding:0;box-sizing:border-box}body{background:var(--bg);color:var(--fg);font-family:'Inter',system-ui,sans-serif;display:flex;align-items:center;justify-content:center;min-height:100vh}div{text-align:center}h1{color:var(--err);font-size:clamp(3rem,8vw,5rem);font-weight:700;letter-spacing:-.04em;margin-bottom:.5rem}p{font-size:1rem;max-width:32ch;line-height:1.5}</style>
|
||||
</head><body><div><h1>404</h1><p>File not found. The QA recording may have failed or been cancelled.</p></div></body></html>
|
||||
ERROREOF
|
||||
|
||||
# Copy research log to deploy dir if it exists
|
||||
for rlog in qa-artifacts/*/research/research-log.json qa-artifacts/*/*/research/research-log.json qa-artifacts/before/*/research/research-log.json; do
|
||||
if [ -f "$rlog" ]; then
|
||||
cp "$rlog" "$DEPLOY_DIR/research-log.json"
|
||||
echo "Found research log: $rlog"
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
# Generate badge SVGs into deploy dir
|
||||
# Priority: research-log.json verdict (a11y-verified) > video review verdict (AI interpretation)
|
||||
REPRO_COUNT=0 INCONC_COUNT=0 NOT_REPRO_COUNT=0 TOTAL_REPORTS=0
|
||||
|
||||
# Try research log first (ground truth from a11y assertions)
|
||||
RESEARCH_VERDICT=""
|
||||
if [ -f "$DEPLOY_DIR/research-log.json" ]; then
|
||||
RESEARCH_VERDICT=$(python3 -c "import json,sys; d=json.load(open(sys.argv[1])); print(d.get('verdict',''))" "$DEPLOY_DIR/research-log.json" 2>/dev/null || true)
|
||||
echo "Research verdict (a11y-verified): ${RESEARCH_VERDICT:-none}"
|
||||
if [ -n "$RESEARCH_VERDICT" ]; then
|
||||
TOTAL_REPORTS=1
|
||||
case "$RESEARCH_VERDICT" in
|
||||
REPRODUCED) REPRO_COUNT=1 ;;
|
||||
NOT_REPRODUCIBLE) NOT_REPRO_COUNT=1 ;;
|
||||
INCONCLUSIVE) INCONC_COUNT=1 ;;
|
||||
esac
|
||||
fi
|
||||
fi
|
||||
|
||||
# Fall back to video review verdicts if no research log
|
||||
if [ -z "$RESEARCH_VERDICT" ] && [ -d video-reviews ]; then
|
||||
for rpt in video-reviews/*-qa-video-report.md; do
|
||||
[ -f "$rpt" ] || continue
|
||||
TOTAL_REPORTS=$((TOTAL_REPORTS + 1))
|
||||
# Try structured JSON verdict first (from ## Verdict section)
|
||||
VERDICT_JSON=$(grep -oP '"verdict":\s*"[A-Z_]+' "$rpt" 2>/dev/null | tail -1 | grep -oP '[A-Z_]+$' || true)
|
||||
RISK_JSON=$(grep -oP '"risk":\s*"[a-z]+' "$rpt" 2>/dev/null | tail -1 | grep -oP '[a-z]+$' || true)
|
||||
|
||||
if [ -n "$VERDICT_JSON" ]; then
|
||||
case "$VERDICT_JSON" in
|
||||
REPRODUCED) REPRO_COUNT=$((REPRO_COUNT + 1)) ;;
|
||||
NOT_REPRODUCIBLE) NOT_REPRO_COUNT=$((NOT_REPRO_COUNT + 1)) ;;
|
||||
INCONCLUSIVE) INCONC_COUNT=$((INCONC_COUNT + 1)) ;;
|
||||
esac
|
||||
else
|
||||
# Fallback: grep Summary section (for older reports without ## Verdict)
|
||||
SUMM=$(sed -n '/^## Summary/,/^## /p' "$rpt" 2>/dev/null | head -15)
|
||||
if echo "$SUMM" | grep -iq 'INCONCLUSIVE'; then
|
||||
INCONC_COUNT=$((INCONC_COUNT + 1))
|
||||
elif echo "$SUMM" | grep -iq 'not reproduced\|could not reproduce\|could not be confirmed\|unable to reproduce\|fails\? to reproduce\|fails\? to perform\|was NOT\|NOT visible\|not observed\|fail.* to demonstrate\|does not demonstrate\|steps were not performed\|never.*tested\|never.*accessed\|not.* confirmed'; then
|
||||
NOT_REPRO_COUNT=$((NOT_REPRO_COUNT + 1))
|
||||
elif echo "$SUMM" | grep -iq 'reproduc\|confirm'; then
|
||||
REPRO_COUNT=$((REPRO_COUNT + 1))
|
||||
fi
|
||||
fi
|
||||
done
|
||||
fi
|
||||
FAIL_COUNT=$((TOTAL_REPORTS - REPRO_COUNT - NOT_REPRO_COUNT))
|
||||
[ "$FAIL_COUNT" -lt 0 ] && FAIL_COUNT=0
|
||||
echo "DEBUG verdict: repro=${REPRO_COUNT} not_repro=${NOT_REPRO_COUNT} inconc=${INCONC_COUNT} fail=${FAIL_COUNT} total=${TOTAL_REPORTS}"
|
||||
echo "Verdict: ${REPRO_COUNT}✓ ${NOT_REPRO_COUNT}✗ ${FAIL_COUNT}⚠ / ${TOTAL_REPORTS}"
|
||||
|
||||
# Badge text:
|
||||
# Single pass: "REPRODUCED" / "NOT REPRODUCIBLE" / "INCONCLUSIVE"
|
||||
# Multi pass: "2✓ 0✗ 1⚠ / 3" with color based on dominant result
|
||||
REPRO_RESULT="" REPRO_COLOR="#9f9f9f"
|
||||
if [ "$TOTAL_REPORTS" -le 1 ]; then
|
||||
# Single report — simple label
|
||||
if [ "$REPRO_COUNT" -gt 0 ]; then
|
||||
REPRO_RESULT="REPRODUCED" REPRO_COLOR="#2196f3"
|
||||
elif [ "$NOT_REPRO_COUNT" -gt 0 ]; then
|
||||
REPRO_RESULT="NOT REPRODUCIBLE" REPRO_COLOR="#9f9f9f"
|
||||
elif [ "$FAIL_COUNT" -gt 0 ]; then
|
||||
REPRO_RESULT="INCONCLUSIVE" REPRO_COLOR="#9f9f9f"
|
||||
fi
|
||||
else
|
||||
# Multi pass — show breakdown: X✓ Y✗ Z⚠ / N
|
||||
PARTS=""
|
||||
[ "$REPRO_COUNT" -gt 0 ] && PARTS="${REPRO_COUNT}✓"
|
||||
[ "$NOT_REPRO_COUNT" -gt 0 ] && PARTS="${PARTS:+${PARTS} }${NOT_REPRO_COUNT}✗"
|
||||
[ "$FAIL_COUNT" -gt 0 ] && PARTS="${PARTS:+${PARTS} }${FAIL_COUNT}⚠"
|
||||
REPRO_RESULT="${PARTS} / ${TOTAL_REPORTS}"
|
||||
# Color based on best outcome
|
||||
if [ "$REPRO_COUNT" -gt 0 ]; then
|
||||
REPRO_COLOR="#2196f3"
|
||||
elif [ "$NOT_REPRO_COUNT" -gt 0 ]; then
|
||||
REPRO_COLOR="#9f9f9f"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Badge label: #NUM QA0327 (with today's date)
|
||||
QA_DATE=$(date -u '+%m%d')
|
||||
BADGE_LABEL="QA${QA_DATE}"
|
||||
[ -n "${TARGET_NUM:-}" ] && BADGE_LABEL="#${TARGET_NUM} QA${QA_DATE}"
|
||||
|
||||
# For PRs, also extract fix quality from Overall Risk section
|
||||
FIX_RESULT="" FIX_COLOR="#4c1"
|
||||
if [ "$TARGET_TYPE" != "issue" ]; then
|
||||
# Try structured JSON risk first
|
||||
ALL_RISKS=$(grep -ohP '"risk":\s*"[a-z]+' video-reviews/*.md 2>/dev/null | grep -oP '[a-z]+$' || true)
|
||||
if [ -n "$ALL_RISKS" ]; then
|
||||
# Use worst risk across all reports
|
||||
if echo "$ALL_RISKS" | grep -q 'high'; then
|
||||
FIX_RESULT="MAJOR ISSUES" FIX_COLOR="#e05d44"
|
||||
elif echo "$ALL_RISKS" | grep -q 'medium'; then
|
||||
FIX_RESULT="MINOR ISSUES" FIX_COLOR="#dfb317"
|
||||
elif echo "$ALL_RISKS" | grep -q 'low'; then
|
||||
FIX_RESULT="APPROVED" FIX_COLOR="#4c1"
|
||||
fi
|
||||
else
|
||||
# Fallback: grep Overall Risk section
|
||||
RISK_TEXT=""
|
||||
if [ -d video-reviews ]; then
|
||||
RISK_TEXT=$(sed -n '/^## Overall Risk/,/^## /p' video-reviews/*.md 2>/dev/null | sed 's/\*//g' | head -20)
|
||||
fi
|
||||
RISK_FIRST=$(echo "$RISK_TEXT" | grep -oiP '^\s*(high|medium|moderate|low|minimal|critical)' | head -1 | tr '[:upper:]' '[:lower:]')
|
||||
if [ -n "$RISK_FIRST" ]; then
|
||||
case "$RISK_FIRST" in
|
||||
*low*|*minimal*) FIX_RESULT="APPROVED" FIX_COLOR="#4c1" ;;
|
||||
*medium*|*moderate*) FIX_RESULT="MINOR ISSUES" FIX_COLOR="#dfb317" ;;
|
||||
*high*|*critical*) FIX_RESULT="MAJOR ISSUES" FIX_COLOR="#e05d44" ;;
|
||||
esac
|
||||
elif echo "$RISK_TEXT" | grep -iq 'no.*risk\|approved\|looks good'; then
|
||||
FIX_RESULT="APPROVED" FIX_COLOR="#4c1"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# Always use vertical box badge
|
||||
/tmp/gen-badge-box.sh "$DEPLOY_DIR/badge.svg" "$BADGE_LABEL" \
|
||||
"$REPRO_COUNT" "$NOT_REPRO_COUNT" "$FAIL_COUNT" "$TOTAL_REPORTS" \
|
||||
"$FIX_RESULT" "$FIX_COLOR"
|
||||
BADGE_STATUS="${REPRO_RESULT:-UNKNOWN}${FIX_RESULT:+ | Fix: ${FIX_RESULT}}"
|
||||
echo "badge_status=${BADGE_STATUS:-FINISHED}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
BRANCH=$(echo "$RAW_BRANCH" | sed 's/[^a-zA-Z0-9-]/-/g' | sed 's/--*/-/g' | sed 's/^-//;s/-$//' | cut -c1-28)
|
||||
URL=$(wrangler pages deploy "$DEPLOY_DIR" \
|
||||
--project-name="comfy-qa" \
|
||||
--branch="$BRANCH" 2>&1 \
|
||||
| grep -oE 'https://[a-zA-Z0-9.-]+\.pages\.dev\S*' | head -1)
|
||||
|
||||
echo "url=${URL:-https://${BRANCH}.comfy-qa.pages.dev}" >> "$GITHUB_OUTPUT"
|
||||
echo "Deployed to: ${URL}"
|
||||
208
scripts/qa-generate-test.ts
Normal file
208
scripts/qa-generate-test.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
#!/usr/bin/env tsx
|
||||
/**
|
||||
* Generates a Playwright regression test (.spec.ts) from a QA report + PR diff.
|
||||
* Uses Gemini to produce a test that asserts UIUX behavior verified during QA.
|
||||
*
|
||||
* Usage:
|
||||
* pnpm exec tsx scripts/qa-generate-test.ts \
|
||||
* --qa-report <path> QA video review report (markdown)
|
||||
* --pr-diff <path> PR diff file
|
||||
* --output <path> Output .spec.ts file path
|
||||
* --model <name> Gemini model (default: gemini-3-flash-preview)
|
||||
*/
|
||||
import { readFile, writeFile } from 'node:fs/promises'
|
||||
import { basename, resolve } from 'node:path'
|
||||
|
||||
import { GoogleGenerativeAI } from '@google/generative-ai'
|
||||
|
||||
interface CliOptions {
|
||||
qaReport: string
|
||||
prDiff: string
|
||||
output: string
|
||||
model: string
|
||||
}
|
||||
|
||||
const DEFAULTS: CliOptions = {
|
||||
qaReport: '',
|
||||
prDiff: '',
|
||||
output: '',
|
||||
model: 'gemini-3-flash-preview'
|
||||
}
|
||||
|
||||
// ── Fixture API reference for the prompt ────────────────────────────
|
||||
const FIXTURE_API = `
|
||||
## ComfyUI Playwright Test Fixture API
|
||||
|
||||
Import pattern:
|
||||
\`\`\`typescript
|
||||
import { expect } from '@playwright/test'
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
\`\`\`
|
||||
|
||||
### Available helpers on \`comfyPage\`:
|
||||
- \`comfyPage.page\` — raw Playwright Page
|
||||
- \`comfyPage.menu.topbar\` — Topbar helper:
|
||||
- \`.getTabNames(): Promise<string[]>\` — get all open tab names
|
||||
- \`.getActiveTabName(): Promise<string>\` — get active tab name
|
||||
- \`.saveWorkflow(name)\` — Save via File > Save dialog
|
||||
- \`.saveWorkflowAs(name)\` — Save via File > Save As dialog
|
||||
- \`.exportWorkflow(name)\` — Export via File > Export dialog
|
||||
- \`.triggerTopbarCommand(path: string[])\` — e.g. ['File', 'Save As']
|
||||
- \`.getWorkflowTab(name)\` — get a tab locator by name
|
||||
- \`.closeWorkflowTab(name)\` — close a tab
|
||||
- \`.openTopbarMenu()\` — open the hamburger menu
|
||||
- \`.openSubmenu(label)\` — hover to open a submenu
|
||||
- \`comfyPage.menu.workflowsTab\` — Workflows sidebar:
|
||||
- \`.open()\` / \`.close()\` — toggle sidebar
|
||||
- \`.getTopLevelSavedWorkflowNames()\` — list saved workflows
|
||||
- \`.getPersistedItem(name)\` — get a workflow item locator
|
||||
- \`comfyPage.workflow\` — WorkflowHelper:
|
||||
- \`.loadWorkflow(name)\` — load from browser_tests/assets/{name}.json
|
||||
- \`.setupWorkflowsDirectory(structure)\` — setup test directory
|
||||
- \`.deleteWorkflow(name)\` — delete a workflow
|
||||
- \`.isCurrentWorkflowModified(): Promise<boolean>\` — check dirty state
|
||||
- \`.getUndoQueueSize()\` / \`.getRedoQueueSize()\`
|
||||
- \`comfyPage.settings.setSetting(key, value)\` — change settings
|
||||
- \`comfyPage.keyboard\` — KeyboardHelper:
|
||||
- \`.undo()\` / \`.redo()\` / \`.bypass()\`
|
||||
- \`comfyPage.nodeOps\` — NodeOperationsHelper
|
||||
- \`comfyPage.canvas\` — CanvasHelper
|
||||
- \`comfyPage.contextMenu\` — ContextMenu
|
||||
- \`comfyPage.toast\` — ToastHelper
|
||||
- \`comfyPage.confirmDialog\` — confirmation dialog
|
||||
- \`comfyPage.nextFrame()\` — wait for Vue re-render
|
||||
|
||||
### Test patterns:
|
||||
- Use \`test.describe('Name', { tag: '@ui' }, () => { ... })\` for UI tests
|
||||
- Use \`test.beforeEach\` to set up common state (settings, workflow dir)
|
||||
- Use \`expect(locator).toHaveScreenshot('name.png')\` for visual assertions
|
||||
- Use \`expect(locator).toBeVisible()\` / \`.toHaveText()\` for behavioral assertions
|
||||
- Use \`comfyPage.workflow.setupWorkflowsDirectory({})\` to ensure clean state
|
||||
`
|
||||
|
||||
// ── Prompt builder ──────────────────────────────────────────────────
|
||||
function buildPrompt(qaReport: string, prDiff: string): string {
|
||||
return `You are a Playwright test generator for the ComfyUI frontend.
|
||||
|
||||
Your task: Generate a single .spec.ts regression test file that asserts the UIUX behavior
|
||||
described in the QA report below. The test must:
|
||||
|
||||
1. Use the ComfyUI Playwright fixture API (documented below)
|
||||
2. Test UIUX behavior ONLY — element visibility, tab names, dialog states, workflow states
|
||||
3. NOT test code implementation details
|
||||
4. Be concise — only test the behavior that the PR changed
|
||||
5. Follow existing test conventions (see API reference)
|
||||
|
||||
${FIXTURE_API}
|
||||
|
||||
## QA Video Review Report
|
||||
${qaReport}
|
||||
|
||||
## PR Diff (for context on what changed)
|
||||
${prDiff.slice(0, 8000)}
|
||||
|
||||
## Output Requirements
|
||||
- Output ONLY the .spec.ts file content — no markdown fences, no explanations
|
||||
- Start with imports, end with closing brace
|
||||
- Use descriptive test names that explain the expected behavior
|
||||
- Add screenshot assertions where visual verification matters
|
||||
- Keep it focused: 2-5 test cases covering the core behavioral change
|
||||
- Use \`test.beforeEach\` for common setup (settings, workflow directory)
|
||||
- Tag the describe block with \`{ tag: '@ui' }\` or \`{ tag: '@workflow' }\` as appropriate
|
||||
`
|
||||
}
|
||||
|
||||
// ── Gemini call ─────────────────────────────────────────────────────
|
||||
async function generateTest(
|
||||
qaReport: string,
|
||||
prDiff: string,
|
||||
model: string
|
||||
): Promise<string> {
|
||||
const apiKey = process.env.GEMINI_API_KEY
|
||||
if (!apiKey) throw new Error('GEMINI_API_KEY env var required')
|
||||
|
||||
const genAI = new GoogleGenerativeAI(apiKey)
|
||||
const genModel = genAI.getGenerativeModel({ model })
|
||||
|
||||
const prompt = buildPrompt(qaReport, prDiff)
|
||||
console.warn(`Sending prompt to ${model} (${prompt.length} chars)...`)
|
||||
|
||||
const result = await genModel.generateContent({
|
||||
contents: [{ role: 'user', parts: [{ text: prompt }] }],
|
||||
generationConfig: {
|
||||
temperature: 0.2,
|
||||
maxOutputTokens: 8192
|
||||
}
|
||||
})
|
||||
|
||||
const text = result.response.text()
|
||||
|
||||
// Strip markdown fences if model wraps output
|
||||
return text
|
||||
.replace(/^```(?:typescript|ts)?\n?/, '')
|
||||
.replace(/\n?```$/, '')
|
||||
.trim()
|
||||
}
|
||||
|
||||
// ── CLI ─────────────────────────────────────────────────────────────
|
||||
function parseArgs(): CliOptions {
|
||||
const args = process.argv.slice(2)
|
||||
const opts = { ...DEFAULTS }
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
switch (args[i]) {
|
||||
case '--qa-report':
|
||||
opts.qaReport = args[++i]
|
||||
break
|
||||
case '--pr-diff':
|
||||
opts.prDiff = args[++i]
|
||||
break
|
||||
case '--output':
|
||||
opts.output = args[++i]
|
||||
break
|
||||
case '--model':
|
||||
opts.model = args[++i]
|
||||
break
|
||||
case '--help':
|
||||
console.warn(`Usage:
|
||||
pnpm exec tsx scripts/qa-generate-test.ts [options]
|
||||
|
||||
Options:
|
||||
--qa-report <path> QA video review report (markdown) [required]
|
||||
--pr-diff <path> PR diff file [required]
|
||||
--output <path> Output .spec.ts path [required]
|
||||
--model <name> Gemini model (default: gemini-3-flash-preview)`)
|
||||
process.exit(0)
|
||||
}
|
||||
}
|
||||
|
||||
if (!opts.qaReport || !opts.prDiff || !opts.output) {
|
||||
console.error('Missing required args. Run with --help for usage.')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
return opts
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const opts = parseArgs()
|
||||
|
||||
const qaReport = await readFile(resolve(opts.qaReport), 'utf-8')
|
||||
const prDiff = await readFile(resolve(opts.prDiff), 'utf-8')
|
||||
|
||||
console.warn(
|
||||
`QA report: ${basename(opts.qaReport)} (${qaReport.length} chars)`
|
||||
)
|
||||
console.warn(`PR diff: ${basename(opts.prDiff)} (${prDiff.length} chars)`)
|
||||
|
||||
const testCode = await generateTest(qaReport, prDiff, opts.model)
|
||||
|
||||
const outputPath = resolve(opts.output)
|
||||
await writeFile(outputPath, testCode + '\n')
|
||||
console.warn(`Generated test: ${outputPath} (${testCode.length} chars)`)
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err)
|
||||
process.exit(1)
|
||||
})
|
||||
2126
scripts/qa-record.ts
Normal file
2126
scripts/qa-record.ts
Normal file
File diff suppressed because it is too large
Load Diff
135
scripts/qa-report-template.html
Normal file
135
scripts/qa-report-template.html
Normal file
@@ -0,0 +1,135 @@
|
||||
<!DOCTYPE html><html lang=en><head><meta charset=utf-8><meta name=viewport content="width=device-width,initial-scale=1"><title>QA Session Recordings</title>
|
||||
<link rel=preconnect href=https://fonts.googleapis.com><link rel=preconnect href=https://fonts.gstatic.com crossorigin><link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel=stylesheet>
|
||||
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
||||
<style>
|
||||
:root{--bg:oklch(97% 0.01 265);--surface:oklch(100% 0 0);--surface-up:oklch(94% 0.01 265);--fg:oklch(15% 0.02 265);--fg-muted:oklch(40% 0.01 265);--fg-dim:oklch(55% 0.01 265);--primary:oklch(50% 0.21 265);--primary-up:oklch(45% 0.21 265);--primary-glow:oklch(55% 0.15 265);--ok:oklch(45% 0.18 155);--err:oklch(50% 0.22 25);--border:oklch(85% 0.01 265);--border-faint:oklch(90% 0.01 265);--r:0.75rem;--r-lg:1rem;--ease-out:cubic-bezier(0.22,1,0.36,1);--dur-base:250ms;--dur-slow:500ms;--font:'Inter',system-ui,sans-serif;--font-mono:'JetBrains Mono',monospace}
|
||||
@media(prefers-color-scheme:dark){:root{--bg:oklch(8% 0.02 265);--surface:oklch(12% 0.02 265);--surface-up:oklch(16% 0.02 265);--fg:oklch(96% 0.01 95);--fg-muted:oklch(65% 0.01 265);--fg-dim:oklch(45% 0.01 265);--primary:oklch(62% 0.21 265);--primary-up:oklch(68% 0.21 265);--primary-glow:oklch(62% 0.15 265);--ok:oklch(62% 0.18 155);--err:oklch(62% 0.22 25);--border:oklch(22% 0.02 265);--border-faint:oklch(15% 0.01 265)}}
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
body{background:var(--bg);color:var(--fg);font-family:var(--font);min-height:100vh;padding:clamp(1.5rem,4vw,3rem) clamp(1rem,3vw,2rem);position:relative}
|
||||
@media(prefers-color-scheme:dark){body::after{content:'';position:fixed;inset:0;pointer-events:none;opacity:.03;background:url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.85' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E")}}
|
||||
.container{max-width:1200px;margin:0 auto}
|
||||
header{display:flex;align-items:center;gap:1rem;margin-bottom:clamp(1.5rem,4vw,3rem);padding-bottom:1.25rem;border-bottom:1px solid var(--border)}
|
||||
.header-icon{width:36px;height:36px;display:grid;place-items:center;background:linear-gradient(135deg,oklch(100% 0 0/.06),oklch(100% 0 0/.02));backdrop-filter:blur(12px);border:1px solid oklch(100% 0 0/.1);border-radius:var(--r);flex-shrink:0}
|
||||
.header-icon svg{color:var(--primary)}
|
||||
h1{font-size:clamp(1.25rem,2.5vw,1.625rem);font-weight:700;letter-spacing:-.03em;background:linear-gradient(135deg,var(--fg),var(--fg-muted));-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text}
|
||||
.meta{color:var(--fg-dim);font-size:.8125rem;margin-top:.15rem;letter-spacing:.01em}
|
||||
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(min(480px,100%),1fr));gap:1.5rem}
|
||||
.card{background:var(--surface);border:1px solid var(--border);border-radius:var(--r-lg);overflow:hidden;transition:border-color var(--dur-base) var(--ease-out),box-shadow var(--dur-base) var(--ease-out),transform var(--dur-base) var(--ease-out)}
|
||||
.card:hover{border-color:var(--primary);box-shadow:0 4px 16px oklch(0% 0 0/.1);transform:translateY(-2px)}
|
||||
.video-wrap{position:relative;background:var(--surface);border-bottom:1px solid var(--border-faint)}
|
||||
.video-wrap video{width:100%;display:block;aspect-ratio:16/9;object-fit:contain}
|
||||
.card-body{padding:.75rem 1rem;display:flex;align-items:center;justify-content:space-between}
|
||||
.platform{display:flex;align-items:center;gap:.5rem;font-weight:600;font-size:.9375rem;letter-spacing:-.01em}
|
||||
.icon{font-size:1.125rem}
|
||||
.links{display:flex;gap:.5rem}
|
||||
.dl{color:var(--fg-muted);text-decoration:none;font-size:.75rem;font-weight:500;display:inline-flex;align-items:center;gap:.3rem;padding:.25rem .6rem;border-radius:9999px;border:1px solid var(--border);background:oklch(100% 0 0/.03);transition:all var(--dur-base) var(--ease-out)}
|
||||
.dl:hover{color:var(--primary-up);border-color:var(--primary);background:oklch(62% 0.21 265/.08)}
|
||||
.badge{font-size:.6875rem;font-weight:600;padding:.2rem .625rem;border-radius:9999px;text-transform:uppercase;letter-spacing:.05em}
|
||||
.card-header{padding:.75rem 1rem;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--border-faint)}
|
||||
.comparison{display:grid;grid-template-columns:1fr 1fr;gap:0}
|
||||
.comp-panel{border-right:1px solid var(--border-faint)}
|
||||
.comp-panel:last-child{border-right:none}
|
||||
.comp-label{padding:.4rem .75rem;font-size:.7rem;font-weight:600;text-transform:uppercase;letter-spacing:.05em;color:var(--fg-muted);background:var(--surface);display:flex;align-items:center;gap:.4rem}
|
||||
.comp-tag{font-size:.6rem;padding:.1rem .4rem;border-radius:9999px;font-weight:600}
|
||||
.comp-panel:first-child .comp-tag{background:oklch(65% 0.01 265/.15);color:var(--fg-muted);border:1px solid var(--border)}
|
||||
.comp-panel:last-child .comp-tag{background:oklch(62% 0.18 155/.15);color:var(--ok);border:1px solid oklch(62% 0.18 155/.25)}
|
||||
.comp-dl{padding:.4rem .75rem;display:flex;justify-content:center}
|
||||
.report{border-top:1px solid var(--border-faint);padding:.75rem 1rem;font-size:.8125rem}
|
||||
.report summary{cursor:pointer;color:var(--fg-muted);font-weight:500;display:flex;align-items:center;gap:.4rem;user-select:none;transition:color var(--dur-base) var(--ease-out)}
|
||||
.report summary:hover{color:var(--fg)}
|
||||
.report summary svg{flex-shrink:0;opacity:.5}
|
||||
.report[open] summary{margin-bottom:.75rem;padding-bottom:.5rem;border-bottom:1px solid var(--border-faint)}
|
||||
.report-body{line-height:1.7;color:oklch(80% 0.01 265);overflow-x:auto}
|
||||
.report-body h1,.report-body h2{margin:1.25rem 0 .5rem;color:var(--fg);font-size:1rem;font-weight:600;letter-spacing:-.02em;border-bottom:1px solid var(--border-faint);padding-bottom:.4rem}
|
||||
.report-body h3{margin:.75rem 0 .4rem;color:var(--fg);font-size:.875rem;font-weight:600}
|
||||
.report-body p{margin:.4rem 0}
|
||||
.report-body ul,.report-body ol{margin:.4rem 0 .4rem 1.5rem}
|
||||
.report-body li{margin:.25rem 0}
|
||||
.report-body code{background:var(--surface-up);padding:.125rem .375rem;border-radius:.25rem;font-size:.7rem;font-family:var(--font-mono);border:1px solid var(--border-faint)}
|
||||
.report-body h3+p>code:first-child{background:oklch(62% 0.22 25/.15);color:var(--err);border-color:oklch(62% 0.22 25/.25)}
|
||||
.report-body h3+p>code:nth-child(2){background:oklch(62% 0.21 265/.15);color:var(--primary-up);border-color:oklch(62% 0.21 265/.25)}
|
||||
.report-body h3+p>code:nth-child(3){background:oklch(65% 0.01 265/.15);color:var(--fg-muted);border-color:var(--border)}
|
||||
.report-body table{width:100%;border-collapse:collapse;margin:.75rem 0;font-size:.75rem;border:1px solid var(--border);border-radius:var(--r);overflow:hidden}
|
||||
.report-body th,.report-body td{border:1px solid var(--border-faint);padding:.5rem .75rem;text-align:left;vertical-align:top;word-wrap:break-word}
|
||||
.report-body th{background:var(--surface-up);color:var(--fg);font-weight:600;font-size:.6875rem;text-transform:uppercase;letter-spacing:.05em;position:sticky;top:0;white-space:nowrap}
|
||||
.report-body tr:nth-child(even){background:color-mix(in oklch,var(--surface) 50%,transparent)}
|
||||
.report-body tr:hover{background:color-mix(in oklch,var(--surface-up) 50%,transparent)}
|
||||
.report-body strong{color:var(--fg)}
|
||||
.report-body hr{border:none;border-top:1px solid var(--border-faint);margin:1rem 0}
|
||||
@keyframes fade-up{from{opacity:0;transform:translateY(16px)}to{opacity:1;transform:translateY(0)}}
|
||||
.reveal{animation:fade-up var(--dur-slow) var(--ease-out) both;animation-delay:calc(var(--i,0) * 120ms)}
|
||||
@media(prefers-reduced-motion:reduce){.reveal{animation:none}}
|
||||
@media(max-width:480px){.grid{grid-template-columns:1fr}.card-body{flex-wrap:wrap;gap:.5rem}}
|
||||
.sha{color:var(--primary);text-decoration:none;font-family:var(--font-mono);font-size:.75rem;font-weight:500;padding:.1rem .4rem;border-radius:.25rem;background:oklch(62% 0.21 265/.08);border:1px solid oklch(62% 0.21 265/.15);transition:all var(--dur-base) var(--ease-out)}
|
||||
.sha:hover{background:oklch(62% 0.21 265/.15);border-color:var(--primary)}
|
||||
.badge-bar{display:flex;align-items:center;gap:.5rem;margin-bottom:1rem}
|
||||
.badge-img{height:20px;display:block}
|
||||
.copy-badge{background:oklch(100% 0 0/.06);border:1px solid var(--border);color:var(--fg-muted);padding:.3rem .4rem;border-radius:var(--r);cursor:pointer;display:inline-flex;align-items:center;transition:all var(--dur-base) var(--ease-out)}
|
||||
.copy-badge:hover{color:var(--primary-up);border-color:var(--primary);background:oklch(62% 0.21 265/.1)}
|
||||
.copy-badge.copied{color:var(--ok);border-color:var(--ok)}
|
||||
.vseek{width:100%;padding:0 .75rem;background:var(--surface);border-top:1px solid var(--border-faint);position:relative;height:24px;display:flex;align-items:center}
|
||||
.vseek input[type=range]{-webkit-appearance:none;appearance:none;width:100%;height:4px;background:var(--border);border-radius:2px;outline:none;cursor:pointer;position:relative;z-index:2}
|
||||
.vseek input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;width:12px;height:12px;border-radius:50%;background:var(--primary);cursor:pointer;border:2px solid var(--bg);box-shadow:0 0 4px oklch(0% 0 0/.3)}
|
||||
.vseek input[type=range]::-moz-range-thumb{width:12px;height:12px;border-radius:50%;background:var(--primary);cursor:pointer;border:2px solid var(--bg)}
|
||||
.vseek .vbuf{position:absolute;left:.75rem;right:.75rem;height:4px;border-radius:2px;pointer-events:none;top:50%;transform:translateY(-50%)}
|
||||
.vseek .vbuf-bar{height:100%;background:oklch(62% 0.21 265/.25);border-radius:2px;transition:width 200ms linear}
|
||||
.vctrl{display:flex;align-items:center;gap:.375rem;padding:.5rem .75rem;background:var(--surface);border-top:1px solid var(--border-faint);flex-wrap:wrap}
|
||||
.vctrl button{background:oklch(100% 0 0/.06);border:1px solid var(--border);color:var(--fg-muted);font-size:.6875rem;font-weight:600;font-family:var(--font-mono);padding:.25rem .5rem;border-radius:.25rem;cursor:pointer;transition:all var(--dur-base) var(--ease-out);white-space:nowrap}
|
||||
.vctrl button:hover{color:var(--primary-up);border-color:var(--primary);background:oklch(62% 0.21 265/.1)}
|
||||
.vctrl button.active{color:var(--primary);border-color:var(--primary);background:oklch(62% 0.21 265/.15)}
|
||||
.vctrl .vtime{font-family:var(--font-mono);font-size:.6875rem;color:var(--fg-dim);min-width:10ch;text-align:center}
|
||||
.vctrl .vsep{width:1px;height:1rem;background:var(--border);flex-shrink:0}
|
||||
.vctrl .vhint{font-size:.6rem;color:var(--fg-dim);margin-left:auto}
|
||||
.purpose{background:linear-gradient(135deg,oklch(100% 0 0/.04),oklch(100% 0 0/.02));border:1px solid oklch(100% 0 0/.08);border-radius:var(--r-lg);padding:1rem 1.25rem;margin-bottom:1.5rem;font-size:.85rem;line-height:1.7;color:oklch(80% 0.01 265)}
|
||||
.purpose strong{color:var(--fg);font-weight:600}
|
||||
.purpose .purpose-label{font-size:.7rem;font-weight:600;text-transform:uppercase;letter-spacing:.05em;color:var(--fg-muted);margin-bottom:.4rem}
|
||||
.purpose .purpose-reqs{margin-top:.75rem;padding-top:.75rem;border-top:1px solid oklch(100% 0 0/.06);font-size:.8rem;color:oklch(70% 0.01 265);line-height:1.8}
|
||||
</style></head><body><div class=container>
|
||||
<header><div class=header-icon><svg width=20 height=20 viewBox="0 0 24 24" fill=none stroke=currentColor stroke-width=2 stroke-linecap=round stroke-linejoin=round><polygon points="23 7 16 12 23 17 23 7"/><rect x=1 y=5 width=15 height=14 rx=2 ry=2/></svg></div><div><h1>QA Session Recordings</h1><div class=meta>ComfyUI Frontend · Automated QA{{COMMIT_HTML}}{{RUN_LINK}}{{TIMING_HTML}}</div>{{BADGE_HTML}}</div></header>
|
||||
{{PURPOSE_HTML}}<div class=grid>{{CARDS}}</div>
|
||||
</div><script>
|
||||
function copyBadge(){const u=location.href.replace(/\/[^/]*$/,'/');const b=u+'badge.svg';const md='[]('+u+')';navigator.clipboard.writeText(md).then(()=>{const btn=document.querySelector('.copy-badge');btn.classList.add('copied');btn.innerHTML='<svg width=14 height=14 viewBox="0 0 24 24" fill=none stroke=currentColor stroke-width=2><polyline points="20 6 9 17 4 12"/></svg>';setTimeout(()=>{btn.classList.remove('copied');btn.innerHTML='<svg width=14 height=14 viewBox="0 0 24 24" fill=none stroke=currentColor stroke-width=2><rect x=9 y=9 width=13 height=13 rx=2/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>'},2000)})}
|
||||
document.querySelectorAll('[data-md]').forEach(el=>{const t=el.textContent;el.removeAttribute('data-md');el.innerHTML=marked.parse(t)});
|
||||
const FPS=30,FT=1/FPS,SPEEDS=[0.1,0.25,0.5,1,1.5,2];
|
||||
document.querySelectorAll('.video-wrap video').forEach(v=>{
|
||||
v.playbackRate=0.5;v.removeAttribute('autoplay');v.pause();
|
||||
const c=document.createElement('div');c.className='vctrl';
|
||||
const btn=(label,fn)=>{const b=document.createElement('button');b.textContent=label;b.onclick=fn;c.appendChild(b);return b};
|
||||
const sep=()=>{const s=document.createElement('div');s.className='vsep';c.appendChild(s)};
|
||||
const time=document.createElement('span');time.className='vtime';time.textContent='0:00.000';
|
||||
btn('\u23EE',()=>{v.currentTime=0});
|
||||
btn('\u25C0\u25C0',()=>{v.currentTime=Math.max(0,v.currentTime-FT*10)});
|
||||
btn('\u25C0',()=>{v.pause();v.currentTime=Math.max(0,v.currentTime-FT)});
|
||||
const playBtn=btn('\u25B6',()=>{v.paused?v.play():v.pause()});
|
||||
btn('\u25B6\u25B6',()=>{v.pause();v.currentTime+=FT});
|
||||
btn('\u25B6\u25B6\u25B6',()=>{v.currentTime+=FT*10});
|
||||
sep();
|
||||
const spdBtns=SPEEDS.map(s=>{const b=btn(s+'x',()=>{v.playbackRate=s;spdBtns.forEach(x=>x.classList.remove('active'));b.classList.add('active')});if(s===0.5)b.classList.add('active');return b});
|
||||
sep();c.appendChild(time);
|
||||
const hint=document.createElement('span');hint.className='vhint';hint.textContent='\u2190\u2192 frame \u2022 space play';c.appendChild(hint);
|
||||
// Custom seekbar — works even without server range request support
|
||||
const seekWrap=document.createElement('div');seekWrap.className='vseek';
|
||||
const seekBar=document.createElement('input');seekBar.type='range';seekBar.min=0;seekBar.max=1000;seekBar.value=0;seekBar.step=1;
|
||||
const bufWrap=document.createElement('div');bufWrap.className='vbuf';
|
||||
const bufBar=document.createElement('div');bufBar.className='vbuf-bar';bufBar.style.width='0%';
|
||||
bufWrap.appendChild(bufBar);seekWrap.appendChild(bufWrap);seekWrap.appendChild(seekBar);
|
||||
let seeking=false;
|
||||
seekBar.oninput=()=>{seeking=true;if(v.duration){v.currentTime=v.duration*(seekBar.value/1000)}};
|
||||
seekBar.onchange=()=>{seeking=false};
|
||||
v.closest('.video-wrap').after(seekWrap);
|
||||
seekWrap.after(c);
|
||||
v.ontimeupdate=()=>{
|
||||
const m=Math.floor(v.currentTime/60),s=Math.floor(v.currentTime%60),ms=Math.floor((v.currentTime%1)*1000);
|
||||
time.textContent=m+':'+(s<10?'0':'')+s+'.'+String(ms).padStart(3,'0');
|
||||
if(!seeking&&v.duration){seekBar.value=Math.round((v.currentTime/v.duration)*1000)}
|
||||
};
|
||||
v.onprogress=v.onloadeddata=()=>{if(v.buffered.length&&v.duration){bufBar.style.width=(v.buffered.end(v.buffered.length-1)/v.duration*100)+'%'}};
|
||||
v.onplay=()=>{playBtn.textContent='\u23F8'};v.onpause=()=>{playBtn.textContent='\u25B6'};
|
||||
v.parentElement.addEventListener('keydown',e=>{
|
||||
if(e.key==='ArrowLeft'){e.preventDefault();v.pause();v.currentTime=Math.max(0,v.currentTime-FT)}
|
||||
if(e.key==='ArrowRight'){e.preventDefault();v.pause();v.currentTime+=FT}
|
||||
if(e.key===' '){e.preventDefault();v.paused?v.play():v.pause()}
|
||||
});
|
||||
v.parentElement.setAttribute('tabindex','0');
|
||||
});
|
||||
</script></body></html>
|
||||
253
scripts/qa-reproduce.ts
Normal file
253
scripts/qa-reproduce.ts
Normal file
@@ -0,0 +1,253 @@
|
||||
#!/usr/bin/env tsx
|
||||
/**
|
||||
* QA Reproduce Phase — Deterministic replay of research plan with narration
|
||||
*
|
||||
* Takes a reproduction plan from the research phase and replays it:
|
||||
* 1. Execute each action deterministically (no AI decisions)
|
||||
* 2. Capture a11y snapshot before/after each action
|
||||
* 3. Gemini describes what visually changed (narration for humans)
|
||||
* 4. Output: narration-log.json with full evidence chain
|
||||
*/
|
||||
|
||||
import type { Page } from '@playwright/test'
|
||||
import { GoogleGenerativeAI } from '@google/generative-ai'
|
||||
import { mkdirSync, writeFileSync } from 'fs'
|
||||
|
||||
import type { ActionResult } from './qa-record.js'
|
||||
|
||||
// ── Types ──
|
||||
|
||||
interface ReproductionStep {
|
||||
action: Record<string, unknown> & { action: string }
|
||||
expectedAssertion: string
|
||||
}
|
||||
|
||||
interface NarrationEntry {
|
||||
step: number
|
||||
action: string
|
||||
params: Record<string, unknown>
|
||||
result: ActionResult
|
||||
a11yBefore: unknown
|
||||
a11yAfter: unknown
|
||||
assertionExpected: string
|
||||
assertionPassed: boolean
|
||||
assertionActual: string
|
||||
geminiNarration: string
|
||||
timestampMs: number
|
||||
}
|
||||
|
||||
export interface NarrationLog {
|
||||
entries: NarrationEntry[]
|
||||
allAssertionsPassed: boolean
|
||||
}
|
||||
|
||||
interface ReproduceOptions {
|
||||
page: Page
|
||||
plan: ReproductionStep[]
|
||||
geminiApiKey: string
|
||||
outputDir: string
|
||||
}
|
||||
|
||||
// ── A11y helpers ──
|
||||
|
||||
interface A11yNode {
|
||||
role: string
|
||||
name: string
|
||||
value?: string
|
||||
checked?: boolean
|
||||
disabled?: boolean
|
||||
expanded?: boolean
|
||||
children?: A11yNode[]
|
||||
}
|
||||
|
||||
function searchA11y(node: A11yNode | null, selector: string): A11yNode | null {
|
||||
if (!node) return null
|
||||
const sel = selector.toLowerCase()
|
||||
if (
|
||||
node.name?.toLowerCase().includes(sel) ||
|
||||
node.role?.toLowerCase().includes(sel)
|
||||
) {
|
||||
return node
|
||||
}
|
||||
if (node.children) {
|
||||
for (const child of node.children) {
|
||||
const found = searchA11y(child, selector)
|
||||
if (found) return found
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function summarizeA11y(node: A11yNode | null): string {
|
||||
if (!node) return 'null'
|
||||
const parts = [`role=${node.role}`, `name="${node.name}"`]
|
||||
if (node.value !== undefined) parts.push(`value="${node.value}"`)
|
||||
if (node.checked !== undefined) parts.push(`checked=${node.checked}`)
|
||||
if (node.disabled) parts.push('disabled')
|
||||
if (node.expanded !== undefined) parts.push(`expanded=${node.expanded}`)
|
||||
return `{${parts.join(', ')}}`
|
||||
}
|
||||
|
||||
// ── Subtitle overlay ──
|
||||
|
||||
async function showSubtitle(page: Page, text: string, step: number) {
|
||||
const encoded = encodeURIComponent(
|
||||
text.slice(0, 120).replace(/'/g, "\\'").replace(/\n/g, ' ')
|
||||
)
|
||||
await page.addScriptTag({
|
||||
content: `(function(){
|
||||
var id='qa-subtitle';
|
||||
var el=document.getElementById(id);
|
||||
if(!el){
|
||||
el=document.createElement('div');
|
||||
el.id=id;
|
||||
Object.assign(el.style,{position:'fixed',bottom:'32px',left:'50%',transform:'translateX(-50%)',zIndex:'2147483646',maxWidth:'90%',padding:'6px 14px',borderRadius:'6px',background:'rgba(0,0,0,0.8)',color:'rgba(255,255,255,0.95)',fontSize:'12px',fontFamily:'system-ui,sans-serif',fontWeight:'400',lineHeight:'1.4',pointerEvents:'none',textAlign:'center',whiteSpace:'normal'});
|
||||
document.body.appendChild(el);
|
||||
}
|
||||
el.textContent='['+${step}+'] '+decodeURIComponent('${encoded}');
|
||||
})()`
|
||||
})
|
||||
}
|
||||
|
||||
// ── Gemini visual narration ──
|
||||
|
||||
async function geminiDescribe(
|
||||
page: Page,
|
||||
geminiApiKey: string,
|
||||
focus: string
|
||||
): Promise<string> {
|
||||
try {
|
||||
const screenshot = await page.screenshot({ type: 'jpeg', quality: 70 })
|
||||
const genAI = new GoogleGenerativeAI(geminiApiKey)
|
||||
const model = genAI.getGenerativeModel({ model: 'gemini-3-flash-preview' })
|
||||
|
||||
const result = await model.generateContent([
|
||||
{
|
||||
text: `Describe in 1-2 sentences what you see on this ComfyUI screen. Focus on: ${focus}. Be factual — only describe what is visible.`
|
||||
},
|
||||
{
|
||||
inlineData: {
|
||||
mimeType: 'image/jpeg',
|
||||
data: screenshot.toString('base64')
|
||||
}
|
||||
}
|
||||
])
|
||||
return result.response.text().trim()
|
||||
} catch (e) {
|
||||
return `(Gemini narration failed: ${e instanceof Error ? e.message.slice(0, 50) : e})`
|
||||
}
|
||||
}
|
||||
|
||||
// ── Main reproduce function ──
|
||||
|
||||
export async function runReproducePhase(
|
||||
opts: ReproduceOptions
|
||||
): Promise<NarrationLog> {
|
||||
const { page, plan, geminiApiKey, outputDir } = opts
|
||||
const { executeAction } = await import('./qa-record.js')
|
||||
|
||||
const narrationDir = `${outputDir}/narration`
|
||||
mkdirSync(narrationDir, { recursive: true })
|
||||
|
||||
const entries: NarrationEntry[] = []
|
||||
const startMs = Date.now()
|
||||
|
||||
console.warn(`Reproduce phase: replaying ${plan.length} steps...`)
|
||||
|
||||
for (let i = 0; i < plan.length; i++) {
|
||||
const step = plan[i]
|
||||
const actionObj = step.action
|
||||
const elapsed = Date.now() - startMs
|
||||
|
||||
// Show subtitle
|
||||
await showSubtitle(page, `Step ${i + 1}: ${actionObj.action}`, i + 1)
|
||||
console.warn(` [${i + 1}/${plan.length}] ${actionObj.action}`)
|
||||
|
||||
// Capture a11y BEFORE
|
||||
const a11yBefore = await page
|
||||
.locator('body')
|
||||
.ariaSnapshot({ timeout: 3000 })
|
||||
.catch(() => null)
|
||||
|
||||
// Execute action
|
||||
const result = await executeAction(
|
||||
page,
|
||||
actionObj as Parameters<typeof executeAction>[1],
|
||||
outputDir
|
||||
)
|
||||
await new Promise((r) => setTimeout(r, 500))
|
||||
|
||||
// Capture a11y AFTER
|
||||
const a11yAfter = await page
|
||||
.locator('body')
|
||||
.ariaSnapshot({ timeout: 3000 })
|
||||
.catch(() => null)
|
||||
|
||||
// Check assertion
|
||||
let assertionPassed = false
|
||||
let assertionActual = ''
|
||||
if (step.expectedAssertion) {
|
||||
// Parse the expected assertion — e.g. "Settings dialog: visible" or "tab count: 2"
|
||||
const parts = step.expectedAssertion.split(':').map((s) => s.trim())
|
||||
const selectorName = parts[0]
|
||||
const expectedState = parts.slice(1).join(':').trim()
|
||||
|
||||
const found = searchA11y(a11yAfter as A11yNode | null, selectorName)
|
||||
assertionActual = found ? summarizeA11y(found) : 'NOT FOUND'
|
||||
|
||||
if (expectedState === 'visible' || expectedState === 'exists') {
|
||||
assertionPassed = found !== null
|
||||
} else if (expectedState === 'hidden' || expectedState === 'gone') {
|
||||
assertionPassed = found === null
|
||||
} else {
|
||||
// Generic: check if the actual state contains the expected text
|
||||
assertionPassed = assertionActual
|
||||
.toLowerCase()
|
||||
.includes(expectedState.toLowerCase())
|
||||
}
|
||||
|
||||
console.warn(
|
||||
` Assertion: "${step.expectedAssertion}" → ${assertionPassed ? '✓ PASS' : '✗ FAIL'} (actual: ${assertionActual})`
|
||||
)
|
||||
}
|
||||
|
||||
// Gemini narration (visual description for humans)
|
||||
const geminiNarration = await geminiDescribe(
|
||||
page,
|
||||
geminiApiKey,
|
||||
`What changed after ${actionObj.action}?`
|
||||
)
|
||||
|
||||
entries.push({
|
||||
step: i + 1,
|
||||
action: actionObj.action,
|
||||
params: actionObj,
|
||||
result,
|
||||
a11yBefore,
|
||||
a11yAfter,
|
||||
assertionExpected: step.expectedAssertion,
|
||||
assertionPassed,
|
||||
assertionActual,
|
||||
geminiNarration,
|
||||
timestampMs: elapsed
|
||||
})
|
||||
}
|
||||
|
||||
// Final screenshot
|
||||
await page.screenshot({ path: `${outputDir}/reproduce-final.png` })
|
||||
|
||||
const log: NarrationLog = {
|
||||
entries,
|
||||
allAssertionsPassed: entries.every((e) => e.assertionPassed)
|
||||
}
|
||||
|
||||
writeFileSync(
|
||||
`${narrationDir}/narration-log.json`,
|
||||
JSON.stringify(log, null, 2)
|
||||
)
|
||||
console.warn(
|
||||
`Reproduce phase complete: ${entries.filter((e) => e.assertionPassed).length}/${entries.length} assertions passed`
|
||||
)
|
||||
|
||||
return log
|
||||
}
|
||||
150
scripts/qa-video-review.test.ts
Normal file
150
scripts/qa-video-review.test.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
extractPlatformFromArtifactDirName,
|
||||
pickLatestVideosByPlatform,
|
||||
selectVideoCandidateByFile
|
||||
} from './qa-video-review'
|
||||
|
||||
describe('extractPlatformFromArtifactDirName', () => {
|
||||
it('extracts and normalizes known qa artifact directory names', () => {
|
||||
expect(
|
||||
extractPlatformFromArtifactDirName('qa-report-Windows-22818315023')
|
||||
).toBe('windows')
|
||||
expect(
|
||||
extractPlatformFromArtifactDirName('qa-report-macOS-22818315023')
|
||||
).toBe('macos')
|
||||
expect(
|
||||
extractPlatformFromArtifactDirName('qa-report-Linux-22818315023')
|
||||
).toBe('linux')
|
||||
})
|
||||
|
||||
it('falls back to slugifying unknown directory names', () => {
|
||||
expect(extractPlatformFromArtifactDirName('custom platform run')).toBe(
|
||||
'custom-platform-run'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('pickLatestVideosByPlatform', () => {
|
||||
it('keeps only the latest candidate per platform', () => {
|
||||
const selected = pickLatestVideosByPlatform([
|
||||
{
|
||||
platformName: 'windows',
|
||||
videoPath: '/tmp/windows-old.mp4',
|
||||
mtimeMs: 100
|
||||
},
|
||||
{
|
||||
platformName: 'windows',
|
||||
videoPath: '/tmp/windows-new.mp4',
|
||||
mtimeMs: 200
|
||||
},
|
||||
{
|
||||
platformName: 'linux',
|
||||
videoPath: '/tmp/linux.mp4',
|
||||
mtimeMs: 150
|
||||
}
|
||||
])
|
||||
|
||||
expect(selected).toEqual([
|
||||
{
|
||||
platformName: 'linux',
|
||||
videoPath: '/tmp/linux.mp4',
|
||||
mtimeMs: 150
|
||||
},
|
||||
{
|
||||
platformName: 'windows',
|
||||
videoPath: '/tmp/windows-new.mp4',
|
||||
mtimeMs: 200
|
||||
}
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('selectVideoCandidateByFile', () => {
|
||||
it('selects a single candidate by artifacts-relative path', () => {
|
||||
const selected = selectVideoCandidateByFile(
|
||||
[
|
||||
{
|
||||
platformName: 'windows',
|
||||
videoPath: '/tmp/qa-artifacts/qa-report-Windows-1/qa-session.mp4',
|
||||
mtimeMs: 100
|
||||
},
|
||||
{
|
||||
platformName: 'linux',
|
||||
videoPath: '/tmp/qa-artifacts/qa-report-Linux-1/qa-session.mp4',
|
||||
mtimeMs: 200
|
||||
}
|
||||
],
|
||||
{
|
||||
artifactsDir: '/tmp/qa-artifacts',
|
||||
videoFile: 'qa-report-Linux-1/qa-session.mp4'
|
||||
}
|
||||
)
|
||||
|
||||
expect(selected).toEqual({
|
||||
platformName: 'linux',
|
||||
videoPath: '/tmp/qa-artifacts/qa-report-Linux-1/qa-session.mp4',
|
||||
mtimeMs: 200
|
||||
})
|
||||
})
|
||||
|
||||
it('throws when basename matches multiple videos', () => {
|
||||
expect(() =>
|
||||
selectVideoCandidateByFile(
|
||||
[
|
||||
{
|
||||
platformName: 'windows',
|
||||
videoPath: '/tmp/qa-artifacts/qa-report-Windows-1/qa-session.mp4',
|
||||
mtimeMs: 100
|
||||
},
|
||||
{
|
||||
platformName: 'linux',
|
||||
videoPath: '/tmp/qa-artifacts/qa-report-Linux-1/qa-session.mp4',
|
||||
mtimeMs: 200
|
||||
}
|
||||
],
|
||||
{
|
||||
artifactsDir: '/tmp/qa-artifacts',
|
||||
videoFile: 'qa-session.mp4'
|
||||
}
|
||||
)
|
||||
).toThrow('matched 2 videos')
|
||||
})
|
||||
|
||||
it('throws when there is no matching video', () => {
|
||||
expect(() =>
|
||||
selectVideoCandidateByFile(
|
||||
[
|
||||
{
|
||||
platformName: 'windows',
|
||||
videoPath: '/tmp/qa-artifacts/qa-report-Windows-1/qa-session.mp4',
|
||||
mtimeMs: 100
|
||||
}
|
||||
],
|
||||
{
|
||||
artifactsDir: '/tmp/qa-artifacts',
|
||||
videoFile: 'qa-report-macOS-1/qa-session.mp4'
|
||||
}
|
||||
)
|
||||
).toThrow('No video matched')
|
||||
})
|
||||
|
||||
it('throws when video file is missing', () => {
|
||||
expect(() =>
|
||||
selectVideoCandidateByFile(
|
||||
[
|
||||
{
|
||||
platformName: 'windows',
|
||||
videoPath: '/tmp/qa-artifacts/qa-report-Windows-1/qa-session.mp4',
|
||||
mtimeMs: 100
|
||||
}
|
||||
],
|
||||
{
|
||||
artifactsDir: '/tmp/qa-artifacts',
|
||||
videoFile: ' '
|
||||
}
|
||||
)
|
||||
).toThrow('--video-file is required')
|
||||
})
|
||||
})
|
||||
765
scripts/qa-video-review.ts
Normal file
765
scripts/qa-video-review.ts
Normal file
@@ -0,0 +1,765 @@
|
||||
#!/usr/bin/env tsx
|
||||
import { mkdir, readFile, stat, writeFile } from 'node:fs/promises'
|
||||
import { basename, dirname, extname, relative, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { GoogleGenerativeAI } from '@google/generative-ai'
|
||||
import { globSync } from 'glob'
|
||||
|
||||
interface CliOptions {
|
||||
artifactsDir: string
|
||||
videoFile: string
|
||||
beforeVideo: string
|
||||
outputDir: string
|
||||
model: string
|
||||
requestTimeoutMs: number
|
||||
dryRun: boolean
|
||||
prContext: string
|
||||
targetUrl: string
|
||||
passLabel: string
|
||||
}
|
||||
|
||||
interface VideoCandidate {
|
||||
platformName: string
|
||||
videoPath: string
|
||||
mtimeMs: number
|
||||
}
|
||||
|
||||
const DEFAULT_OPTIONS: CliOptions = {
|
||||
artifactsDir: './tmp/qa-artifacts',
|
||||
videoFile: '',
|
||||
beforeVideo: '',
|
||||
outputDir: './tmp',
|
||||
model: 'gemini-3-flash-preview',
|
||||
requestTimeoutMs: 300_000,
|
||||
dryRun: false,
|
||||
prContext: '',
|
||||
targetUrl: '',
|
||||
passLabel: ''
|
||||
}
|
||||
|
||||
const USAGE = `Usage:
|
||||
pnpm exec tsx scripts/qa-video-review.ts [options]
|
||||
|
||||
Options:
|
||||
--artifacts-dir <path> Artifacts root directory
|
||||
(default: ./tmp/qa-artifacts)
|
||||
--video-file <name-or-path> Video file to analyze (required)
|
||||
(supports basename or relative/absolute path)
|
||||
--before-video <path> Before video (main branch) for comparison
|
||||
When provided, sends both videos to Gemini
|
||||
for comparative before/after analysis
|
||||
--output-dir <path> Output directory for markdown reports
|
||||
(default: ./tmp)
|
||||
--model <name> Gemini model
|
||||
(default: gemini-3-flash-preview)
|
||||
--request-timeout-ms <n> Request timeout in milliseconds
|
||||
(default: 300000)
|
||||
--pr-context <file> File with PR context (title, body, diff)
|
||||
for PR-aware review
|
||||
--target-url <url> Issue or PR URL to include in the report
|
||||
--pass-label <label> Label for multi-pass reports (e.g. pass1)
|
||||
Output becomes {platform}-{label}-qa-video-report.md
|
||||
--dry-run Discover videos and output targets only
|
||||
--help Show this help text
|
||||
|
||||
Environment:
|
||||
GEMINI_API_KEY Required unless --dry-run
|
||||
`
|
||||
|
||||
function parsePositiveInteger(rawValue: string, flagName: string): number {
|
||||
const parsedValue = Number.parseInt(rawValue, 10)
|
||||
if (!Number.isInteger(parsedValue) || parsedValue <= 0) {
|
||||
throw new Error(`Invalid value for ${flagName}: "${rawValue}"`)
|
||||
}
|
||||
return parsedValue
|
||||
}
|
||||
|
||||
function parseCliOptions(args: string[]): CliOptions {
|
||||
const options: CliOptions = { ...DEFAULT_OPTIONS }
|
||||
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
const argument = args[index]
|
||||
const nextValue = args[index + 1]
|
||||
const requireValue = (flagName: string): string => {
|
||||
if (!nextValue || nextValue.startsWith('--')) {
|
||||
throw new Error(`Missing value for ${flagName}`)
|
||||
}
|
||||
index += 1
|
||||
return nextValue
|
||||
}
|
||||
|
||||
if (argument === '--help') {
|
||||
process.stdout.write(USAGE)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
if (argument === '--artifacts-dir') {
|
||||
options.artifactsDir = requireValue(argument)
|
||||
continue
|
||||
}
|
||||
|
||||
if (argument === '--video-file') {
|
||||
options.videoFile = requireValue(argument)
|
||||
continue
|
||||
}
|
||||
|
||||
if (argument === '--output-dir') {
|
||||
options.outputDir = requireValue(argument)
|
||||
continue
|
||||
}
|
||||
|
||||
if (argument === '--model') {
|
||||
options.model = requireValue(argument)
|
||||
continue
|
||||
}
|
||||
|
||||
if (argument === '--request-timeout-ms') {
|
||||
options.requestTimeoutMs = parsePositiveInteger(
|
||||
requireValue(argument),
|
||||
argument
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
if (argument === '--before-video') {
|
||||
options.beforeVideo = requireValue(argument)
|
||||
continue
|
||||
}
|
||||
|
||||
if (argument === '--pr-context') {
|
||||
options.prContext = requireValue(argument)
|
||||
continue
|
||||
}
|
||||
|
||||
if (argument === '--target-url') {
|
||||
options.targetUrl = requireValue(argument)
|
||||
continue
|
||||
}
|
||||
|
||||
if (argument === '--pass-label') {
|
||||
options.passLabel = requireValue(argument)
|
||||
continue
|
||||
}
|
||||
|
||||
if (argument === '--dry-run') {
|
||||
options.dryRun = true
|
||||
continue
|
||||
}
|
||||
|
||||
throw new Error(`Unknown argument: ${argument}`)
|
||||
}
|
||||
|
||||
return options
|
||||
}
|
||||
|
||||
function normalizePlatformName(value: string): string {
|
||||
const slug = value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
|
||||
return slug.length > 0 ? slug : 'unknown-platform'
|
||||
}
|
||||
|
||||
export function extractPlatformFromArtifactDirName(dirName: string): string {
|
||||
const matchedValue = dirName.match(/^qa-report-(.+?)(?:-\d+)?$/i)?.[1]
|
||||
return normalizePlatformName(matchedValue ?? dirName)
|
||||
}
|
||||
|
||||
function extractPlatformFromVideoPath(videoPath: string): string {
|
||||
const artifactDirName = basename(dirname(videoPath))
|
||||
return extractPlatformFromArtifactDirName(artifactDirName)
|
||||
}
|
||||
|
||||
export function pickLatestVideosByPlatform(
|
||||
candidates: VideoCandidate[]
|
||||
): VideoCandidate[] {
|
||||
const latestByPlatform = new Map<string, VideoCandidate>()
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const current = latestByPlatform.get(candidate.platformName)
|
||||
if (!current || candidate.mtimeMs > current.mtimeMs) {
|
||||
latestByPlatform.set(candidate.platformName, candidate)
|
||||
}
|
||||
}
|
||||
|
||||
return [...latestByPlatform.values()].sort((a, b) =>
|
||||
a.platformName.localeCompare(b.platformName)
|
||||
)
|
||||
}
|
||||
|
||||
function toProjectRelativePath(targetPath: string): string {
|
||||
const relativePath = relative(process.cwd(), targetPath)
|
||||
if (relativePath.startsWith('.')) {
|
||||
return relativePath
|
||||
}
|
||||
return `./${relativePath}`
|
||||
}
|
||||
|
||||
function errorToString(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
|
||||
function normalizePathForMatch(value: string): string {
|
||||
return value.replaceAll('\\', '/').replace(/^\.\/+/, '')
|
||||
}
|
||||
|
||||
export function selectVideoCandidateByFile(
|
||||
candidates: VideoCandidate[],
|
||||
options: { artifactsDir: string; videoFile: string }
|
||||
): VideoCandidate {
|
||||
const requestedValue = options.videoFile.trim()
|
||||
if (requestedValue.length === 0) {
|
||||
throw new Error('--video-file is required')
|
||||
}
|
||||
|
||||
const artifactsRoot = resolve(options.artifactsDir)
|
||||
const requestedAbsolutePath = resolve(requestedValue)
|
||||
const requestedPathKey = normalizePathForMatch(requestedValue)
|
||||
|
||||
const matches = candidates.filter((candidate) => {
|
||||
const candidateAbsolutePath = resolve(candidate.videoPath)
|
||||
if (candidateAbsolutePath === requestedAbsolutePath) {
|
||||
return true
|
||||
}
|
||||
|
||||
const candidateBaseName = basename(candidate.videoPath)
|
||||
if (candidateBaseName === requestedValue) {
|
||||
return true
|
||||
}
|
||||
|
||||
const relativeToCwd = normalizePathForMatch(
|
||||
relative(process.cwd(), candidateAbsolutePath)
|
||||
)
|
||||
if (relativeToCwd === requestedPathKey) {
|
||||
return true
|
||||
}
|
||||
|
||||
const relativeToArtifacts = normalizePathForMatch(
|
||||
relative(artifactsRoot, candidateAbsolutePath)
|
||||
)
|
||||
return relativeToArtifacts === requestedPathKey
|
||||
})
|
||||
|
||||
if (matches.length === 1) {
|
||||
return matches[0]
|
||||
}
|
||||
|
||||
if (matches.length === 0) {
|
||||
const availableVideos = candidates.map((candidate) =>
|
||||
toProjectRelativePath(candidate.videoPath)
|
||||
)
|
||||
throw new Error(
|
||||
[
|
||||
`No video matched --video-file "${options.videoFile}".`,
|
||||
'Available videos:',
|
||||
...availableVideos.map((videoPath) => `- ${videoPath}`)
|
||||
].join('\n')
|
||||
)
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
[
|
||||
`--video-file "${options.videoFile}" matched ${matches.length} videos.`,
|
||||
'Please pass a more specific path.',
|
||||
...matches.map((match) => `- ${toProjectRelativePath(match.videoPath)}`)
|
||||
].join('\n')
|
||||
)
|
||||
}
|
||||
|
||||
async function collectVideoCandidates(
|
||||
artifactsDir: string
|
||||
): Promise<VideoCandidate[]> {
|
||||
const absoluteArtifactsDir = resolve(artifactsDir)
|
||||
const videoPaths = globSync('**/qa-session{,-[0-9]}.mp4', {
|
||||
cwd: absoluteArtifactsDir,
|
||||
absolute: true,
|
||||
nodir: true
|
||||
}).sort()
|
||||
|
||||
const candidates = await Promise.all(
|
||||
videoPaths.map(async (videoPath) => {
|
||||
const videoStat = await stat(videoPath)
|
||||
return {
|
||||
platformName: extractPlatformFromVideoPath(videoPath),
|
||||
videoPath,
|
||||
mtimeMs: videoStat.mtimeMs
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
return candidates
|
||||
}
|
||||
|
||||
function getMimeType(filePath: string): string {
|
||||
const ext = extname(filePath).toLowerCase()
|
||||
const mimeMap: Record<string, string> = {
|
||||
'.mp4': 'video/mp4',
|
||||
'.webm': 'video/webm',
|
||||
'.mov': 'video/quicktime',
|
||||
'.avi': 'video/x-msvideo',
|
||||
'.mkv': 'video/x-matroska',
|
||||
'.m4v': 'video/mp4'
|
||||
}
|
||||
return mimeMap[ext] || 'video/mp4'
|
||||
}
|
||||
|
||||
function buildReviewPrompt(options: {
|
||||
platformName: string
|
||||
videoPath: string
|
||||
prContext: string
|
||||
isComparative: boolean
|
||||
}): string {
|
||||
const { platformName, videoPath, prContext, isComparative } = options
|
||||
|
||||
if (isComparative) {
|
||||
return buildComparativePrompt(platformName, videoPath, prContext)
|
||||
}
|
||||
|
||||
return buildSingleVideoPrompt(platformName, videoPath, prContext)
|
||||
}
|
||||
|
||||
function buildComparativePrompt(
|
||||
platformName: string,
|
||||
videoPath: string,
|
||||
prContext: string
|
||||
): string {
|
||||
const lines = [
|
||||
'You are a senior QA engineer performing a BEFORE/AFTER comparison review.',
|
||||
'',
|
||||
'You are given TWO videos:',
|
||||
'- **Video 1 (BEFORE)**: The main branch BEFORE the PR. This shows the OLD behavior.',
|
||||
'- **Video 2 (AFTER)**: The PR branch AFTER the changes. This shows the NEW behavior.',
|
||||
'',
|
||||
'Both videos show the same test steps executed on different code versions.',
|
||||
''
|
||||
]
|
||||
|
||||
if (prContext) {
|
||||
lines.push('## PR Context', prContext, '')
|
||||
}
|
||||
|
||||
lines.push(
|
||||
'## Your Task',
|
||||
`Platform: "${platformName}". After video: ${toProjectRelativePath(videoPath)}.`,
|
||||
'',
|
||||
'1. **BEFORE video**: Does it demonstrate the old behavior or bug that the PR aims to fix?',
|
||||
' Describe what you observe — this establishes the baseline.',
|
||||
'2. **AFTER video**: Does it prove the PR fix works? Is the intended new behavior visible?',
|
||||
'3. **Comparison**: What specifically changed between before and after?',
|
||||
'4. **Regressions**: Did the PR introduce any new problems visible in the AFTER video',
|
||||
' that were NOT present in the BEFORE video?',
|
||||
'',
|
||||
'Note: Brief black frames during page transitions are NORMAL.',
|
||||
'Note: Small cyan/purple dashed labels prefixed with "QA:" are annotations placed by the automated test script — they are NOT part of the application UI. Do not treat them as bugs or evidence.',
|
||||
'Report only concrete, visible differences. Avoid speculation.',
|
||||
'',
|
||||
'Return markdown with these sections exactly:',
|
||||
'## Summary',
|
||||
'(What the PR changes, whether BEFORE confirms the old behavior, whether AFTER proves the fix)',
|
||||
'',
|
||||
'## Behavior Changes',
|
||||
'Summarize ALL behavioral differences as a markdown TABLE:',
|
||||
'| Behavior | Before (main) | After (PR) | Verdict |',
|
||||
'',
|
||||
'- **Behavior**: short name for the behavior (e.g. "Save shortcut label", "Menu hover style")',
|
||||
'- **Before (main)**: how it works/looks in the BEFORE video',
|
||||
'- **After (PR)**: how it works/looks in the AFTER video',
|
||||
'- **Verdict**: `Fixed`, `Improved`, `Changed`, `Regression`, or `No Change`',
|
||||
'',
|
||||
'One row per distinct behavior. Include both changed AND unchanged key behaviors',
|
||||
'that were tested, so reviewers can confirm nothing was missed.',
|
||||
'',
|
||||
'## Timeline Comparison',
|
||||
'Present a chronological frame-by-frame comparison as a markdown TABLE:',
|
||||
'| Time | Type | Severity | Before (main) | After (PR) |',
|
||||
'',
|
||||
'- **Time**: timestamp or range from the videos (e.g. `0:05-0:08`)',
|
||||
'- **Type**: category such as `Visual`, `Behavior`, `Layout`, `Text`, `Animation`, `Menu`, `State`',
|
||||
'- **Severity**: `None` (neutral change), `Fixed` (bug resolved), `Regression`, `Minor`, `Major`',
|
||||
'- **Before (main)**: what is observed in the BEFORE video at that time',
|
||||
'- **After (PR)**: what is observed in the AFTER video at that time',
|
||||
'',
|
||||
'Include one row per distinct observable difference. If behavior is identical at a timestamp,',
|
||||
'omit that row. Focus on meaningful differences, not narrating every frame.',
|
||||
'',
|
||||
'## Confirmed Issues',
|
||||
'For each issue, use this exact format:',
|
||||
'',
|
||||
'### [Short issue title]',
|
||||
'`SEVERITY` `TIMESTAMP` `Confidence: LEVEL`',
|
||||
'',
|
||||
'[Description — specify whether it appears in BEFORE, AFTER, or both]',
|
||||
'',
|
||||
'**Evidence:** [What you observed at the given timestamp in which video]',
|
||||
'',
|
||||
'**Suggested Fix:** [Actionable recommendation]',
|
||||
'',
|
||||
'---',
|
||||
'',
|
||||
'## Possible Issues (Needs Human Verification)',
|
||||
'## Overall Risk',
|
||||
'(Assess whether the PR achieves its goal based on the before/after comparison)',
|
||||
'',
|
||||
'## Verdict',
|
||||
'End your report with this EXACT JSON block (no markdown fence):',
|
||||
'{"verdict": "REPRODUCED" | "NOT_REPRODUCIBLE" | "INCONCLUSIVE", "risk": "low" | "medium" | "high", "confidence": "high" | "medium" | "low"}',
|
||||
'- REPRODUCED: the before video confirms the old behavior and the after video shows the fix working',
|
||||
'- NOT_REPRODUCIBLE: the before video does not show the reported bug',
|
||||
'- INCONCLUSIVE: the videos do not adequately demonstrate the behavior change'
|
||||
)
|
||||
|
||||
return lines.filter(Boolean).join('\n')
|
||||
}
|
||||
|
||||
function buildSingleVideoPrompt(
|
||||
platformName: string,
|
||||
videoPath: string,
|
||||
prContext: string
|
||||
): string {
|
||||
const lines = [
|
||||
'You are a senior QA engineer reviewing a UI test session recording.',
|
||||
'',
|
||||
'## ANTI-HALLUCINATION RULES (READ FIRST)',
|
||||
'- Describe ONLY what you can directly observe in the video frames',
|
||||
'- NEVER infer or assume what "must have happened" between frames',
|
||||
'- If a step is not visible in the video, say "NOT SHOWN" — do not guess',
|
||||
'- Your job is to be a CAMERA — report facts, not interpretations',
|
||||
''
|
||||
]
|
||||
|
||||
const isIssueContext =
|
||||
prContext &&
|
||||
/^### Issue #|^Title:.*\bbug\b|^This video attempts to reproduce/im.test(
|
||||
prContext
|
||||
)
|
||||
|
||||
if (prContext) {
|
||||
lines.push(
|
||||
'## Phase 1: Blind Observation (describe what you SEE)',
|
||||
'First, describe every UI interaction chronologically WITHOUT knowing the expected outcome:',
|
||||
'- What elements does the user click/hover/type?',
|
||||
'- What dialogs/menus open and close?',
|
||||
'- What keyboard indicators appear? (look for subtitle overlays)',
|
||||
'- What is the BEFORE state and AFTER state of each action?',
|
||||
'',
|
||||
'## Phase 2: Compare against expected behavior',
|
||||
'Now compare your observations against the context below.',
|
||||
'Only claim a match if your Phase 1 observations EXPLICITLY support it.',
|
||||
''
|
||||
)
|
||||
|
||||
if (isIssueContext) {
|
||||
lines.push(
|
||||
'## Issue Context',
|
||||
prContext,
|
||||
'',
|
||||
'## Comparison Questions',
|
||||
'1. Did the video perform the reproduction steps described in the issue?',
|
||||
'2. Did your Phase 1 observations show the reported bug behavior?',
|
||||
'3. If the steps were not performed or the bug was not visible, say INCONCLUSIVE.',
|
||||
''
|
||||
)
|
||||
} else {
|
||||
lines.push(
|
||||
'## PR Context',
|
||||
prContext,
|
||||
'',
|
||||
'## Comparison Questions',
|
||||
'1. Did the video test the specific behavior the PR changes?',
|
||||
'2. Did your Phase 1 observations show the expected before/after difference?',
|
||||
'3. If the test was incomplete or inconclusive, say so honestly.',
|
||||
''
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
lines.push(
|
||||
`Review this QA session video for platform "${platformName}".`,
|
||||
`Source video: ${toProjectRelativePath(videoPath)}.`,
|
||||
'The video shows the full test session — analyze it chronologically.',
|
||||
'Focus on UI regressions, broken states, visual glitches, unreadable text, missing labels/i18n, and clear workflow failures.',
|
||||
'Note: Brief black frames during page transitions are NORMAL and should NOT be reported as issues.',
|
||||
'Note: Small cyan/purple dashed labels prefixed with "QA:" are annotations placed by the automated test script — they are NOT part of the application UI. Do not treat them as bugs or evidence.',
|
||||
'Report only concrete, visible problems and avoid speculation.',
|
||||
'If confidence is low, mark it explicitly.',
|
||||
'',
|
||||
'Return markdown with these sections exactly:',
|
||||
'## Summary',
|
||||
isIssueContext
|
||||
? '(Explain what bug was reported and whether the video confirms it is reproducible)'
|
||||
: prContext
|
||||
? '(Explain what the PR intended and whether the video confirms it works)'
|
||||
: '',
|
||||
'## Confirmed Issues',
|
||||
'For each confirmed issue, use this exact format (one block per issue):',
|
||||
'',
|
||||
'### [Short issue title]',
|
||||
'`HIGH` `01:03` `Confidence: High`',
|
||||
'',
|
||||
'[Description of the issue — what went wrong and what was expected]',
|
||||
'',
|
||||
'**Evidence:** [What you observed in the video at the given timestamp]',
|
||||
'',
|
||||
'**Suggested Fix:** [Actionable recommendation]',
|
||||
'',
|
||||
'---',
|
||||
'',
|
||||
'The first line after the heading MUST be exactly three backtick-wrapped labels:',
|
||||
'`SEVERITY` `TIMESTAMP` `Confidence: LEVEL`',
|
||||
'Do NOT use a table for issues — use the block format above.',
|
||||
'## Possible Issues (Needs Human Verification)',
|
||||
'## Overall Risk',
|
||||
'',
|
||||
'## Verdict',
|
||||
'End your report with this EXACT JSON block (no markdown fence):',
|
||||
'{"verdict": "REPRODUCED" | "NOT_REPRODUCIBLE" | "INCONCLUSIVE", "risk": "low" | "medium" | "high" | null, "confidence": "high" | "medium" | "low"}',
|
||||
'- REPRODUCED: the bug/behavior is clearly visible in the video',
|
||||
'- NOT_REPRODUCIBLE: the steps were performed correctly but the bug was not observed',
|
||||
'- INCONCLUSIVE: the reproduction steps were not performed or the video is insufficient'
|
||||
)
|
||||
|
||||
return lines.filter(Boolean).join('\n')
|
||||
}
|
||||
|
||||
const MAX_VIDEO_BYTES = 100 * 1024 * 1024
|
||||
|
||||
async function readVideoFile(videoPath: string): Promise<Buffer> {
|
||||
const fileStat = await stat(videoPath)
|
||||
if (fileStat.size > MAX_VIDEO_BYTES) {
|
||||
throw new Error(
|
||||
`Video ${basename(videoPath)} is ${formatBytes(fileStat.size)}, exceeds ${formatBytes(MAX_VIDEO_BYTES)} limit`
|
||||
)
|
||||
}
|
||||
return readFile(videoPath)
|
||||
}
|
||||
|
||||
async function requestGeminiReview(options: {
|
||||
apiKey: string
|
||||
model: string
|
||||
platformName: string
|
||||
videoPath: string
|
||||
beforeVideoPath: string
|
||||
timeoutMs: number
|
||||
prContext: string
|
||||
}): Promise<string> {
|
||||
const genAI = new GoogleGenerativeAI(options.apiKey)
|
||||
const model = genAI.getGenerativeModel({ model: options.model })
|
||||
|
||||
const isComparative = options.beforeVideoPath.length > 0
|
||||
const prompt = buildReviewPrompt({
|
||||
platformName: options.platformName,
|
||||
videoPath: options.videoPath,
|
||||
prContext: options.prContext,
|
||||
isComparative
|
||||
})
|
||||
|
||||
const parts: Array<
|
||||
{ text: string } | { inlineData: { mimeType: string; data: string } }
|
||||
> = [{ text: prompt }]
|
||||
|
||||
if (isComparative) {
|
||||
const beforeBuffer = await readVideoFile(options.beforeVideoPath)
|
||||
parts.push(
|
||||
{ text: 'Video 1 — BEFORE (main branch):' },
|
||||
{
|
||||
inlineData: {
|
||||
mimeType: getMimeType(options.beforeVideoPath),
|
||||
data: beforeBuffer.toString('base64')
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const afterBuffer = await readVideoFile(options.videoPath)
|
||||
if (isComparative) {
|
||||
parts.push({ text: 'Video 2 — AFTER (PR branch):' })
|
||||
}
|
||||
parts.push({
|
||||
inlineData: {
|
||||
mimeType: getMimeType(options.videoPath),
|
||||
data: afterBuffer.toString('base64')
|
||||
}
|
||||
})
|
||||
|
||||
const result = await model.generateContent(parts, {
|
||||
timeout: options.timeoutMs
|
||||
})
|
||||
const response = result.response
|
||||
const text = response.text()
|
||||
|
||||
if (!text || text.trim().length === 0) {
|
||||
throw new Error('Gemini API returned no output text')
|
||||
}
|
||||
|
||||
return text.trim()
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
function buildReportMarkdown(input: {
|
||||
platformName: string
|
||||
model: string
|
||||
videoPath: string
|
||||
videoSizeBytes: number
|
||||
beforeVideoPath?: string
|
||||
beforeVideoSizeBytes?: number
|
||||
reviewText: string
|
||||
targetUrl?: string
|
||||
}): string {
|
||||
const headerLines = [
|
||||
`# ${input.platformName} QA Video Report`,
|
||||
'',
|
||||
`- Generated at: ${new Date().toISOString()}`,
|
||||
`- Model: \`${input.model}\``
|
||||
]
|
||||
|
||||
if (input.targetUrl) {
|
||||
headerLines.push(`- Target: ${input.targetUrl}`)
|
||||
}
|
||||
|
||||
if (input.beforeVideoPath) {
|
||||
headerLines.push(
|
||||
`- Before video: \`${toProjectRelativePath(input.beforeVideoPath)}\` (${formatBytes(input.beforeVideoSizeBytes ?? 0)})`,
|
||||
`- After video: \`${toProjectRelativePath(input.videoPath)}\` (${formatBytes(input.videoSizeBytes)})`,
|
||||
'- Mode: **Comparative (before/after)**'
|
||||
)
|
||||
} else {
|
||||
headerLines.push(
|
||||
`- Source video: \`${toProjectRelativePath(input.videoPath)}\``,
|
||||
`- Video size: ${formatBytes(input.videoSizeBytes)}`
|
||||
)
|
||||
}
|
||||
|
||||
headerLines.push('', '## AI Review', '')
|
||||
return `${headerLines.join('\n')}${input.reviewText.trim()}\n`
|
||||
}
|
||||
|
||||
async function reviewVideo(
|
||||
video: VideoCandidate,
|
||||
options: CliOptions,
|
||||
apiKey: string
|
||||
): Promise<void> {
|
||||
let prContext = ''
|
||||
if (options.prContext) {
|
||||
try {
|
||||
prContext = await readFile(options.prContext, 'utf-8')
|
||||
process.stdout.write(
|
||||
`[${video.platformName}] Loaded PR context from ${options.prContext}\n`
|
||||
)
|
||||
} catch {
|
||||
process.stdout.write(
|
||||
`[${video.platformName}] Warning: Could not read PR context file ${options.prContext}\n`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const beforeVideoPath = options.beforeVideo
|
||||
? resolve(options.beforeVideo)
|
||||
: ''
|
||||
|
||||
if (beforeVideoPath) {
|
||||
const beforeStat = await stat(beforeVideoPath)
|
||||
process.stdout.write(
|
||||
`[${video.platformName}] Before video: ${toProjectRelativePath(beforeVideoPath)} (${formatBytes(beforeStat.size)})\n`
|
||||
)
|
||||
}
|
||||
|
||||
process.stdout.write(
|
||||
`[${video.platformName}] Sending ${beforeVideoPath ? '2 videos (comparative)' : 'video'} to ${options.model}\n`
|
||||
)
|
||||
|
||||
const reviewText = await requestGeminiReview({
|
||||
apiKey,
|
||||
model: options.model,
|
||||
platformName: video.platformName,
|
||||
videoPath: video.videoPath,
|
||||
beforeVideoPath,
|
||||
timeoutMs: options.requestTimeoutMs,
|
||||
prContext
|
||||
})
|
||||
|
||||
const videoStat = await stat(video.videoPath)
|
||||
const passSegment = options.passLabel ? `-${options.passLabel}` : ''
|
||||
const outputPath = resolve(
|
||||
options.outputDir,
|
||||
`${video.platformName}${passSegment}-qa-video-report.md`
|
||||
)
|
||||
|
||||
const reportInput: Parameters<typeof buildReportMarkdown>[0] = {
|
||||
platformName: video.platformName,
|
||||
model: options.model,
|
||||
videoPath: video.videoPath,
|
||||
videoSizeBytes: videoStat.size,
|
||||
reviewText,
|
||||
targetUrl: options.targetUrl || undefined
|
||||
}
|
||||
|
||||
if (beforeVideoPath) {
|
||||
const beforeStat = await stat(beforeVideoPath)
|
||||
reportInput.beforeVideoPath = beforeVideoPath
|
||||
reportInput.beforeVideoSizeBytes = beforeStat.size
|
||||
}
|
||||
|
||||
const reportMarkdown = buildReportMarkdown(reportInput)
|
||||
|
||||
await mkdir(dirname(outputPath), { recursive: true })
|
||||
await writeFile(outputPath, reportMarkdown, 'utf-8')
|
||||
|
||||
process.stdout.write(
|
||||
`[${video.platformName}] Wrote ${toProjectRelativePath(outputPath)}\n`
|
||||
)
|
||||
}
|
||||
|
||||
function isExecutedAsScript(metaUrl: string): boolean {
|
||||
const modulePath = fileURLToPath(metaUrl)
|
||||
const scriptPath = process.argv[1] ? resolve(process.argv[1]) : ''
|
||||
return modulePath === scriptPath
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const options = parseCliOptions(process.argv.slice(2))
|
||||
const candidates = await collectVideoCandidates(options.artifactsDir)
|
||||
|
||||
if (candidates.length === 0) {
|
||||
process.stdout.write(
|
||||
`No qa-session.mp4 files found under ${toProjectRelativePath(resolve(options.artifactsDir))}\n`
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const selectedVideo = selectVideoCandidateByFile(candidates, {
|
||||
artifactsDir: options.artifactsDir,
|
||||
videoFile: options.videoFile
|
||||
})
|
||||
|
||||
process.stdout.write(
|
||||
`Selected ${selectedVideo.platformName}: ${toProjectRelativePath(selectedVideo.videoPath)}\n`
|
||||
)
|
||||
|
||||
if (options.dryRun) {
|
||||
process.stdout.write('\nDry run mode enabled, no API calls were made.\n')
|
||||
return
|
||||
}
|
||||
|
||||
const apiKey = process.env.GEMINI_API_KEY
|
||||
if (!apiKey) {
|
||||
throw new Error('GEMINI_API_KEY is required unless --dry-run is set')
|
||||
}
|
||||
|
||||
await reviewVideo(selectedVideo, options, apiKey)
|
||||
}
|
||||
|
||||
if (isExecutedAsScript(import.meta.url)) {
|
||||
void main().catch((error: unknown) => {
|
||||
const message = errorToString(error)
|
||||
process.stderr.write(`qa-video-review failed: ${message}\n`)
|
||||
process.exit(1)
|
||||
})
|
||||
}
|
||||
@@ -42,7 +42,6 @@ import type { StyleValue } from 'vue'
|
||||
|
||||
import { useIntersectionObserver } from '@/composables/useIntersectionObserver'
|
||||
import { useMediaCache } from '@/services/mediaCacheService'
|
||||
import type { ClassValue } from '@/utils/tailwindUtil'
|
||||
|
||||
const {
|
||||
src,
|
||||
@@ -54,8 +53,8 @@ const {
|
||||
} = defineProps<{
|
||||
src: string
|
||||
alt?: string
|
||||
containerClass?: ClassValue
|
||||
imageClass?: ClassValue
|
||||
containerClass?: string
|
||||
imageClass?: string
|
||||
imageStyle?: StyleValue
|
||||
rootMargin?: string
|
||||
}>()
|
||||
|
||||
@@ -147,7 +147,6 @@ import WorkflowTabs from '@/components/topbar/WorkflowTabs.vue'
|
||||
import { useChainCallback } from '@/composables/functional/useChainCallback'
|
||||
import { installErrorClearingHooks } from '@/composables/graph/useErrorClearingHooks'
|
||||
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
|
||||
import { useViewportCulling } from '@/composables/graph/useViewportCulling'
|
||||
import { useVueNodeLifecycle } from '@/composables/graph/useVueNodeLifecycle'
|
||||
import { useNodeBadge } from '@/composables/node/useNodeBadge'
|
||||
import { useCanvasDrop } from '@/composables/useCanvasDrop'
|
||||
@@ -168,7 +167,6 @@ import { useWorkflowPersistenceV2 as useWorkflowPersistence } from '@/platform/w
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
import { useTransformSettling } from '@/renderer/core/layout/transform/useTransformSettling'
|
||||
import TransformPane from '@/renderer/core/layout/transform/TransformPane.vue'
|
||||
import MiniMap from '@/renderer/extensions/minimap/MiniMap.vue'
|
||||
import LGraphNode from '@/renderer/extensions/vueNodes/components/LGraphNode.vue'
|
||||
@@ -284,7 +282,7 @@ watch(
|
||||
}
|
||||
)
|
||||
|
||||
const rawNodes = computed((): VueNodeData[] =>
|
||||
const allNodes = computed((): VueNodeData[] =>
|
||||
Array.from(vueNodeLifecycle.nodeManager.value?.vueNodeData?.values() ?? [])
|
||||
)
|
||||
watch(
|
||||
@@ -304,28 +302,6 @@ watch(
|
||||
}
|
||||
)
|
||||
|
||||
const canvasElement = computed(() => canvasStore.canvas?.canvas)
|
||||
const { isTransforming } = useTransformSettling(canvasElement, {
|
||||
settleDelay: 256
|
||||
})
|
||||
|
||||
const nodeLayouts = layoutStore.getAllNodes()
|
||||
|
||||
const { mountedNodeIds } = useViewportCulling({
|
||||
rawNodes,
|
||||
nodeLayouts,
|
||||
getViewportSize: () => {
|
||||
const rect = canvasStore.canvas?.canvas?.getBoundingClientRect()
|
||||
return { width: rect?.width ?? 0, height: rect?.height ?? 0 }
|
||||
},
|
||||
isTransforming,
|
||||
canvasElement
|
||||
})
|
||||
|
||||
const allNodes = computed(() =>
|
||||
rawNodes.value.filter((node) => mountedNodeIds.value.has(node.id))
|
||||
)
|
||||
|
||||
function onLinkOverlayReady(el: HTMLCanvasElement) {
|
||||
if (!canvasStore.canvas) return
|
||||
canvasStore.canvas.overlayCanvas = el
|
||||
|
||||
@@ -65,8 +65,7 @@ describe('DefaultThumbnail', () => {
|
||||
const classString = Array.isArray(imageClass)
|
||||
? imageClass.join(' ')
|
||||
: imageClass
|
||||
expect(classString).toContain('w-full')
|
||||
expect(classString).toContain('h-full')
|
||||
expect(classString).toContain('size-full')
|
||||
expect(classString).toContain('object-cover')
|
||||
})
|
||||
|
||||
|
||||
@@ -3,12 +3,14 @@
|
||||
<LazyImage
|
||||
:src="src"
|
||||
:alt="alt"
|
||||
:image-class="[
|
||||
'transform-gpu transition-transform duration-300 ease-out',
|
||||
isVideoType
|
||||
? 'w-full h-full object-cover'
|
||||
: 'max-w-full max-h-64 object-contain'
|
||||
]"
|
||||
:image-class="
|
||||
cn(
|
||||
'transform-gpu transition-transform duration-300 ease-out',
|
||||
isVideoType
|
||||
? 'size-full object-cover'
|
||||
: 'max-h-64 max-w-full object-contain'
|
||||
)
|
||||
"
|
||||
:image-style="
|
||||
isHovered ? { transform: `scale(${1 + hoverZoom / 100})` } : undefined
|
||||
"
|
||||
@@ -19,6 +21,7 @@
|
||||
<script setup lang="ts">
|
||||
import LazyImage from '@/components/common/LazyImage.vue'
|
||||
import BaseThumbnail from '@/components/templates/thumbnails/BaseThumbnail.vue'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { src, isVideo } = defineProps<{
|
||||
src: string
|
||||
|
||||
@@ -1,240 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
|
||||
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
|
||||
import type { NodeId, NodeLayout } from '@/renderer/core/layout/types'
|
||||
|
||||
const mockIsNodeInViewport = vi.fn()
|
||||
|
||||
vi.mock('@/renderer/core/layout/transform/useTransformState', () => ({
|
||||
useTransformState: () => ({
|
||||
isNodeInViewport: mockIsNodeInViewport,
|
||||
camera: { x: 0, y: 0, z: 1 }
|
||||
})
|
||||
}))
|
||||
|
||||
// Must import after mock setup
|
||||
const { useViewportCulling } =
|
||||
await import('@/composables/graph/useViewportCulling')
|
||||
|
||||
function makeNode(id: string): VueNodeData {
|
||||
return {
|
||||
id: id as NodeId,
|
||||
title: `Node ${id}`,
|
||||
type: 'test',
|
||||
mode: 0,
|
||||
executing: false,
|
||||
selected: false
|
||||
}
|
||||
}
|
||||
|
||||
let layoutCounter = 0
|
||||
|
||||
function makeLayout(x: number, y: number, w = 200, h = 100): NodeLayout {
|
||||
return {
|
||||
id: `layout-${layoutCounter++}`,
|
||||
position: { x, y },
|
||||
size: { width: w, height: h },
|
||||
bounds: { x, y, width: w, height: h },
|
||||
zIndex: 0,
|
||||
visible: true
|
||||
}
|
||||
}
|
||||
|
||||
describe('useViewportCulling', () => {
|
||||
const isTransforming = ref(false)
|
||||
const viewport = { width: 1000, height: 600 }
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.clearAllMocks()
|
||||
isTransforming.value = false
|
||||
mockIsNodeInViewport.mockReturnValue(true)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('mounts all nodes when all are visible', () => {
|
||||
const nodes = [makeNode('1'), makeNode('2'), makeNode('3')]
|
||||
const layouts = new Map<NodeId, NodeLayout>([
|
||||
['1', makeLayout(100, 100)],
|
||||
['2', makeLayout(300, 100)],
|
||||
['3', makeLayout(500, 100)]
|
||||
])
|
||||
|
||||
const { mountedNodeIds } = useViewportCulling({
|
||||
rawNodes: computed(() => nodes),
|
||||
nodeLayouts: computed(() => layouts),
|
||||
getViewportSize: () => viewport,
|
||||
isTransforming
|
||||
})
|
||||
|
||||
expect(mountedNodeIds.value.size).toBe(3)
|
||||
expect(mountedNodeIds.value.has('1')).toBe(true)
|
||||
expect(mountedNodeIds.value.has('2')).toBe(true)
|
||||
expect(mountedNodeIds.value.has('3')).toBe(true)
|
||||
})
|
||||
|
||||
it('culls nodes outside the viewport after debounce', async () => {
|
||||
const nodes = [makeNode('1'), makeNode('2')]
|
||||
const layouts = new Map<NodeId, NodeLayout>([
|
||||
['1', makeLayout(100, 100)],
|
||||
['2', makeLayout(5000, 5000)]
|
||||
])
|
||||
|
||||
mockIsNodeInViewport.mockImplementation(
|
||||
(pos: [number, number]) => pos[0] < 2000 && pos[1] < 2000
|
||||
)
|
||||
|
||||
const { mountedNodeIds } = useViewportCulling({
|
||||
rawNodes: computed(() => nodes),
|
||||
nodeLayouts: computed(() => layouts),
|
||||
getViewportSize: () => viewport,
|
||||
isTransforming
|
||||
})
|
||||
|
||||
// Node 2 is outside viewport, but hasn't been pruned yet on
|
||||
// initial mount since it was never in the set to begin with
|
||||
// and computeVisibleNodeIds runs immediately
|
||||
expect(mountedNodeIds.value.has('1')).toBe(true)
|
||||
expect(mountedNodeIds.value.has('2')).toBe(false)
|
||||
})
|
||||
|
||||
it('mounts nodes without layout data', () => {
|
||||
const nodes = [makeNode('1'), makeNode('2')]
|
||||
const layouts = new Map<NodeId, NodeLayout>([
|
||||
['1', makeLayout(100, 100)]
|
||||
// Node '2' has no layout
|
||||
])
|
||||
|
||||
mockIsNodeInViewport.mockReturnValue(true)
|
||||
|
||||
const { mountedNodeIds } = useViewportCulling({
|
||||
rawNodes: computed(() => nodes),
|
||||
nodeLayouts: computed(() => layouts),
|
||||
getViewportSize: () => viewport,
|
||||
isTransforming
|
||||
})
|
||||
|
||||
expect(mountedNodeIds.value.has('1')).toBe(true)
|
||||
expect(mountedNodeIds.value.has('2')).toBe(true)
|
||||
})
|
||||
|
||||
it('mounts all nodes when viewport size is zero', () => {
|
||||
const nodes = [makeNode('1'), makeNode('2')]
|
||||
const layouts = new Map<NodeId, NodeLayout>([
|
||||
['1', makeLayout(100, 100)],
|
||||
['2', makeLayout(5000, 5000)]
|
||||
])
|
||||
|
||||
mockIsNodeInViewport.mockReturnValue(false)
|
||||
|
||||
const { mountedNodeIds } = useViewportCulling({
|
||||
rawNodes: computed(() => nodes),
|
||||
nodeLayouts: computed(() => layouts),
|
||||
getViewportSize: () => ({ width: 0, height: 0 }),
|
||||
isTransforming
|
||||
})
|
||||
|
||||
expect(mountedNodeIds.value.size).toBe(2)
|
||||
})
|
||||
|
||||
it('delays unmounting nodes that leave the viewport', async () => {
|
||||
const nodes = [makeNode('1'), makeNode('2')]
|
||||
const layouts = new Map<NodeId, NodeLayout>([
|
||||
['1', makeLayout(100, 100)],
|
||||
['2', makeLayout(300, 100)]
|
||||
])
|
||||
|
||||
mockIsNodeInViewport.mockReturnValue(true)
|
||||
|
||||
const rawNodes = ref(nodes)
|
||||
const nodeLayouts = ref(layouts)
|
||||
|
||||
const { mountedNodeIds } = useViewportCulling({
|
||||
rawNodes: computed(() => rawNodes.value),
|
||||
nodeLayouts: computed(() => nodeLayouts.value),
|
||||
getViewportSize: () => viewport,
|
||||
isTransforming
|
||||
})
|
||||
|
||||
expect(mountedNodeIds.value.size).toBe(2)
|
||||
|
||||
// Node 2 leaves viewport
|
||||
mockIsNodeInViewport.mockImplementation(
|
||||
(pos: [number, number]) => pos[0] < 200
|
||||
)
|
||||
|
||||
// Trigger a refresh by updating layouts
|
||||
nodeLayouts.value = new Map(layouts)
|
||||
await nextTick()
|
||||
|
||||
// Node 2 should still be mounted (debounce hasn't fired)
|
||||
expect(mountedNodeIds.value.has('2')).toBe(true)
|
||||
|
||||
// After debounce delay, node 2 should be unmounted
|
||||
await vi.advanceTimersByTimeAsync(300)
|
||||
|
||||
expect(mountedNodeIds.value.has('1')).toBe(true)
|
||||
expect(mountedNodeIds.value.has('2')).toBe(false)
|
||||
})
|
||||
|
||||
it('immediately mounts nodes entering the viewport', async () => {
|
||||
const nodes = [makeNode('1'), makeNode('2')]
|
||||
const layouts = new Map<NodeId, NodeLayout>([
|
||||
['1', makeLayout(100, 100)],
|
||||
['2', makeLayout(5000, 5000)]
|
||||
])
|
||||
|
||||
mockIsNodeInViewport.mockImplementation(
|
||||
(pos: [number, number]) => pos[0] < 2000
|
||||
)
|
||||
|
||||
const rawNodes = ref(nodes)
|
||||
const nodeLayouts = ref(layouts)
|
||||
|
||||
const { mountedNodeIds } = useViewportCulling({
|
||||
rawNodes: computed(() => rawNodes.value),
|
||||
nodeLayouts: computed(() => nodeLayouts.value),
|
||||
getViewportSize: () => viewport,
|
||||
isTransforming
|
||||
})
|
||||
|
||||
expect(mountedNodeIds.value.has('2')).toBe(false)
|
||||
|
||||
// Node 2 enters viewport
|
||||
mockIsNodeInViewport.mockReturnValue(true)
|
||||
nodeLayouts.value = new Map(layouts)
|
||||
await nextTick()
|
||||
|
||||
// Should be immediately mounted without waiting for debounce
|
||||
expect(mountedNodeIds.value.has('2')).toBe(true)
|
||||
})
|
||||
|
||||
it('handles new nodes being added to the graph', async () => {
|
||||
const nodes = ref([makeNode('1')])
|
||||
const layouts = ref(
|
||||
new Map<NodeId, NodeLayout>([['1', makeLayout(100, 100)]])
|
||||
)
|
||||
|
||||
mockIsNodeInViewport.mockReturnValue(true)
|
||||
|
||||
const { mountedNodeIds } = useViewportCulling({
|
||||
rawNodes: computed(() => nodes.value),
|
||||
nodeLayouts: computed(() => layouts.value),
|
||||
getViewportSize: () => viewport,
|
||||
isTransforming
|
||||
})
|
||||
|
||||
expect(mountedNodeIds.value.size).toBe(1)
|
||||
|
||||
// Add a new node
|
||||
nodes.value = [...nodes.value, makeNode('2')]
|
||||
layouts.value = new Map([...layouts.value, ['2', makeLayout(200, 200)]])
|
||||
await nextTick()
|
||||
|
||||
expect(mountedNodeIds.value.has('2')).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -1,150 +0,0 @@
|
||||
/**
|
||||
* Viewport Culling for Vue Node Components
|
||||
*
|
||||
* Controls which nodes are mounted as Vue components based on viewport
|
||||
* visibility. Nodes entering the viewport are mounted immediately; nodes
|
||||
* leaving are unmounted after a debounce delay to avoid mount/unmount
|
||||
* churn when nodes oscillate at the viewport edge during panning.
|
||||
*
|
||||
* Visibility checks are throttled during active pan/zoom interactions
|
||||
* to avoid turning culling into a per-frame reactive hotspot.
|
||||
*/
|
||||
import {
|
||||
useDebounceFn,
|
||||
useEventListener,
|
||||
useResizeObserver,
|
||||
useThrottleFn
|
||||
} from '@vueuse/core'
|
||||
import { shallowRef, watch } from 'vue'
|
||||
import type { ComputedRef, Ref } from 'vue'
|
||||
|
||||
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
|
||||
import type { NodeId, NodeLayout } from '@/renderer/core/layout/types'
|
||||
import { useTransformState } from '@/renderer/core/layout/transform/useTransformState'
|
||||
|
||||
/** Viewport margin as a fraction of viewport dimensions (0.75 = 75% extra) */
|
||||
const VIEWPORT_MARGIN = 0.75
|
||||
|
||||
/** Delay before unmounting nodes that left the viewport */
|
||||
const HIDE_DELAY_MS = 250
|
||||
|
||||
/** Throttle interval for visibility recomputation during pan/zoom */
|
||||
const CULL_THROTTLE_MS = 96
|
||||
|
||||
interface UseViewportCullingOptions {
|
||||
rawNodes: ComputedRef<VueNodeData[]>
|
||||
nodeLayouts: ComputedRef<ReadonlyMap<NodeId, NodeLayout>>
|
||||
getViewportSize: () => { width: number; height: number }
|
||||
isTransforming: Ref<boolean>
|
||||
canvasElement?: Ref<HTMLElement | undefined | null>
|
||||
}
|
||||
|
||||
export function useViewportCulling({
|
||||
rawNodes,
|
||||
nodeLayouts,
|
||||
getViewportSize,
|
||||
isTransforming,
|
||||
canvasElement
|
||||
}: UseViewportCullingOptions) {
|
||||
const { isNodeInViewport } = useTransformState()
|
||||
const mountedNodeIds = shallowRef(new Set<string>())
|
||||
|
||||
function computeVisibleNodeIds(): Set<string> {
|
||||
const viewport = getViewportSize()
|
||||
const layouts = nodeLayouts.value
|
||||
const visible = new Set<string>()
|
||||
|
||||
if (!viewport.width || !viewport.height) {
|
||||
for (const node of rawNodes.value) visible.add(node.id)
|
||||
return visible
|
||||
}
|
||||
|
||||
for (const node of rawNodes.value) {
|
||||
const layout = layouts.get(node.id)
|
||||
|
||||
if (!layout) {
|
||||
visible.add(node.id)
|
||||
continue
|
||||
}
|
||||
|
||||
if (
|
||||
isNodeInViewport(
|
||||
[layout.position.x, layout.position.y],
|
||||
[layout.size.width, layout.size.height],
|
||||
viewport,
|
||||
VIEWPORT_MARGIN
|
||||
)
|
||||
) {
|
||||
visible.add(node.id)
|
||||
}
|
||||
}
|
||||
|
||||
return visible
|
||||
}
|
||||
|
||||
const pruneMountedNodes = useDebounceFn(() => {
|
||||
mountedNodeIds.value = computeVisibleNodeIds()
|
||||
}, HIDE_DELAY_MS)
|
||||
|
||||
function refreshMountedNodes() {
|
||||
const visibleNow = computeVisibleNodeIds()
|
||||
const current = mountedNodeIds.value
|
||||
|
||||
let hasNewNodes = false
|
||||
let needsPrune = false
|
||||
let next = current
|
||||
|
||||
for (const id of visibleNow) {
|
||||
if (!current.has(id)) {
|
||||
if (next === current) next = new Set(current)
|
||||
next.add(id)
|
||||
hasNewNodes = true
|
||||
}
|
||||
}
|
||||
|
||||
for (const id of current) {
|
||||
if (!visibleNow.has(id)) {
|
||||
needsPrune = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (hasNewNodes) {
|
||||
mountedNodeIds.value = next
|
||||
}
|
||||
|
||||
if (needsPrune) {
|
||||
void pruneMountedNodes()
|
||||
}
|
||||
}
|
||||
|
||||
const refreshThrottled = useThrottleFn(refreshMountedNodes, CULL_THROTTLE_MS)
|
||||
|
||||
watch([rawNodes, nodeLayouts], refreshMountedNodes, { immediate: true })
|
||||
|
||||
const { camera } = useTransformState()
|
||||
watch(
|
||||
() => [camera.x, camera.y, camera.z] as const,
|
||||
() => {
|
||||
if (isTransforming.value) {
|
||||
void refreshThrottled()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
watch(isTransforming, (moving) => {
|
||||
if (!moving) {
|
||||
refreshMountedNodes()
|
||||
}
|
||||
})
|
||||
|
||||
useEventListener(window, 'resize', refreshMountedNodes)
|
||||
|
||||
if (canvasElement) {
|
||||
useResizeObserver(canvasElement, refreshMountedNodes)
|
||||
}
|
||||
|
||||
return {
|
||||
mountedNodeIds
|
||||
}
|
||||
}
|
||||
@@ -3167,6 +3167,7 @@
|
||||
},
|
||||
"comfyHubPublish": {
|
||||
"title": "Publish to ComfyHub",
|
||||
"unsavedDescription": "You must save your workflow before publishing to ComfyHub. Save it now to continue.",
|
||||
"stepDescribe": "Describe your workflow",
|
||||
"stepExamples": "Add output examples",
|
||||
"stepFinish": "Finish publishing",
|
||||
@@ -3174,12 +3175,6 @@
|
||||
"workflowNamePlaceholder": "Tip: enter a descriptive name that's easy to search",
|
||||
"workflowDescription": "Workflow description",
|
||||
"workflowDescriptionPlaceholder": "What makes your workflow exciting and special? Be specific so people know what to expect.",
|
||||
"workflowType": "Workflow type",
|
||||
"workflowTypePlaceholder": "Select the type",
|
||||
"workflowTypeImageGeneration": "Image generation",
|
||||
"workflowTypeVideoGeneration": "Video generation",
|
||||
"workflowTypeUpscaling": "Upscaling",
|
||||
"workflowTypeEditing": "Editing",
|
||||
"tags": "Tags",
|
||||
"tagsDescription": "Select tags so people can find your workflow faster",
|
||||
"tagsPlaceholder": "Enter tags that match your workflow to help people find it e.g #nanobanana, #anime or #faceswap",
|
||||
@@ -3206,11 +3201,17 @@
|
||||
"examplesDescription": "Add up to {total} additional sample images",
|
||||
"uploadAnImage": "Click to browse or drag an image",
|
||||
"uploadExampleImage": "Upload example image",
|
||||
"removeExampleImage": "Remove example image",
|
||||
"exampleImage": "Example image {index}",
|
||||
"exampleImagePosition": "Example image {index} of {total}",
|
||||
"videoPreview": "Video thumbnail preview",
|
||||
"maxExamples": "You can select up to {max} examples",
|
||||
"shareAs": "Share as",
|
||||
"additionalInfo": "Additional information",
|
||||
"createProfileToPublish": "Create a profile to publish to ComfyHub",
|
||||
"createProfileCta": "Create a profile"
|
||||
"createProfileCta": "Create a profile",
|
||||
"publishFailedTitle": "Publish failed",
|
||||
"publishFailedDescription": "Something went wrong while publishing your workflow. Please try again."
|
||||
},
|
||||
"comfyHubProfile": {
|
||||
"checkingAccess": "Checking your publishing access...",
|
||||
@@ -3229,6 +3230,7 @@
|
||||
"namePlaceholder": "Enter your name here",
|
||||
"usernameLabel": "Your username (required)",
|
||||
"usernamePlaceholder": "@",
|
||||
"usernameError": "3–42 lowercase alphanumeric characters and hyphens, must start and end with a letter or number",
|
||||
"descriptionLabel": "Your description",
|
||||
"descriptionPlaceholder": "Tell the community about yourself...",
|
||||
"createProfile": "Create profile",
|
||||
|
||||
@@ -133,9 +133,45 @@
|
||||
role="tabpanel"
|
||||
aria-labelledby="tab-publish"
|
||||
data-testid="publish-tab-panel"
|
||||
class="min-h-0"
|
||||
class="flex min-h-0 flex-col gap-4"
|
||||
>
|
||||
<template v-if="dialogState === 'loading'">
|
||||
<Skeleton class="h-3 w-4/5" />
|
||||
<Skeleton class="h-3 w-3/5" />
|
||||
<Skeleton class="h-10 w-full" />
|
||||
</template>
|
||||
|
||||
<template v-else-if="dialogState === 'unsaved'">
|
||||
<p class="m-0 text-sm text-muted-foreground">
|
||||
{{ $t('comfyHubPublish.unsavedDescription') }}
|
||||
</p>
|
||||
<label v-if="isTemporary" class="flex flex-col gap-1">
|
||||
<span class="text-sm font-medium text-muted-foreground">
|
||||
{{ $t('shareWorkflow.workflowNameLabel') }}
|
||||
</span>
|
||||
<Input
|
||||
ref="publishNameInputRef"
|
||||
v-model="workflowName"
|
||||
:disabled="isSaving"
|
||||
@keydown.enter="() => handleSave()"
|
||||
/>
|
||||
</label>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="lg"
|
||||
:loading="isSaving"
|
||||
@click="() => handleSave()"
|
||||
>
|
||||
{{
|
||||
isSaving
|
||||
? $t('shareWorkflow.saving')
|
||||
: $t('shareWorkflow.saveButton')
|
||||
}}
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
<ComfyHubPublishIntroPanel
|
||||
v-else
|
||||
data-testid="publish-intro"
|
||||
:on-create-profile="handleOpenPublishDialog"
|
||||
:on-close="onClose"
|
||||
@@ -215,10 +251,15 @@ const dialogMode = ref<DialogMode>('shareLink')
|
||||
const acknowledged = ref(false)
|
||||
const workflowName = ref('')
|
||||
const nameInputRef = ref<InstanceType<typeof Input> | null>(null)
|
||||
const publishNameInputRef = ref<InstanceType<typeof Input> | null>(null)
|
||||
|
||||
function focusNameInput() {
|
||||
nameInputRef.value?.focus()
|
||||
nameInputRef.value?.select()
|
||||
function focusActiveNameInput() {
|
||||
const input =
|
||||
dialogMode.value === 'publishToHub'
|
||||
? publishNameInputRef.value
|
||||
: nameInputRef.value
|
||||
input?.focus()
|
||||
input?.select()
|
||||
}
|
||||
|
||||
const isTemporary = computed(
|
||||
@@ -228,7 +269,7 @@ const isTemporary = computed(
|
||||
watch(dialogState, async (state) => {
|
||||
if (state === 'unsaved' && isTemporary.value) {
|
||||
await nextTick()
|
||||
focusNameInput()
|
||||
focusActiveNameInput()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -255,10 +296,14 @@ function tabButtonClass(mode: DialogMode) {
|
||||
)
|
||||
}
|
||||
|
||||
function handleDialogModeChange(nextMode: DialogMode) {
|
||||
async function handleDialogModeChange(nextMode: DialogMode) {
|
||||
if (nextMode === dialogMode.value) return
|
||||
if (nextMode === 'publishToHub' && !showPublishToHubTab.value) return
|
||||
dialogMode.value = nextMode
|
||||
if (dialogState.value === 'unsaved' && isTemporary.value) {
|
||||
await nextTick()
|
||||
focusActiveNameInput()
|
||||
}
|
||||
}
|
||||
|
||||
watch(showPublishToHubTab, (isVisible) => {
|
||||
|
||||
@@ -75,8 +75,23 @@
|
||||
>
|
||||
@
|
||||
</span>
|
||||
<Input id="profile-username" v-model="username" class="pl-7" />
|
||||
<Input
|
||||
id="profile-username"
|
||||
v-model="username"
|
||||
class="pl-7"
|
||||
:aria-invalid="showUsernameError ? 'true' : 'false'"
|
||||
:aria-describedby="
|
||||
showUsernameError ? 'profile-username-error' : undefined
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<p
|
||||
v-if="showUsernameError"
|
||||
id="profile-username-error"
|
||||
class="text-xs text-destructive-background"
|
||||
>
|
||||
{{ $t('comfyHubProfile.usernameError') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
@@ -105,7 +120,7 @@
|
||||
<Button
|
||||
variant="primary"
|
||||
size="lg"
|
||||
:disabled="!username.trim() || isCreating"
|
||||
:disabled="!isUsernameValid || isCreating"
|
||||
@click="handleCreate"
|
||||
>
|
||||
{{
|
||||
@@ -156,6 +171,16 @@ const profilePictureFile = ref<File | null>(null)
|
||||
const profilePreviewUrl = useObjectUrl(profilePictureFile)
|
||||
const isCreating = ref(false)
|
||||
|
||||
const VALID_USERNAME_PATTERN = /^[a-z0-9][a-z0-9-]{1,40}[a-z0-9]$/
|
||||
|
||||
const isUsernameValid = computed(() =>
|
||||
VALID_USERNAME_PATTERN.test(username.value)
|
||||
)
|
||||
|
||||
const showUsernameError = computed(
|
||||
() => username.value.length > 0 && !isUsernameValid.value
|
||||
)
|
||||
|
||||
const profileInitial = computed(() => {
|
||||
const source = name.value.trim() || username.value.trim()
|
||||
return source ? source[0].toUpperCase() : 'C'
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { COMFY_HUB_TAG_OPTIONS } from '@/platform/workflow/sharing/constants/comfyHubTags'
|
||||
|
||||
import ComfyHubDescribeStep from './ComfyHubDescribeStep.vue'
|
||||
|
||||
function mountStep(
|
||||
props: Partial<InstanceType<typeof ComfyHubDescribeStep>['$props']> = {}
|
||||
) {
|
||||
return mount(ComfyHubDescribeStep, {
|
||||
props: {
|
||||
name: 'Workflow Name',
|
||||
description: 'Workflow description',
|
||||
tags: [],
|
||||
...props
|
||||
},
|
||||
global: {
|
||||
mocks: {
|
||||
$t: (key: string) => key
|
||||
},
|
||||
stubs: {
|
||||
Input: {
|
||||
template:
|
||||
'<input data-testid="name-input" :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)" />',
|
||||
props: ['modelValue']
|
||||
},
|
||||
Textarea: {
|
||||
template:
|
||||
'<textarea data-testid="description-input" :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)" />',
|
||||
props: ['modelValue']
|
||||
},
|
||||
TagsInput: {
|
||||
template:
|
||||
'<div data-testid="tags-input" :data-disabled="disabled ? \'true\' : \'false\'"><slot :is-empty="!modelValue || modelValue.length === 0" /></div>',
|
||||
props: {
|
||||
modelValue: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
disabled: Boolean
|
||||
}
|
||||
},
|
||||
TagsInputItem: {
|
||||
template:
|
||||
'<button data-testid="tag-item" :data-value="value" type="button"><slot /></button>',
|
||||
props: ['value']
|
||||
},
|
||||
TagsInputItemText: {
|
||||
template: '<span data-testid="tag-item-text" />'
|
||||
},
|
||||
TagsInputItemDelete: {
|
||||
template: '<button data-testid="tag-item-delete" type="button" />'
|
||||
},
|
||||
TagsInputInput: {
|
||||
template: '<input data-testid="tags-input-input" />'
|
||||
},
|
||||
Button: {
|
||||
template:
|
||||
'<button data-testid="toggle-suggestions" type="button"><slot /></button>'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('ComfyHubDescribeStep', () => {
|
||||
it('emits name and description updates', async () => {
|
||||
const wrapper = mountStep()
|
||||
|
||||
await wrapper.find('[data-testid="name-input"]').setValue('New workflow')
|
||||
await wrapper
|
||||
.find('[data-testid="description-input"]')
|
||||
.setValue('New description')
|
||||
|
||||
expect(wrapper.emitted('update:name')).toEqual([['New workflow']])
|
||||
expect(wrapper.emitted('update:description')).toEqual([['New description']])
|
||||
})
|
||||
|
||||
it('adds a suggested tag when clicked', async () => {
|
||||
const wrapper = mountStep()
|
||||
const suggestionButtons = wrapper.findAll(
|
||||
'[data-testid="tags-input"][data-disabled="true"] [data-testid="tag-item"]'
|
||||
)
|
||||
|
||||
expect(suggestionButtons.length).toBeGreaterThan(0)
|
||||
|
||||
const firstSuggestion = suggestionButtons[0].attributes('data-value')
|
||||
await suggestionButtons[0].trigger('click')
|
||||
|
||||
const tagUpdates = wrapper.emitted('update:tags')
|
||||
expect(tagUpdates?.at(-1)).toEqual([[firstSuggestion]])
|
||||
})
|
||||
|
||||
it('hides already-selected tags from suggestions', () => {
|
||||
const selectedTag = COMFY_HUB_TAG_OPTIONS[0]
|
||||
const wrapper = mountStep({ tags: [selectedTag] })
|
||||
const suggestionValues = wrapper
|
||||
.findAll(
|
||||
'[data-testid="tags-input"][data-disabled="true"] [data-testid="tag-item"]'
|
||||
)
|
||||
.map((button) => button.attributes('data-value'))
|
||||
|
||||
expect(suggestionValues).not.toContain(selectedTag)
|
||||
})
|
||||
|
||||
it('toggles between default and full suggestion lists', async () => {
|
||||
const wrapper = mountStep()
|
||||
|
||||
const defaultSuggestions = wrapper.findAll(
|
||||
'[data-testid="tags-input"][data-disabled="true"] [data-testid="tag-item"]'
|
||||
)
|
||||
expect(defaultSuggestions).toHaveLength(10)
|
||||
expect(wrapper.text()).toContain('comfyHubPublish.showMoreTags')
|
||||
|
||||
await wrapper.find('[data-testid="toggle-suggestions"]').trigger('click')
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
const allSuggestions = wrapper.findAll(
|
||||
'[data-testid="tags-input"][data-disabled="true"] [data-testid="tag-item"]'
|
||||
)
|
||||
expect(allSuggestions).toHaveLength(COMFY_HUB_TAG_OPTIONS.length)
|
||||
expect(wrapper.text()).toContain('comfyHubPublish.showLessTags')
|
||||
})
|
||||
})
|
||||
@@ -25,35 +25,8 @@
|
||||
|
||||
<label class="flex flex-col gap-2">
|
||||
<span class="text-sm text-base-foreground">
|
||||
{{ $t('comfyHubPublish.workflowType') }}
|
||||
</span>
|
||||
<Select
|
||||
:model-value="workflowType"
|
||||
@update:model-value="
|
||||
emit('update:workflowType', $event as ComfyHubWorkflowType)
|
||||
"
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
:placeholder="$t('comfyHubPublish.workflowTypePlaceholder')"
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
v-for="option in workflowTypeOptions"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
>
|
||||
{{ option.label }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</label>
|
||||
|
||||
<fieldset class="flex flex-col gap-2">
|
||||
<legend class="text-sm text-base-foreground">
|
||||
{{ $t('comfyHubPublish.tagsDescription') }}
|
||||
</legend>
|
||||
</span>
|
||||
<TagsInput
|
||||
v-slot="{ isEmpty }"
|
||||
always-editing
|
||||
@@ -67,54 +40,48 @@
|
||||
</TagsInputItem>
|
||||
<TagsInputInput :is-empty />
|
||||
</TagsInput>
|
||||
|
||||
<TagsInput
|
||||
disabled
|
||||
class="hover-within:bg-transparent bg-transparent p-0 hover:bg-transparent"
|
||||
</label>
|
||||
<TagsInput
|
||||
disabled
|
||||
class="hover-within:bg-transparent bg-transparent p-0 hover:bg-transparent"
|
||||
>
|
||||
<div
|
||||
v-if="displayedSuggestions.length > 0"
|
||||
class="flex basis-full flex-wrap gap-2"
|
||||
>
|
||||
<div
|
||||
v-if="displayedSuggestions.length > 0"
|
||||
class="flex basis-full flex-wrap gap-2"
|
||||
<TagsInputItem
|
||||
v-for="tag in displayedSuggestions"
|
||||
:key="tag"
|
||||
v-auto-animate
|
||||
:value="tag"
|
||||
class="cursor-pointer bg-secondary-background px-2 text-muted-foreground transition-colors select-none hover:bg-secondary-background-selected"
|
||||
@click="addTag(tag)"
|
||||
>
|
||||
<TagsInputItem
|
||||
v-for="tag in displayedSuggestions"
|
||||
:key="tag"
|
||||
v-auto-animate
|
||||
:value="tag"
|
||||
class="cursor-pointer bg-secondary-background px-2 text-muted-foreground transition-colors select-none hover:bg-secondary-background-selected"
|
||||
@click="addTag(tag)"
|
||||
>
|
||||
<TagsInputItemText />
|
||||
</TagsInputItem>
|
||||
</div>
|
||||
<Button
|
||||
v-if="shouldShowSuggestionToggle"
|
||||
variant="muted-textonly"
|
||||
size="unset"
|
||||
class="hover:bg-unset px-0 text-xs"
|
||||
@click="showAllSuggestions = !showAllSuggestions"
|
||||
>
|
||||
{{
|
||||
$t(
|
||||
showAllSuggestions
|
||||
? 'comfyHubPublish.showLessTags'
|
||||
: 'comfyHubPublish.showMoreTags'
|
||||
)
|
||||
}}
|
||||
</Button>
|
||||
</TagsInput>
|
||||
</fieldset>
|
||||
<TagsInputItemText />
|
||||
</TagsInputItem>
|
||||
</div>
|
||||
<Button
|
||||
v-if="shouldShowSuggestionToggle"
|
||||
variant="muted-textonly"
|
||||
size="unset"
|
||||
class="hover:bg-unset px-0 text-xs"
|
||||
@click="showAllSuggestions = !showAllSuggestions"
|
||||
>
|
||||
{{
|
||||
$t(
|
||||
showAllSuggestions
|
||||
? 'comfyHubPublish.showLessTags'
|
||||
: 'comfyHubPublish.showMoreTags'
|
||||
)
|
||||
}}
|
||||
</Button>
|
||||
</TagsInput>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import Input from '@/components/ui/input/Input.vue'
|
||||
import Select from '@/components/ui/select/Select.vue'
|
||||
import SelectContent from '@/components/ui/select/SelectContent.vue'
|
||||
import SelectItem from '@/components/ui/select/SelectItem.vue'
|
||||
import SelectTrigger from '@/components/ui/select/SelectTrigger.vue'
|
||||
import SelectValue from '@/components/ui/select/SelectValue.vue'
|
||||
import TagsInput from '@/components/ui/tags-input/TagsInput.vue'
|
||||
import TagsInputInput from '@/components/ui/tags-input/TagsInputInput.vue'
|
||||
import TagsInputItem from '@/components/ui/tags-input/TagsInputItem.vue'
|
||||
@@ -122,46 +89,21 @@ import TagsInputItemDelete from '@/components/ui/tags-input/TagsInputItemDelete.
|
||||
import TagsInputItemText from '@/components/ui/tags-input/TagsInputItemText.vue'
|
||||
import Textarea from '@/components/ui/textarea/Textarea.vue'
|
||||
import { COMFY_HUB_TAG_OPTIONS } from '@/platform/workflow/sharing/constants/comfyHubTags'
|
||||
import type { ComfyHubWorkflowType } from '@/platform/workflow/sharing/types/comfyHubTypes'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { vAutoAnimate } from '@formkit/auto-animate/vue'
|
||||
|
||||
const { tags, workflowType } = defineProps<{
|
||||
const { tags } = defineProps<{
|
||||
name: string
|
||||
description: string
|
||||
workflowType: ComfyHubWorkflowType | ''
|
||||
tags: string[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:name': [value: string]
|
||||
'update:description': [value: string]
|
||||
'update:workflowType': [value: ComfyHubWorkflowType | '']
|
||||
'update:tags': [value: string[]]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const workflowTypeOptions = computed(() => [
|
||||
{
|
||||
value: 'imageGeneration',
|
||||
label: t('comfyHubPublish.workflowTypeImageGeneration')
|
||||
},
|
||||
{
|
||||
value: 'videoGeneration',
|
||||
label: t('comfyHubPublish.workflowTypeVideoGeneration')
|
||||
},
|
||||
{
|
||||
value: 'upscaling',
|
||||
label: t('comfyHubPublish.workflowTypeUpscaling')
|
||||
},
|
||||
{
|
||||
value: 'editing',
|
||||
label: t('comfyHubPublish.workflowTypeEditing')
|
||||
}
|
||||
])
|
||||
|
||||
const INITIAL_TAG_SUGGESTION_COUNT = 10
|
||||
|
||||
const showAllSuggestions = ref(false)
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { ExampleImage } from '@/platform/workflow/sharing/types/comfyHubTypes'
|
||||
|
||||
import ComfyHubExamplesStep from './ComfyHubExamplesStep.vue'
|
||||
|
||||
vi.mock('@atlaskit/pragmatic-drag-and-drop/element/adapter', () => ({
|
||||
draggable: vi.fn(() => vi.fn()),
|
||||
dropTargetForElements: vi.fn(() => vi.fn()),
|
||||
monitorForElements: vi.fn(() => vi.fn())
|
||||
}))
|
||||
|
||||
function createImages(count: number): ExampleImage[] {
|
||||
return Array.from({ length: count }, (_, i) => ({
|
||||
id: `img-${i}`,
|
||||
url: `blob:http://localhost/img-${i}`
|
||||
}))
|
||||
}
|
||||
|
||||
function mountStep(images: ExampleImage[]) {
|
||||
return mount(ComfyHubExamplesStep, {
|
||||
props: { exampleImages: images },
|
||||
global: {
|
||||
mocks: { $t: (key: string) => key }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('ComfyHubExamplesStep', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('renders all example images', () => {
|
||||
const wrapper = mountStep(createImages(3))
|
||||
expect(wrapper.findAll('[role="listitem"]')).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('emits reordered array when moving image left via keyboard', async () => {
|
||||
const wrapper = mountStep(createImages(3))
|
||||
|
||||
const tiles = wrapper.findAll('[role="listitem"]')
|
||||
await tiles[1].trigger('keydown', { key: 'ArrowLeft', shiftKey: true })
|
||||
|
||||
const emitted = wrapper.emitted('update:exampleImages')
|
||||
expect(emitted).toBeTruthy()
|
||||
const reordered = emitted![0][0] as ExampleImage[]
|
||||
expect(reordered.map((img) => img.id)).toEqual(['img-1', 'img-0', 'img-2'])
|
||||
})
|
||||
|
||||
it('emits reordered array when moving image right via keyboard', async () => {
|
||||
const wrapper = mountStep(createImages(3))
|
||||
|
||||
const tiles = wrapper.findAll('[role="listitem"]')
|
||||
await tiles[1].trigger('keydown', { key: 'ArrowRight', shiftKey: true })
|
||||
|
||||
const emitted = wrapper.emitted('update:exampleImages')
|
||||
expect(emitted).toBeTruthy()
|
||||
const reordered = emitted![0][0] as ExampleImage[]
|
||||
expect(reordered.map((img) => img.id)).toEqual(['img-0', 'img-2', 'img-1'])
|
||||
})
|
||||
|
||||
it('does not emit when moving first image left (boundary)', async () => {
|
||||
const wrapper = mountStep(createImages(3))
|
||||
|
||||
const tiles = wrapper.findAll('[role="listitem"]')
|
||||
await tiles[0].trigger('keydown', { key: 'ArrowLeft', shiftKey: true })
|
||||
|
||||
expect(wrapper.emitted('update:exampleImages')).toBeFalsy()
|
||||
})
|
||||
|
||||
it('does not emit when moving last image right (boundary)', async () => {
|
||||
const wrapper = mountStep(createImages(3))
|
||||
|
||||
const tiles = wrapper.findAll('[role="listitem"]')
|
||||
await tiles[2].trigger('keydown', { key: 'ArrowRight', shiftKey: true })
|
||||
|
||||
expect(wrapper.emitted('update:exampleImages')).toBeFalsy()
|
||||
})
|
||||
|
||||
it('emits filtered array when removing an image', async () => {
|
||||
const wrapper = mountStep(createImages(2))
|
||||
|
||||
const removeBtn = wrapper.find(
|
||||
'button[aria-label="comfyHubPublish.removeExampleImage"]'
|
||||
)
|
||||
expect(removeBtn.exists()).toBe(true)
|
||||
await removeBtn.trigger('click')
|
||||
|
||||
const emitted = wrapper.emitted('update:exampleImages')
|
||||
expect(emitted).toBeTruthy()
|
||||
expect(emitted![0][0]).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -1,21 +1,25 @@
|
||||
<template>
|
||||
<div class="flex min-h-0 flex-1 flex-col gap-6">
|
||||
<p class="text-sm">
|
||||
<div class="flex min-h-0 flex-1 flex-col">
|
||||
<p class="text-sm select-none">
|
||||
{{
|
||||
$t('comfyHubPublish.examplesDescription', {
|
||||
selected: selectedExampleIds.length,
|
||||
total: MAX_EXAMPLES
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
|
||||
<div class="grid grid-cols-4 gap-2.5 overflow-y-auto">
|
||||
<!-- Upload tile -->
|
||||
<div
|
||||
role="list"
|
||||
class="group/grid grid gap-2"
|
||||
style="grid-template-columns: repeat(auto-fill, 8rem)"
|
||||
>
|
||||
<!-- Upload tile (hidden when max images reached) -->
|
||||
<label
|
||||
v-if="showUploadTile"
|
||||
tabindex="0"
|
||||
role="button"
|
||||
:aria-label="$t('comfyHubPublish.uploadExampleImage')"
|
||||
class="focus-visible:outline-ring flex aspect-square h-25 cursor-pointer flex-col items-center justify-center gap-2 rounded-lg border border-dashed border-border-default text-center transition-colors hover:border-muted-foreground focus-visible:outline-2 focus-visible:outline-offset-2"
|
||||
class="focus-visible:outline-ring flex aspect-square cursor-pointer flex-col items-center justify-center gap-2 rounded-lg border border-dashed border-border-default text-center transition-colors hover:border-muted-foreground focus-visible:outline-2 focus-visible:outline-offset-2"
|
||||
@dragenter.stop
|
||||
@dragleave.stop
|
||||
@dragover.prevent.stop
|
||||
@@ -40,83 +44,100 @@
|
||||
}}</span>
|
||||
</label>
|
||||
|
||||
<!-- Example images -->
|
||||
<Button
|
||||
<!-- Example images (drag to reorder) -->
|
||||
<ReorderableExampleImage
|
||||
v-for="(image, index) in exampleImages"
|
||||
:key="image.id"
|
||||
variant="textonly"
|
||||
size="unset"
|
||||
:class="
|
||||
cn(
|
||||
'relative h-25 cursor-pointer overflow-hidden rounded-sm p-0',
|
||||
isSelected(image.id) ? 'ring-ring ring-2' : 'ring-0'
|
||||
)
|
||||
"
|
||||
@click="toggleSelection(image.id)"
|
||||
>
|
||||
<img
|
||||
:src="image.url"
|
||||
:alt="$t('comfyHubPublish.exampleImage', { index: index + 1 })"
|
||||
class="size-full object-cover"
|
||||
/>
|
||||
<div
|
||||
v-if="isSelected(image.id)"
|
||||
class="absolute bottom-1.5 left-1.5 flex size-7 items-center justify-center rounded-full bg-primary-background text-sm font-bold text-base-foreground"
|
||||
>
|
||||
{{ selectionIndex(image.id) }}
|
||||
</div>
|
||||
</Button>
|
||||
:image="image"
|
||||
:index="index"
|
||||
:total="exampleImages.length"
|
||||
:instance-id="instanceId"
|
||||
@remove="removeImage"
|
||||
@move="moveImage"
|
||||
@insert-files="insertImagesAt"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { monitorForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { ref } from 'vue'
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import type { ExampleImage } from '@/platform/workflow/sharing/types/comfyHubTypes'
|
||||
import {
|
||||
isFileTooLarge,
|
||||
MAX_IMAGE_SIZE_MB
|
||||
} from '@/platform/workflow/sharing/utils/validateFileSize'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import ReorderableExampleImage from './ReorderableExampleImage.vue'
|
||||
|
||||
const fileInputRef = ref<HTMLInputElement | null>(null)
|
||||
|
||||
const MAX_EXAMPLES = 8
|
||||
|
||||
const { exampleImages, selectedExampleIds } = defineProps<{
|
||||
exampleImages: ExampleImage[]
|
||||
selectedExampleIds: string[]
|
||||
}>()
|
||||
const exampleImages = defineModel<ExampleImage[]>('exampleImages', {
|
||||
required: true
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:exampleImages': [value: ExampleImage[]]
|
||||
'update:selectedExampleIds': [value: string[]]
|
||||
}>()
|
||||
const showUploadTile = computed(() => exampleImages.value.length < MAX_EXAMPLES)
|
||||
|
||||
function isSelected(id: string): boolean {
|
||||
return selectedExampleIds.includes(id)
|
||||
const instanceId = Symbol('example-images')
|
||||
|
||||
let cleanupMonitor = () => {}
|
||||
|
||||
onMounted(() => {
|
||||
cleanupMonitor = monitorForElements({
|
||||
canMonitor: ({ source }) => source.data.instanceId === instanceId,
|
||||
onDrop: ({ source, location }) => {
|
||||
const destination = location.current.dropTargets[0]
|
||||
if (!destination) return
|
||||
|
||||
const fromId = source.data.imageId
|
||||
const toId = destination.data.imageId
|
||||
if (typeof fromId !== 'string' || typeof toId !== 'string') return
|
||||
|
||||
reorderImages(fromId, toId)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
cleanupMonitor()
|
||||
})
|
||||
|
||||
function moveByIndex(fromIndex: number, toIndex: number) {
|
||||
if (fromIndex < 0 || toIndex < 0) return
|
||||
if (toIndex >= exampleImages.value.length || fromIndex === toIndex) return
|
||||
|
||||
const updated = [...exampleImages.value]
|
||||
const [moved] = updated.splice(fromIndex, 1)
|
||||
updated.splice(toIndex, 0, moved)
|
||||
exampleImages.value = updated
|
||||
}
|
||||
|
||||
function selectionIndex(id: string): number {
|
||||
return selectedExampleIds.indexOf(id) + 1
|
||||
function reorderImages(fromId: string, toId: string) {
|
||||
moveByIndex(
|
||||
exampleImages.value.findIndex((img) => img.id === fromId),
|
||||
exampleImages.value.findIndex((img) => img.id === toId)
|
||||
)
|
||||
}
|
||||
|
||||
function toggleSelection(id: string) {
|
||||
if (isSelected(id)) {
|
||||
emit(
|
||||
'update:selectedExampleIds',
|
||||
selectedExampleIds.filter((sid) => sid !== id)
|
||||
)
|
||||
} else if (selectedExampleIds.length < MAX_EXAMPLES) {
|
||||
emit('update:selectedExampleIds', [...selectedExampleIds, id])
|
||||
function moveImage(id: string, direction: number) {
|
||||
const currentIndex = exampleImages.value.findIndex((img) => img.id === id)
|
||||
moveByIndex(currentIndex, currentIndex + direction)
|
||||
}
|
||||
|
||||
function removeImage(id: string) {
|
||||
const image = exampleImages.value.find((img) => img.id === id)
|
||||
if (image?.file) {
|
||||
URL.revokeObjectURL(image.url)
|
||||
}
|
||||
exampleImages.value = exampleImages.value.filter((img) => img.id !== id)
|
||||
}
|
||||
|
||||
function addImages(files: FileList) {
|
||||
const newImages: ExampleImage[] = Array.from(files)
|
||||
function createExampleImages(files: FileList): ExampleImage[] {
|
||||
return Array.from(files)
|
||||
.filter((f) => f.type.startsWith('image/'))
|
||||
.filter((f) => !isFileTooLarge(f, MAX_IMAGE_SIZE_MB))
|
||||
.map((file) => ({
|
||||
@@ -124,10 +145,51 @@ function addImages(files: FileList) {
|
||||
url: URL.createObjectURL(file),
|
||||
file
|
||||
}))
|
||||
}
|
||||
|
||||
if (newImages.length > 0) {
|
||||
emit('update:exampleImages', [...exampleImages, ...newImages])
|
||||
function addImages(files: FileList) {
|
||||
const remaining = MAX_EXAMPLES - exampleImages.value.length
|
||||
if (remaining <= 0) return
|
||||
|
||||
const created = createExampleImages(files)
|
||||
const newImages = created.slice(0, remaining)
|
||||
for (const img of created.slice(remaining)) {
|
||||
URL.revokeObjectURL(img.url)
|
||||
}
|
||||
if (newImages.length > 0) {
|
||||
exampleImages.value = [...newImages, ...exampleImages.value]
|
||||
}
|
||||
}
|
||||
|
||||
function insertImagesAt(index: number, files: FileList) {
|
||||
const created = createExampleImages(files)
|
||||
if (created.length === 0) return
|
||||
|
||||
const updated = [...exampleImages.value]
|
||||
const safeIndex = Math.min(Math.max(index, 0), updated.length)
|
||||
const remaining = MAX_EXAMPLES - exampleImages.value.length
|
||||
const maxInsert =
|
||||
remaining <= 0 ? Math.max(updated.length - safeIndex, 0) : remaining
|
||||
const newImages = created.slice(0, maxInsert)
|
||||
for (const img of created.slice(maxInsert)) {
|
||||
URL.revokeObjectURL(img.url)
|
||||
}
|
||||
|
||||
if (newImages.length === 0) return
|
||||
if (remaining <= 0) {
|
||||
const replacedImages = updated.splice(
|
||||
safeIndex,
|
||||
newImages.length,
|
||||
...newImages
|
||||
)
|
||||
for (const img of replacedImages) {
|
||||
if (img.file) URL.revokeObjectURL(img.url)
|
||||
}
|
||||
} else {
|
||||
updated.splice(safeIndex, 0, ...newImages)
|
||||
}
|
||||
|
||||
exampleImages.value = updated
|
||||
}
|
||||
|
||||
function handleFileSelect(event: Event) {
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
<template>
|
||||
<div class="flex min-h-0 flex-1 flex-col gap-8 px-6 py-4">
|
||||
<section class="flex flex-col gap-4">
|
||||
<span class="text-sm text-base-foreground">
|
||||
{{ $t('comfyHubPublish.shareAs') }}
|
||||
</span>
|
||||
|
||||
<div
|
||||
class="flex items-center gap-4 rounded-2xl bg-secondary-background px-6 py-4"
|
||||
>
|
||||
<div
|
||||
class="flex size-12 shrink-0 items-center justify-center overflow-hidden rounded-full bg-linear-to-b from-green-600/50 to-green-900"
|
||||
>
|
||||
<img
|
||||
v-if="profile.profilePictureUrl"
|
||||
:src="profile.profilePictureUrl"
|
||||
:alt="profile.username"
|
||||
class="size-full rounded-full object-cover"
|
||||
/>
|
||||
<span v-else class="text-base text-white">
|
||||
{{ (profile.name ?? profile.username).charAt(0).toUpperCase() }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex flex-1 flex-col gap-2">
|
||||
<span class="text-sm text-base-foreground">
|
||||
{{ profile.name ?? profile.username }}
|
||||
</span>
|
||||
<span class="text-sm text-muted-foreground">
|
||||
@{{ profile.username }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
v-if="isLoadingAssets || hasPrivateAssets"
|
||||
class="flex flex-col gap-4"
|
||||
>
|
||||
<span class="text-sm text-base-foreground">
|
||||
{{ $t('comfyHubPublish.additionalInfo') }}
|
||||
</span>
|
||||
|
||||
<p
|
||||
v-if="isLoadingAssets"
|
||||
class="m-0 text-sm text-muted-foreground italic"
|
||||
>
|
||||
{{ $t('shareWorkflow.checkingAssets') }}
|
||||
</p>
|
||||
<ShareAssetWarningBox
|
||||
v-else
|
||||
v-model:acknowledged="acknowledged"
|
||||
:items="privateAssets"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useAsyncState } from '@vueuse/core'
|
||||
import { computed, watch } from 'vue'
|
||||
|
||||
import type { ComfyHubProfile } from '@/schemas/apiSchema'
|
||||
import ShareAssetWarningBox from '@/platform/workflow/sharing/components/ShareAssetWarningBox.vue'
|
||||
import { useWorkflowShareService } from '@/platform/workflow/sharing/services/workflowShareService'
|
||||
|
||||
const { profile } = defineProps<{
|
||||
profile: ComfyHubProfile
|
||||
}>()
|
||||
|
||||
const acknowledged = defineModel<boolean>('acknowledged', { default: false })
|
||||
const ready = defineModel<boolean>('ready', { default: false })
|
||||
|
||||
const shareService = useWorkflowShareService()
|
||||
|
||||
const {
|
||||
state: privateAssets,
|
||||
isLoading: isLoadingAssets,
|
||||
error: privateAssetsError
|
||||
} = useAsyncState(() => shareService.getShareableAssets(), [])
|
||||
|
||||
const hasPrivateAssets = computed(() => privateAssets.value.length > 0)
|
||||
const isReady = computed(
|
||||
() =>
|
||||
!isLoadingAssets.value &&
|
||||
!privateAssetsError.value &&
|
||||
(!hasPrivateAssets.value || acknowledged.value)
|
||||
)
|
||||
|
||||
watch(
|
||||
isReady,
|
||||
(val) => {
|
||||
ready.value = val
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
</script>
|
||||
@@ -2,6 +2,18 @@ import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
|
||||
vi.mock('vue-i18n', async (importOriginal) => {
|
||||
const actual = await importOriginal()
|
||||
return {
|
||||
...(actual as Record<string, unknown>),
|
||||
useI18n: () => ({ t: (key: string) => key })
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('primevue/usetoast', () => ({
|
||||
useToast: () => ({ add: vi.fn() })
|
||||
}))
|
||||
|
||||
import ComfyHubPublishDialog from '@/platform/workflow/sharing/components/publish/ComfyHubPublishDialog.vue'
|
||||
|
||||
const mockFetchProfile = vi.hoisted(() => vi.fn())
|
||||
@@ -10,6 +22,11 @@ const mockGoNext = vi.hoisted(() => vi.fn())
|
||||
const mockGoBack = vi.hoisted(() => vi.fn())
|
||||
const mockOpenProfileCreationStep = vi.hoisted(() => vi.fn())
|
||||
const mockCloseProfileCreationStep = vi.hoisted(() => vi.fn())
|
||||
const mockApplyPrefill = vi.hoisted(() => vi.fn())
|
||||
const mockCachePublishPrefill = vi.hoisted(() => vi.fn())
|
||||
const mockGetCachedPrefill = vi.hoisted(() => vi.fn())
|
||||
const mockSubmitToComfyHub = vi.hoisted(() => vi.fn())
|
||||
const mockGetPublishStatus = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock(
|
||||
'@/platform/workflow/sharing/composables/useComfyHubProfileGate',
|
||||
@@ -28,14 +45,16 @@ vi.mock(
|
||||
formData: ref({
|
||||
name: '',
|
||||
description: '',
|
||||
workflowType: '',
|
||||
tags: [],
|
||||
models: [],
|
||||
customNodes: [],
|
||||
thumbnailType: 'image',
|
||||
thumbnailFile: null,
|
||||
comparisonBeforeFile: null,
|
||||
comparisonAfterFile: null,
|
||||
exampleImages: [],
|
||||
selectedExampleIds: []
|
||||
tutorialUrl: '',
|
||||
metadata: {}
|
||||
}),
|
||||
isFirstStep: ref(false),
|
||||
isLastStep: ref(true),
|
||||
@@ -43,17 +62,64 @@ vi.mock(
|
||||
goNext: mockGoNext,
|
||||
goBack: mockGoBack,
|
||||
openProfileCreationStep: mockOpenProfileCreationStep,
|
||||
closeProfileCreationStep: mockCloseProfileCreationStep
|
||||
closeProfileCreationStep: mockCloseProfileCreationStep,
|
||||
applyPrefill: mockApplyPrefill
|
||||
}),
|
||||
cachePublishPrefill: mockCachePublishPrefill,
|
||||
getCachedPrefill: mockGetCachedPrefill
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock(
|
||||
'@/platform/workflow/sharing/composables/useComfyHubPublishSubmission',
|
||||
() => ({
|
||||
useComfyHubPublishSubmission: () => ({
|
||||
submitToComfyHub: mockSubmitToComfyHub
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock('@/platform/workflow/sharing/services/workflowShareService', () => ({
|
||||
useWorkflowShareService: () => ({
|
||||
getPublishStatus: mockGetPublishStatus
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workflow/core/services/workflowService', () => ({
|
||||
useWorkflowService: () => ({
|
||||
renameWorkflow: vi.fn(),
|
||||
saveWorkflow: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
|
||||
useWorkflowStore: () => ({
|
||||
activeWorkflow: {
|
||||
path: 'workflows/test.json',
|
||||
filename: 'test.json',
|
||||
directory: 'workflows',
|
||||
isTemporary: false,
|
||||
isModified: false
|
||||
},
|
||||
saveWorkflow: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
describe('ComfyHubPublishDialog', () => {
|
||||
const onClose = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockFetchProfile.mockResolvedValue(null)
|
||||
mockSubmitToComfyHub.mockResolvedValue(undefined)
|
||||
mockGetCachedPrefill.mockReturnValue(null)
|
||||
mockGetPublishStatus.mockResolvedValue({
|
||||
isPublished: false,
|
||||
shareId: null,
|
||||
shareUrl: null,
|
||||
publishedAt: null,
|
||||
prefill: null
|
||||
})
|
||||
})
|
||||
|
||||
function createWrapper() {
|
||||
@@ -78,14 +144,16 @@ describe('ComfyHubPublishDialog', () => {
|
||||
},
|
||||
ComfyHubPublishWizardContent: {
|
||||
template:
|
||||
'<div><button data-testid="require-profile" @click="$props.onRequireProfile()" /><button data-testid="gate-complete" @click="$props.onGateComplete()" /><button data-testid="gate-close" @click="$props.onGateClose()" /></div>',
|
||||
'<div :data-is-publishing="$props.isPublishing"><button data-testid="require-profile" @click="$props.onRequireProfile()" /><button data-testid="gate-complete" @click="$props.onGateComplete()" /><button data-testid="gate-close" @click="$props.onGateClose()" /><button data-testid="publish" @click="$props.onPublish()" /></div>',
|
||||
props: [
|
||||
'currentStep',
|
||||
'formData',
|
||||
'isFirstStep',
|
||||
'isLastStep',
|
||||
'isPublishing',
|
||||
'onGoNext',
|
||||
'onGoBack',
|
||||
'onPublish',
|
||||
'onRequireProfile',
|
||||
'onGateComplete',
|
||||
'onGateClose'
|
||||
@@ -136,4 +204,72 @@ describe('ComfyHubPublishDialog', () => {
|
||||
expect(mockCloseProfileCreationStep).toHaveBeenCalledOnce()
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('closes dialog after successful publish', async () => {
|
||||
const wrapper = createWrapper()
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.find('[data-testid="publish"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(mockSubmitToComfyHub).toHaveBeenCalledOnce()
|
||||
expect(onClose).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('applies prefill when workflow is already published with metadata', async () => {
|
||||
mockGetPublishStatus.mockResolvedValue({
|
||||
isPublished: true,
|
||||
shareId: 'abc123',
|
||||
shareUrl: 'http://localhost/?share=abc123',
|
||||
publishedAt: new Date(),
|
||||
prefill: {
|
||||
description: 'Existing description',
|
||||
tags: ['art', 'upscale'],
|
||||
thumbnailType: 'video',
|
||||
sampleImageUrls: ['https://example.com/img1.png']
|
||||
}
|
||||
})
|
||||
|
||||
createWrapper()
|
||||
await flushPromises()
|
||||
|
||||
expect(mockApplyPrefill).toHaveBeenCalledWith({
|
||||
description: 'Existing description',
|
||||
tags: ['art', 'upscale'],
|
||||
thumbnailType: 'video',
|
||||
sampleImageUrls: ['https://example.com/img1.png']
|
||||
})
|
||||
})
|
||||
|
||||
it('does not apply prefill when workflow is not published', async () => {
|
||||
createWrapper()
|
||||
await flushPromises()
|
||||
|
||||
expect(mockApplyPrefill).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not apply prefill when status has no prefill data', async () => {
|
||||
mockGetPublishStatus.mockResolvedValue({
|
||||
isPublished: true,
|
||||
shareId: 'abc123',
|
||||
shareUrl: 'http://localhost/?share=abc123',
|
||||
publishedAt: new Date(),
|
||||
prefill: null
|
||||
})
|
||||
|
||||
createWrapper()
|
||||
await flushPromises()
|
||||
|
||||
expect(mockApplyPrefill).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('silently ignores prefill fetch errors', async () => {
|
||||
mockGetPublishStatus.mockRejectedValue(new Error('Network error'))
|
||||
|
||||
createWrapper()
|
||||
await flushPromises()
|
||||
|
||||
expect(mockApplyPrefill).not.toHaveBeenCalled()
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -12,44 +12,106 @@
|
||||
</template>
|
||||
|
||||
<template #leftPanel>
|
||||
<ComfyHubPublishNav :current-step @step-click="goToStep" />
|
||||
<ComfyHubPublishNav
|
||||
v-if="!needsSave"
|
||||
:current-step
|
||||
@step-click="goToStep"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #header />
|
||||
<template #content>
|
||||
<div v-if="needsSave" class="flex flex-col gap-4 p-6">
|
||||
<p class="m-0 text-sm text-muted-foreground">
|
||||
{{ $t('comfyHubPublish.unsavedDescription') }}
|
||||
</p>
|
||||
<label v-if="isTemporary" class="flex flex-col gap-1">
|
||||
<span class="text-sm font-medium text-muted-foreground">
|
||||
{{ $t('shareWorkflow.workflowNameLabel') }}
|
||||
</span>
|
||||
<Input
|
||||
ref="nameInputRef"
|
||||
v-model="workflowName"
|
||||
:disabled="isSaving"
|
||||
@keydown.enter="() => handleSave()"
|
||||
/>
|
||||
</label>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="lg"
|
||||
:loading="isSaving"
|
||||
@click="() => handleSave()"
|
||||
>
|
||||
{{
|
||||
isSaving
|
||||
? $t('shareWorkflow.saving')
|
||||
: $t('shareWorkflow.saveButton')
|
||||
}}
|
||||
</Button>
|
||||
</div>
|
||||
<ComfyHubPublishWizardContent
|
||||
v-else
|
||||
:current-step
|
||||
:form-data
|
||||
:is-first-step
|
||||
:is-last-step
|
||||
:is-publishing
|
||||
:on-update-form-data="updateFormData"
|
||||
:on-go-next="goNext"
|
||||
:on-go-back="goBack"
|
||||
:on-require-profile="handleRequireProfile"
|
||||
:on-gate-complete="handlePublishGateComplete"
|
||||
:on-gate-close="handlePublishGateClose"
|
||||
:on-publish="onClose"
|
||||
:on-publish="handlePublish"
|
||||
/>
|
||||
</template>
|
||||
</BaseModalLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, onMounted, provide } from 'vue'
|
||||
import { useAsyncState } from '@vueuse/core'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import {
|
||||
computed,
|
||||
nextTick,
|
||||
onBeforeUnmount,
|
||||
onMounted,
|
||||
provide,
|
||||
ref,
|
||||
watch
|
||||
} from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import BaseModalLayout from '@/components/widget/layout/BaseModalLayout.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import Input from '@/components/ui/input/Input.vue'
|
||||
import ComfyHubPublishNav from '@/platform/workflow/sharing/components/publish/ComfyHubPublishNav.vue'
|
||||
import ComfyHubPublishWizardContent from '@/platform/workflow/sharing/components/publish/ComfyHubPublishWizardContent.vue'
|
||||
import { useComfyHubPublishWizard } from '@/platform/workflow/sharing/composables/useComfyHubPublishWizard'
|
||||
import { useComfyHubPublishSubmission } from '@/platform/workflow/sharing/composables/useComfyHubPublishSubmission'
|
||||
import {
|
||||
cachePublishPrefill,
|
||||
getCachedPrefill,
|
||||
useComfyHubPublishWizard
|
||||
} from '@/platform/workflow/sharing/composables/useComfyHubPublishWizard'
|
||||
import { useComfyHubProfileGate } from '@/platform/workflow/sharing/composables/useComfyHubProfileGate'
|
||||
import { useWorkflowShareService } from '@/platform/workflow/sharing/services/workflowShareService'
|
||||
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import type { ComfyHubPublishFormData } from '@/platform/workflow/sharing/types/comfyHubTypes'
|
||||
import { appendJsonExt } from '@/utils/formatUtil'
|
||||
import { OnCloseKey } from '@/types/widgetTypes'
|
||||
|
||||
const { onClose } = defineProps<{
|
||||
onClose: () => void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const toast = useToast()
|
||||
const { fetchProfile } = useComfyHubProfileGate()
|
||||
const { submitToComfyHub } = useComfyHubPublishSubmission()
|
||||
const shareService = useWorkflowShareService()
|
||||
const workflowService = useWorkflowService()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const {
|
||||
currentStep,
|
||||
formData,
|
||||
@@ -59,8 +121,72 @@ const {
|
||||
goNext,
|
||||
goBack,
|
||||
openProfileCreationStep,
|
||||
closeProfileCreationStep
|
||||
closeProfileCreationStep,
|
||||
applyPrefill
|
||||
} = useComfyHubPublishWizard()
|
||||
const isPublishing = ref(false)
|
||||
const needsSave = ref(false)
|
||||
const workflowName = ref('')
|
||||
const nameInputRef = ref<InstanceType<typeof Input> | null>(null)
|
||||
|
||||
const isTemporary = computed(
|
||||
() => workflowStore.activeWorkflow?.isTemporary ?? false
|
||||
)
|
||||
|
||||
function checkNeedsSave() {
|
||||
const workflow = workflowStore.activeWorkflow
|
||||
needsSave.value = !workflow || workflow.isTemporary || workflow.isModified
|
||||
if (workflow) {
|
||||
workflowName.value = workflow.filename.replace(/\.json$/i, '')
|
||||
}
|
||||
}
|
||||
|
||||
watch(needsSave, async (needs) => {
|
||||
if (needs && isTemporary.value) {
|
||||
await nextTick()
|
||||
nameInputRef.value?.focus()
|
||||
nameInputRef.value?.select()
|
||||
}
|
||||
})
|
||||
|
||||
function buildWorkflowPath(directory: string, filename: string): string {
|
||||
const normalizedDirectory = directory.replace(/\/+$/, '')
|
||||
const normalizedFilename = appendJsonExt(filename.replace(/\.json$/i, ''))
|
||||
return normalizedDirectory
|
||||
? `${normalizedDirectory}/${normalizedFilename}`
|
||||
: normalizedFilename
|
||||
}
|
||||
|
||||
const { isLoading: isSaving, execute: handleSave } = useAsyncState(
|
||||
async () => {
|
||||
const workflow = workflowStore.activeWorkflow
|
||||
if (!workflow) return
|
||||
|
||||
if (workflow.isTemporary) {
|
||||
const name = workflowName.value.trim()
|
||||
if (!name) return
|
||||
const newPath = buildWorkflowPath(workflow.directory, name)
|
||||
await workflowService.renameWorkflow(workflow, newPath)
|
||||
await workflowStore.saveWorkflow(workflow)
|
||||
} else {
|
||||
await workflowService.saveWorkflow(workflow)
|
||||
}
|
||||
|
||||
checkNeedsSave()
|
||||
},
|
||||
undefined,
|
||||
{
|
||||
immediate: false,
|
||||
onError: (error) => {
|
||||
console.error('Failed to save workflow:', error)
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('shareWorkflow.saveFailedTitle'),
|
||||
detail: t('shareWorkflow.saveFailedDescription')
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
function handlePublishGateComplete() {
|
||||
closeProfileCreationStep()
|
||||
@@ -75,18 +201,67 @@ function handleRequireProfile() {
|
||||
openProfileCreationStep()
|
||||
}
|
||||
|
||||
async function handlePublish(): Promise<void> {
|
||||
if (isPublishing.value) {
|
||||
return
|
||||
}
|
||||
|
||||
isPublishing.value = true
|
||||
try {
|
||||
await submitToComfyHub(formData.value)
|
||||
const path = workflowStore.activeWorkflow?.path
|
||||
if (path) {
|
||||
cachePublishPrefill(path, formData.value)
|
||||
}
|
||||
onClose()
|
||||
} catch (error) {
|
||||
console.error('Failed to publish workflow:', error)
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('comfyHubPublish.publishFailedTitle'),
|
||||
detail: t('comfyHubPublish.publishFailedDescription')
|
||||
})
|
||||
} finally {
|
||||
isPublishing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function updateFormData(patch: Partial<ComfyHubPublishFormData>) {
|
||||
formData.value = { ...formData.value, ...patch }
|
||||
}
|
||||
|
||||
async function fetchPublishPrefill() {
|
||||
const path = workflowStore.activeWorkflow?.path
|
||||
if (!path) return
|
||||
|
||||
try {
|
||||
const status = await shareService.getPublishStatus(path)
|
||||
const prefill = status.isPublished
|
||||
? (status.prefill ?? getCachedPrefill(path))
|
||||
: getCachedPrefill(path)
|
||||
if (prefill) {
|
||||
applyPrefill(prefill)
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to fetch publish prefill:', error)
|
||||
const cached = getCachedPrefill(path)
|
||||
if (cached) {
|
||||
applyPrefill(cached)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// Prefetch profile data in the background so finish-step profile context is ready.
|
||||
checkNeedsSave()
|
||||
void fetchProfile()
|
||||
void fetchPublishPrefill()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
for (const image of formData.value.exampleImages) {
|
||||
URL.revokeObjectURL(image.url)
|
||||
if (image.file) {
|
||||
URL.revokeObjectURL(image.url)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -1,26 +1,30 @@
|
||||
<template>
|
||||
<footer class="flex shrink items-center justify-between py-2">
|
||||
<div>
|
||||
<Button v-if="!isFirstStep" size="lg" @click="$emit('back')">
|
||||
{{ $t('comfyHubPublish.back') }}
|
||||
</Button>
|
||||
</div>
|
||||
<div class="flex gap-4">
|
||||
<Button v-if="!isLastStep" size="lg" @click="$emit('next')">
|
||||
{{ $t('comfyHubPublish.next') }}
|
||||
<i class="icon-[lucide--chevron-right] size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
v-else
|
||||
variant="primary"
|
||||
size="lg"
|
||||
:disabled="isPublishDisabled"
|
||||
@click="$emit('publish')"
|
||||
>
|
||||
<i class="icon-[lucide--upload] size-4" />
|
||||
{{ $t('comfyHubPublish.publishButton') }}
|
||||
</Button>
|
||||
</div>
|
||||
<footer
|
||||
class="flex shrink items-center justify-end gap-4 border-t border-border-default px-6 py-4"
|
||||
>
|
||||
<Button v-if="!isFirstStep" size="lg" @click="$emit('back')">
|
||||
{{ $t('comfyHubPublish.back') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="!isLastStep"
|
||||
variant="primary"
|
||||
size="lg"
|
||||
@click="$emit('next')"
|
||||
>
|
||||
{{ $t('comfyHubPublish.next') }}
|
||||
<i class="icon-[lucide--chevron-right] size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
v-else
|
||||
variant="primary"
|
||||
size="lg"
|
||||
:disabled="isPublishDisabled || isPublishing"
|
||||
:loading="isPublishing"
|
||||
@click="$emit('publish')"
|
||||
>
|
||||
<i class="icon-[lucide--upload] size-4" />
|
||||
{{ $t('comfyHubPublish.publishButton') }}
|
||||
</Button>
|
||||
</footer>
|
||||
</template>
|
||||
|
||||
@@ -31,6 +35,7 @@ defineProps<{
|
||||
isFirstStep: boolean
|
||||
isLastStep: boolean
|
||||
isPublishDisabled?: boolean
|
||||
isPublishing?: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<nav class="flex flex-col gap-6 px-3 py-4">
|
||||
<ol class="flex flex-col">
|
||||
<ol class="flex list-none flex-col p-0">
|
||||
<li
|
||||
v-for="step in steps"
|
||||
:key="step.name"
|
||||
|
||||
@@ -8,13 +8,20 @@ import type { ComfyHubPublishFormData } from '@/platform/workflow/sharing/types/
|
||||
const mockCheckProfile = vi.hoisted(() => vi.fn())
|
||||
const mockToastErrorHandler = vi.hoisted(() => vi.fn())
|
||||
const mockHasProfile = ref<boolean | null>(true)
|
||||
const mockIsFetchingProfile = ref(false)
|
||||
const mockProfile = ref<{ username: string; name?: string } | null>({
|
||||
username: 'testuser',
|
||||
name: 'Test User'
|
||||
})
|
||||
|
||||
vi.mock(
|
||||
'@/platform/workflow/sharing/composables/useComfyHubProfileGate',
|
||||
() => ({
|
||||
useComfyHubProfileGate: () => ({
|
||||
checkProfile: mockCheckProfile,
|
||||
hasProfile: mockHasProfile
|
||||
hasProfile: mockHasProfile,
|
||||
isFetchingProfile: mockIsFetchingProfile,
|
||||
profile: mockProfile
|
||||
})
|
||||
})
|
||||
)
|
||||
@@ -39,14 +46,16 @@ function createDefaultFormData(): ComfyHubPublishFormData {
|
||||
return {
|
||||
name: 'Test Workflow',
|
||||
description: '',
|
||||
workflowType: '',
|
||||
tags: [],
|
||||
models: [],
|
||||
customNodes: [],
|
||||
thumbnailType: 'image',
|
||||
thumbnailFile: null,
|
||||
comparisonBeforeFile: null,
|
||||
comparisonAfterFile: null,
|
||||
exampleImages: [],
|
||||
selectedExampleIds: []
|
||||
tutorialUrl: '',
|
||||
metadata: {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,8 +70,11 @@ describe('ComfyHubPublishWizardContent', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
onPublish.mockResolvedValue(undefined)
|
||||
mockCheckProfile.mockResolvedValue(true)
|
||||
mockHasProfile.value = true
|
||||
mockIsFetchingProfile.value = false
|
||||
mockProfile.value = { username: 'testuser', name: 'Test User' }
|
||||
mockFlags.comfyHubProfileGateEnabled = true
|
||||
})
|
||||
|
||||
@@ -99,9 +111,23 @@ describe('ComfyHubPublishWizardContent', () => {
|
||||
template: '<div data-testid="publish-gate-flow" />',
|
||||
props: ['onProfileCreated', 'onClose', 'showCloseButton']
|
||||
},
|
||||
Skeleton: {
|
||||
template: '<div class="skeleton" />'
|
||||
},
|
||||
ComfyHubDescribeStep: {
|
||||
template: '<div data-testid="describe-step" />'
|
||||
},
|
||||
ComfyHubFinishStep: {
|
||||
template: '<div data-testid="finish-step" />',
|
||||
props: ['profile', 'acknowledged', 'ready'],
|
||||
emits: ['update:ready', 'update:acknowledged'],
|
||||
setup(
|
||||
_: unknown,
|
||||
{ emit }: { emit: (e: string, v: boolean) => void }
|
||||
) {
|
||||
emit('update:ready', true)
|
||||
}
|
||||
},
|
||||
ComfyHubExamplesStep: {
|
||||
template: '<div data-testid="examples-step" />'
|
||||
},
|
||||
@@ -115,8 +141,13 @@ describe('ComfyHubPublishWizardContent', () => {
|
||||
},
|
||||
ComfyHubPublishFooter: {
|
||||
template:
|
||||
'<div data-testid="publish-footer" :data-publish-disabled="isPublishDisabled"><button data-testid="publish-btn" @click="$emit(\'publish\')" /><button data-testid="next-btn" @click="$emit(\'next\')" /><button data-testid="back-btn" @click="$emit(\'back\')" /></div>',
|
||||
props: ['isFirstStep', 'isLastStep', 'isPublishDisabled'],
|
||||
'<div data-testid="publish-footer" :data-publish-disabled="isPublishDisabled" :data-is-publishing="isPublishing"><button data-testid="publish-btn" @click="$emit(\'publish\')" /><button data-testid="next-btn" @click="$emit(\'next\')" /><button data-testid="back-btn" @click="$emit(\'back\')" /></div>',
|
||||
props: [
|
||||
'isFirstStep',
|
||||
'isLastStep',
|
||||
'isPublishDisabled',
|
||||
'isPublishing'
|
||||
],
|
||||
emits: ['publish', 'next', 'back']
|
||||
}
|
||||
}
|
||||
@@ -124,43 +155,19 @@ describe('ComfyHubPublishWizardContent', () => {
|
||||
})
|
||||
}
|
||||
|
||||
describe('handlePublish — double-click guard', () => {
|
||||
it('prevents concurrent publish calls', async () => {
|
||||
let resolveCheck!: (v: boolean) => void
|
||||
mockCheckProfile.mockReturnValue(
|
||||
new Promise<boolean>((resolve) => {
|
||||
resolveCheck = resolve
|
||||
})
|
||||
)
|
||||
function createDeferred<T>() {
|
||||
let resolve: (value: T) => void = () => {}
|
||||
let reject: (error: unknown) => void = () => {}
|
||||
|
||||
const wrapper = createWrapper()
|
||||
|
||||
const publishBtn = wrapper.find('[data-testid="publish-btn"]')
|
||||
await publishBtn.trigger('click')
|
||||
await publishBtn.trigger('click')
|
||||
|
||||
resolveCheck(true)
|
||||
await flushPromises()
|
||||
|
||||
expect(mockCheckProfile).toHaveBeenCalledTimes(1)
|
||||
expect(onPublish).toHaveBeenCalledTimes(1)
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res
|
||||
reject = rej
|
||||
})
|
||||
})
|
||||
|
||||
describe('handlePublish — feature flag bypass', () => {
|
||||
it('calls onPublish directly when profile gate is disabled', async () => {
|
||||
mockFlags.comfyHubProfileGateEnabled = false
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
const wrapper = createWrapper()
|
||||
await wrapper.find('[data-testid="publish-btn"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(mockCheckProfile).not.toHaveBeenCalled()
|
||||
expect(onPublish).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
describe('handlePublish — profile check routing', () => {
|
||||
describe('handlePublish - profile check routing', () => {
|
||||
it('calls onPublish when profile exists', async () => {
|
||||
mockCheckProfile.mockResolvedValue(true)
|
||||
|
||||
@@ -197,20 +204,83 @@ describe('ComfyHubPublishWizardContent', () => {
|
||||
expect(onRequireProfile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('resets guard after checkProfile error so retry is possible', async () => {
|
||||
mockCheckProfile.mockRejectedValueOnce(new Error('Network error'))
|
||||
it('calls onPublish directly when profile gate is disabled', async () => {
|
||||
mockFlags.comfyHubProfileGateEnabled = false
|
||||
|
||||
const wrapper = createWrapper()
|
||||
await wrapper.find('[data-testid="publish-btn"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(mockCheckProfile).not.toHaveBeenCalled()
|
||||
expect(onPublish).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
describe('handlePublish - async submission', () => {
|
||||
it('prevents duplicate publish submissions while in-flight', async () => {
|
||||
const publishDeferred = createDeferred<void>()
|
||||
onPublish.mockReturnValue(publishDeferred.promise)
|
||||
|
||||
const wrapper = createWrapper()
|
||||
const publishBtn = wrapper.find('[data-testid="publish-btn"]')
|
||||
|
||||
await publishBtn.trigger('click')
|
||||
await publishBtn.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(onPublish).toHaveBeenCalledTimes(1)
|
||||
|
||||
publishDeferred.resolve(undefined)
|
||||
await flushPromises()
|
||||
})
|
||||
|
||||
it('calls onPublish and does not close when publish request fails', async () => {
|
||||
const publishError = new Error('Publish failed')
|
||||
onPublish.mockRejectedValueOnce(publishError)
|
||||
|
||||
const wrapper = createWrapper()
|
||||
await wrapper.find('[data-testid="publish-btn"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(onPublish).toHaveBeenCalledOnce()
|
||||
expect(mockToastErrorHandler).toHaveBeenCalledWith(publishError)
|
||||
expect(onGateClose).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows publish disabled while submitting', async () => {
|
||||
const publishDeferred = createDeferred<void>()
|
||||
onPublish.mockReturnValue(publishDeferred.promise)
|
||||
|
||||
const wrapper = createWrapper()
|
||||
const publishBtn = wrapper.find('[data-testid="publish-btn"]')
|
||||
|
||||
await publishBtn.trigger('click')
|
||||
await flushPromises()
|
||||
expect(onPublish).not.toHaveBeenCalled()
|
||||
|
||||
mockCheckProfile.mockResolvedValue(true)
|
||||
const footer = wrapper.find('[data-testid="publish-footer"]')
|
||||
expect(footer.attributes('data-publish-disabled')).toBe('true')
|
||||
expect(footer.attributes('data-is-publishing')).toBe('true')
|
||||
|
||||
publishDeferred.resolve(undefined)
|
||||
await flushPromises()
|
||||
|
||||
expect(footer.attributes('data-is-publishing')).toBe('false')
|
||||
})
|
||||
|
||||
it('resets guard after publish error so retry is possible', async () => {
|
||||
onPublish.mockRejectedValueOnce(new Error('Publish failed'))
|
||||
|
||||
const wrapper = createWrapper()
|
||||
const publishBtn = wrapper.find('[data-testid="publish-btn"]')
|
||||
|
||||
await publishBtn.trigger('click')
|
||||
await flushPromises()
|
||||
expect(onPublish).toHaveBeenCalledOnce()
|
||||
|
||||
onPublish.mockResolvedValueOnce(undefined)
|
||||
await publishBtn.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(onPublish).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -223,9 +293,10 @@ describe('ComfyHubPublishWizardContent', () => {
|
||||
expect(footer.attributes('data-publish-disabled')).toBe('true')
|
||||
})
|
||||
|
||||
it('enables publish when gate enabled and hasProfile is true', () => {
|
||||
it('enables publish when gate enabled and hasProfile is true', async () => {
|
||||
mockHasProfile.value = true
|
||||
const wrapper = createWrapper()
|
||||
await flushPromises()
|
||||
|
||||
const footer = wrapper.find('[data-testid="publish-footer"]')
|
||||
expect(footer.attributes('data-publish-disabled')).toBe('false')
|
||||
|
||||
@@ -7,17 +7,15 @@
|
||||
:on-close="onGateClose"
|
||||
:show-close-button="false"
|
||||
/>
|
||||
<div v-else class="flex min-h-0 flex-1 flex-col px-6 pt-4 pb-2">
|
||||
<div v-else class="flex min-h-0 flex-1 flex-col">
|
||||
<div class="flex min-h-0 flex-1 flex-col overflow-y-auto">
|
||||
<ComfyHubDescribeStep
|
||||
v-if="currentStep === 'describe'"
|
||||
:name="formData.name"
|
||||
:description="formData.description"
|
||||
:workflow-type="formData.workflowType"
|
||||
:tags="formData.tags"
|
||||
@update:name="onUpdateFormData({ name: $event })"
|
||||
@update:description="onUpdateFormData({ description: $event })"
|
||||
@update:workflow-type="onUpdateFormData({ workflowType: $event })"
|
||||
@update:tags="onUpdateFormData({ tags: $event })"
|
||||
/>
|
||||
<div
|
||||
@@ -37,13 +35,22 @@
|
||||
/>
|
||||
<ComfyHubExamplesStep
|
||||
:example-images="formData.exampleImages"
|
||||
:selected-example-ids="formData.selectedExampleIds"
|
||||
@update:example-images="onUpdateFormData({ exampleImages: $event })"
|
||||
@update:selected-example-ids="
|
||||
onUpdateFormData({ selectedExampleIds: $event })
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="currentStep === 'finish' && isProfileLoading"
|
||||
class="flex min-h-0 flex-1 flex-col gap-4 px-6 py-4"
|
||||
>
|
||||
<Skeleton class="h-4 w-1/4" />
|
||||
<Skeleton class="h-20 w-full rounded-2xl" />
|
||||
</div>
|
||||
<ComfyHubFinishStep
|
||||
v-else-if="currentStep === 'finish' && hasProfile && profile"
|
||||
v-model:ready="finishStepReady"
|
||||
v-model:acknowledged="assetsAcknowledged"
|
||||
:profile
|
||||
/>
|
||||
<ComfyHubProfilePromptPanel
|
||||
v-else-if="currentStep === 'finish'"
|
||||
@request-profile="onRequireProfile"
|
||||
@@ -53,6 +60,7 @@
|
||||
:is-first-step
|
||||
:is-last-step
|
||||
:is-publish-disabled
|
||||
:is-publishing="isPublishInFlight"
|
||||
@back="onGoBack"
|
||||
@next="onGoNext"
|
||||
@publish="handlePublish"
|
||||
@@ -70,8 +78,10 @@ import { useComfyHubProfileGate } from '@/platform/workflow/sharing/composables/
|
||||
import ComfyHubCreateProfileForm from '@/platform/workflow/sharing/components/profile/ComfyHubCreateProfileForm.vue'
|
||||
import type { ComfyHubPublishStep } from '@/platform/workflow/sharing/composables/useComfyHubPublishWizard'
|
||||
import type { ComfyHubPublishFormData } from '@/platform/workflow/sharing/types/comfyHubTypes'
|
||||
import Skeleton from '@/components/ui/skeleton/Skeleton.vue'
|
||||
import ComfyHubDescribeStep from './ComfyHubDescribeStep.vue'
|
||||
import ComfyHubExamplesStep from './ComfyHubExamplesStep.vue'
|
||||
import ComfyHubFinishStep from './ComfyHubFinishStep.vue'
|
||||
import ComfyHubProfilePromptPanel from './ComfyHubProfilePromptPanel.vue'
|
||||
import ComfyHubThumbnailStep from './ComfyHubThumbnailStep.vue'
|
||||
import ComfyHubPublishFooter from './ComfyHubPublishFooter.vue'
|
||||
@@ -81,6 +91,7 @@ const {
|
||||
formData,
|
||||
isFirstStep,
|
||||
isLastStep,
|
||||
isPublishing = false,
|
||||
onGoNext,
|
||||
onGoBack,
|
||||
onUpdateFormData,
|
||||
@@ -93,10 +104,11 @@ const {
|
||||
formData: ComfyHubPublishFormData
|
||||
isFirstStep: boolean
|
||||
isLastStep: boolean
|
||||
isPublishing?: boolean
|
||||
onGoNext: () => void
|
||||
onGoBack: () => void
|
||||
onUpdateFormData: (patch: Partial<ComfyHubPublishFormData>) => void
|
||||
onPublish: () => void
|
||||
onPublish: () => Promise<void>
|
||||
onRequireProfile: () => void
|
||||
onGateComplete?: () => void
|
||||
onGateClose?: () => void
|
||||
@@ -104,24 +116,42 @@ const {
|
||||
|
||||
const { toastErrorHandler } = useErrorHandling()
|
||||
const { flags } = useFeatureFlags()
|
||||
const { checkProfile, hasProfile } = useComfyHubProfileGate()
|
||||
const { checkProfile, hasProfile, isFetchingProfile, profile } =
|
||||
useComfyHubProfileGate()
|
||||
const isProfileLoading = computed(
|
||||
() => hasProfile.value === null || isFetchingProfile.value
|
||||
)
|
||||
const finishStepReady = ref(false)
|
||||
const assetsAcknowledged = ref(false)
|
||||
const isResolvingPublishAccess = ref(false)
|
||||
const isPublishInFlight = computed(
|
||||
() => isPublishing || isResolvingPublishAccess.value
|
||||
)
|
||||
const isFinishStepVisible = computed(
|
||||
() =>
|
||||
currentStep === 'finish' &&
|
||||
hasProfile.value === true &&
|
||||
profile.value !== null
|
||||
)
|
||||
const isPublishDisabled = computed(
|
||||
() => flags.comfyHubProfileGateEnabled && hasProfile.value !== true
|
||||
() =>
|
||||
isPublishInFlight.value ||
|
||||
(flags.comfyHubProfileGateEnabled && hasProfile.value !== true) ||
|
||||
(isFinishStepVisible.value && !finishStepReady.value)
|
||||
)
|
||||
|
||||
async function handlePublish() {
|
||||
if (isResolvingPublishAccess.value) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!flags.comfyHubProfileGateEnabled) {
|
||||
onPublish()
|
||||
if (isResolvingPublishAccess.value || isPublishing) {
|
||||
return
|
||||
}
|
||||
|
||||
isResolvingPublishAccess.value = true
|
||||
try {
|
||||
if (!flags.comfyHubProfileGateEnabled) {
|
||||
await onPublish()
|
||||
return
|
||||
}
|
||||
|
||||
let profileExists: boolean
|
||||
try {
|
||||
profileExists = await checkProfile()
|
||||
@@ -131,11 +161,13 @@ async function handlePublish() {
|
||||
}
|
||||
|
||||
if (profileExists) {
|
||||
onPublish()
|
||||
await onPublish()
|
||||
return
|
||||
}
|
||||
|
||||
onRequireProfile()
|
||||
} catch (error) {
|
||||
toastErrorHandler(error)
|
||||
} finally {
|
||||
isResolvingPublishAccess.value = false
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<template>
|
||||
<div class="flex min-h-0 flex-1 flex-col gap-6">
|
||||
<fieldset class="flex flex-col gap-2">
|
||||
<legend class="text-sm text-base-foreground">
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="text-sm text-base-foreground select-none">
|
||||
{{ $t('comfyHubPublish.selectAThumbnail') }}
|
||||
</legend>
|
||||
</span>
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
:model-value="thumbnailType"
|
||||
@@ -14,18 +14,19 @@
|
||||
v-for="option in thumbnailOptions"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
class="h-auto w-full rounded-sm bg-node-component-surface p-2 data-[state=on]:bg-muted-background"
|
||||
class="flex h-auto w-full gap-2 rounded-sm bg-node-component-surface p-2 font-inter text-base-foreground data-[state=on]:bg-muted-background"
|
||||
>
|
||||
<span class="text-center text-sm font-bold text-base-foreground">
|
||||
<i :class="cn('size-4', option.icon)" />
|
||||
<span class="text-center text-sm font-bold">
|
||||
{{ option.label }}
|
||||
</span>
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
<div class="flex min-h-0 flex-1 flex-col gap-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-base-foreground">
|
||||
<span class="text-sm text-base-foreground select-none">
|
||||
{{ uploadSectionLabel }}
|
||||
</span>
|
||||
<Button
|
||||
@@ -40,7 +41,7 @@
|
||||
|
||||
<template v-if="thumbnailType === 'imageComparison'">
|
||||
<div
|
||||
class="grid flex-1 grid-cols-1 grid-rows-1 place-content-center-safe"
|
||||
class="grid flex-1 grid-cols-1 grid-rows-1 place-content-center-safe overflow-hidden"
|
||||
>
|
||||
<div
|
||||
v-if="hasBothComparisonImages"
|
||||
@@ -69,7 +70,7 @@
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'col-span-full row-span-full flex gap-2',
|
||||
'col-span-full row-span-full flex items-center-safe justify-center-safe gap-2',
|
||||
hasBothComparisonImages && 'invisible'
|
||||
)
|
||||
"
|
||||
@@ -80,8 +81,10 @@
|
||||
:ref="(el) => (comparisonDropRefs[slot.key] = el as HTMLElement)"
|
||||
:class="
|
||||
cn(
|
||||
'flex max-w-1/2 cursor-pointer flex-col items-center justify-center rounded-lg border border-dashed transition-colors',
|
||||
comparisonPreviewUrls[slot.key] ? 'self-start' : 'flex-1',
|
||||
'flex aspect-square h-full min-h-0 flex-[0_1_auto] cursor-pointer flex-col items-center justify-center rounded-lg border border-dashed text-center transition-colors',
|
||||
comparisonPreviewUrls[slot.key]
|
||||
? 'self-start'
|
||||
: 'flex-[0_1_1]',
|
||||
comparisonOverStates[slot.key]
|
||||
? 'border-muted-foreground'
|
||||
: 'border-border-default hover:border-muted-foreground'
|
||||
@@ -123,7 +126,7 @@
|
||||
ref="singleDropRef"
|
||||
:class="
|
||||
cn(
|
||||
'flex cursor-pointer flex-col items-center justify-center rounded-lg border border-dashed transition-colors',
|
||||
'm-auto flex aspect-square min-h-0 w-full max-w-48 cursor-pointer flex-col items-center justify-center rounded-lg border border-dashed text-center transition-colors',
|
||||
thumbnailPreviewUrl ? 'self-center p-1' : 'flex-1',
|
||||
isOverSingleDrop
|
||||
? 'border-muted-foreground'
|
||||
@@ -239,15 +242,18 @@ const uploadDropText = computed(() =>
|
||||
const thumbnailOptions = [
|
||||
{
|
||||
value: 'image' as const,
|
||||
label: t('comfyHubPublish.thumbnailImage')
|
||||
label: t('comfyHubPublish.thumbnailImage'),
|
||||
icon: 'icon-[lucide--image]'
|
||||
},
|
||||
{
|
||||
value: 'video' as const,
|
||||
label: t('comfyHubPublish.thumbnailVideo')
|
||||
label: t('comfyHubPublish.thumbnailVideo'),
|
||||
icon: 'icon-[lucide--video]'
|
||||
},
|
||||
{
|
||||
value: 'imageComparison' as const,
|
||||
label: t('comfyHubPublish.thumbnailImageComparison')
|
||||
label: t('comfyHubPublish.thumbnailImageComparison'),
|
||||
icon: 'icon-[lucide--diff]'
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
<template>
|
||||
<div
|
||||
ref="tileRef"
|
||||
:class="
|
||||
cn(
|
||||
'group focus-visible:outline-ring relative aspect-square overflow-hidden rounded-sm outline-offset-2 focus-visible:outline-2',
|
||||
state === 'dragging' && 'opacity-40',
|
||||
state === 'over' && 'ring-2 ring-primary'
|
||||
)
|
||||
"
|
||||
tabindex="0"
|
||||
role="listitem"
|
||||
:aria-label="
|
||||
$t('comfyHubPublish.exampleImagePosition', {
|
||||
index: index + 1,
|
||||
total: total
|
||||
})
|
||||
"
|
||||
@pointerdown="tileRef && focusVisible(tileRef)"
|
||||
@keydown.left.prevent="handleArrowKey(-1, $event)"
|
||||
@keydown.right.prevent="handleArrowKey(1, $event)"
|
||||
@keydown.delete.prevent="handleRemove"
|
||||
@keydown.backspace.prevent="handleRemove"
|
||||
@dragover.prevent.stop
|
||||
@drop.prevent.stop="handleFileDrop"
|
||||
>
|
||||
<img
|
||||
:src="image.url"
|
||||
:alt="$t('comfyHubPublish.exampleImage', { index: index + 1 })"
|
||||
class="pointer-events-none size-full object-cover"
|
||||
draggable="false"
|
||||
/>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="icon"
|
||||
:aria-label="$t('comfyHubPublish.removeExampleImage')"
|
||||
tabindex="-1"
|
||||
class="absolute top-1 right-1 flex size-6 items-center justify-center bg-black/60 text-white opacity-0 transition-opacity not-group-has-focus-visible/grid:group-hover:opacity-100 group-focus-visible:opacity-100 hover:bg-black/80"
|
||||
@click="$emit('remove', image.id)"
|
||||
>
|
||||
<i class="icon-[lucide--x] size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { setCustomNativeDragPreview } from '@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview'
|
||||
import { nextTick, ref } from 'vue'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import {
|
||||
usePragmaticDraggable,
|
||||
usePragmaticDroppable
|
||||
} from '@/composables/usePragmaticDragAndDrop'
|
||||
import type { ExampleImage } from '@/platform/workflow/sharing/types/comfyHubTypes'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { image, index, total, instanceId } = defineProps<{
|
||||
image: ExampleImage
|
||||
index: number
|
||||
total: number
|
||||
instanceId: symbol
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
remove: [id: string]
|
||||
move: [id: string, direction: number]
|
||||
insertFiles: [index: number, files: FileList]
|
||||
}>()
|
||||
|
||||
// focusVisible is a Chromium 122+ extension to FocusOptions
|
||||
// (not yet in TypeScript's lib.dom.d.ts)
|
||||
function focusVisible(el: HTMLElement) {
|
||||
el.focus({ focusVisible: true } as FocusOptions)
|
||||
}
|
||||
|
||||
async function handleArrowKey(direction: number, event: KeyboardEvent) {
|
||||
if (event.shiftKey) {
|
||||
emit('move', image.id, direction)
|
||||
await nextTick()
|
||||
if (tileRef.value) focusVisible(tileRef.value)
|
||||
} else {
|
||||
focusSibling(direction)
|
||||
}
|
||||
}
|
||||
|
||||
function focusSibling(direction: number) {
|
||||
const sibling =
|
||||
direction < 0
|
||||
? tileRef.value?.previousElementSibling
|
||||
: tileRef.value?.nextElementSibling
|
||||
if (sibling instanceof HTMLElement) focusVisible(sibling)
|
||||
}
|
||||
|
||||
async function handleRemove() {
|
||||
const next =
|
||||
tileRef.value?.nextElementSibling ?? tileRef.value?.previousElementSibling
|
||||
emit('remove', image.id)
|
||||
await nextTick()
|
||||
if (next instanceof HTMLElement) focusVisible(next)
|
||||
}
|
||||
|
||||
function handleFileDrop(event: DragEvent) {
|
||||
if (event.dataTransfer?.files?.length) {
|
||||
emit('insertFiles', index, event.dataTransfer.files)
|
||||
}
|
||||
}
|
||||
|
||||
const tileRef = ref<HTMLElement | null>(null)
|
||||
|
||||
type DragState = 'idle' | 'dragging' | 'over'
|
||||
const state = ref<DragState>('idle')
|
||||
|
||||
const tileGetter = () => tileRef.value as HTMLElement
|
||||
|
||||
usePragmaticDraggable(tileGetter, {
|
||||
getInitialData: () => ({
|
||||
type: 'example-image',
|
||||
imageId: image.id,
|
||||
instanceId
|
||||
}),
|
||||
onGenerateDragPreview: ({ nativeSetDragImage }) => {
|
||||
setCustomNativeDragPreview({
|
||||
nativeSetDragImage,
|
||||
render: ({ container }) => {
|
||||
const img = tileRef.value?.querySelector('img')
|
||||
if (!img) return
|
||||
const preview = img.cloneNode(true) as HTMLImageElement
|
||||
preview.style.width = '8rem'
|
||||
preview.style.height = '8rem'
|
||||
preview.style.objectFit = 'cover'
|
||||
preview.style.borderRadius = '4px'
|
||||
container.appendChild(preview)
|
||||
}
|
||||
})
|
||||
},
|
||||
onDragStart: () => {
|
||||
state.value = 'dragging'
|
||||
},
|
||||
onDrop: () => {
|
||||
state.value = 'idle'
|
||||
}
|
||||
})
|
||||
|
||||
usePragmaticDroppable(tileGetter, {
|
||||
getData: () => ({ imageId: image.id }),
|
||||
canDrop: ({ source }) =>
|
||||
source.data.instanceId === instanceId &&
|
||||
source.data.type === 'example-image' &&
|
||||
source.data.imageId !== image.id,
|
||||
onDragEnter: () => {
|
||||
state.value = 'over'
|
||||
},
|
||||
onDragLeave: () => {
|
||||
state.value = 'idle'
|
||||
},
|
||||
onDrop: () => {
|
||||
state.value = 'idle'
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -2,16 +2,22 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { ComfyHubProfile } from '@/schemas/apiSchema'
|
||||
|
||||
const mockFetchApi = vi.hoisted(() => vi.fn())
|
||||
const mockGetMyProfile = vi.hoisted(() => vi.fn())
|
||||
const mockRequestAssetUploadUrl = vi.hoisted(() => vi.fn())
|
||||
const mockUploadFileToPresignedUrl = vi.hoisted(() => vi.fn())
|
||||
const mockCreateProfile = vi.hoisted(() => vi.fn())
|
||||
const mockToastErrorHandler = vi.hoisted(() => vi.fn())
|
||||
const mockResolvedUserInfo = vi.hoisted(() => ({
|
||||
value: { id: 'user-a' }
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
fetchApi: mockFetchApi
|
||||
}
|
||||
vi.mock('@/platform/workflow/sharing/services/comfyHubService', () => ({
|
||||
useComfyHubService: () => ({
|
||||
getMyProfile: mockGetMyProfile,
|
||||
requestAssetUploadUrl: mockRequestAssetUploadUrl,
|
||||
uploadFileToPresignedUrl: mockUploadFileToPresignedUrl,
|
||||
createProfile: mockCreateProfile
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/auth/useCurrentUser', () => ({
|
||||
@@ -35,19 +41,16 @@ const mockProfile: ComfyHubProfile = {
|
||||
description: 'A test profile'
|
||||
}
|
||||
|
||||
function mockSuccessResponse(data?: unknown) {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => data ?? mockProfile
|
||||
} as Response
|
||||
}
|
||||
|
||||
function mockErrorResponse(status = 500, message = 'Server error') {
|
||||
return {
|
||||
ok: false,
|
||||
status,
|
||||
json: async () => ({ message })
|
||||
} as Response
|
||||
function setCurrentWorkspace(workspaceId: string) {
|
||||
sessionStorage.setItem(
|
||||
'Comfy.Workspace.Current',
|
||||
JSON.stringify({
|
||||
id: workspaceId,
|
||||
type: 'team',
|
||||
name: 'Test Workspace',
|
||||
role: 'owner'
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
describe('useComfyHubProfileGate', () => {
|
||||
@@ -56,6 +59,15 @@ describe('useComfyHubProfileGate', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockResolvedUserInfo.value = { id: 'user-a' }
|
||||
setCurrentWorkspace('workspace-1')
|
||||
mockGetMyProfile.mockResolvedValue(mockProfile)
|
||||
mockRequestAssetUploadUrl.mockResolvedValue({
|
||||
uploadUrl: 'https://upload.example.com/avatar.png',
|
||||
publicUrl: 'https://cdn.example.com/avatar.png',
|
||||
token: 'avatar-token'
|
||||
})
|
||||
mockUploadFileToPresignedUrl.mockResolvedValue(undefined)
|
||||
mockCreateProfile.mockResolvedValue(mockProfile)
|
||||
|
||||
// Reset module-level singleton refs
|
||||
gate = useComfyHubProfileGate()
|
||||
@@ -66,50 +78,40 @@ describe('useComfyHubProfileGate', () => {
|
||||
})
|
||||
|
||||
describe('fetchProfile', () => {
|
||||
it('returns mapped profile when API responds ok', async () => {
|
||||
mockFetchApi.mockResolvedValue(mockSuccessResponse())
|
||||
|
||||
it('fetches profile from /hub/profiles/me', async () => {
|
||||
const profile = await gate.fetchProfile()
|
||||
|
||||
expect(profile).toEqual(mockProfile)
|
||||
expect(gate.hasProfile.value).toBe(true)
|
||||
expect(gate.profile.value).toEqual(mockProfile)
|
||||
expect(mockFetchApi).toHaveBeenCalledWith('/hub/profile')
|
||||
expect(mockGetMyProfile).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('returns cached profile when already fetched', async () => {
|
||||
mockFetchApi.mockResolvedValue(mockSuccessResponse())
|
||||
it('reuses cached profile state per user', async () => {
|
||||
await gate.fetchProfile()
|
||||
await gate.fetchProfile()
|
||||
expect(mockGetMyProfile).toHaveBeenCalledTimes(1)
|
||||
|
||||
mockResolvedUserInfo.value = { id: 'user-b' }
|
||||
await gate.fetchProfile()
|
||||
|
||||
expect(mockGetMyProfile).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('sets hasProfile to false when fetch throws', async () => {
|
||||
mockGetMyProfile.mockRejectedValue(new Error('Network error'))
|
||||
|
||||
await gate.fetchProfile()
|
||||
const profile = await gate.fetchProfile()
|
||||
|
||||
expect(profile).toEqual(mockProfile)
|
||||
expect(mockFetchApi).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('re-fetches profile when force option is enabled', async () => {
|
||||
mockFetchApi.mockResolvedValue(mockSuccessResponse())
|
||||
|
||||
await gate.fetchProfile()
|
||||
await gate.fetchProfile({ force: true })
|
||||
|
||||
expect(mockFetchApi).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('returns null when API responds with error', async () => {
|
||||
mockFetchApi.mockResolvedValue(mockErrorResponse(404))
|
||||
|
||||
const profile = await gate.fetchProfile()
|
||||
|
||||
expect(profile).toBeNull()
|
||||
expect(gate.hasProfile.value).toBe(false)
|
||||
expect(gate.profile.value).toBeNull()
|
||||
expect(gate.profile.value).toBe(null)
|
||||
expect(mockToastErrorHandler).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('sets isFetchingProfile during fetch', async () => {
|
||||
let resolvePromise: (v: Response) => void
|
||||
mockFetchApi.mockReturnValue(
|
||||
new Promise<Response>((resolve) => {
|
||||
let resolvePromise: (v: ComfyHubProfile | null) => void
|
||||
mockGetMyProfile.mockReturnValue(
|
||||
new Promise<ComfyHubProfile | null>((resolve) => {
|
||||
resolvePromise = resolve
|
||||
})
|
||||
)
|
||||
@@ -117,7 +119,7 @@ describe('useComfyHubProfileGate', () => {
|
||||
const promise = gate.fetchProfile()
|
||||
expect(gate.isFetchingProfile.value).toBe(true)
|
||||
|
||||
resolvePromise!(mockSuccessResponse())
|
||||
resolvePromise!(mockProfile)
|
||||
await promise
|
||||
|
||||
expect(gate.isFetchingProfile.value).toBe(false)
|
||||
@@ -126,7 +128,7 @@ describe('useComfyHubProfileGate', () => {
|
||||
|
||||
describe('checkProfile', () => {
|
||||
it('returns true when API responds ok', async () => {
|
||||
mockFetchApi.mockResolvedValue(mockSuccessResponse())
|
||||
mockGetMyProfile.mockResolvedValue(mockProfile)
|
||||
|
||||
const result = await gate.checkProfile()
|
||||
|
||||
@@ -134,105 +136,62 @@ describe('useComfyHubProfileGate', () => {
|
||||
expect(gate.hasProfile.value).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false when API responds with error', async () => {
|
||||
mockFetchApi.mockResolvedValue(mockErrorResponse(404))
|
||||
it('returns false when no profile exists', async () => {
|
||||
mockGetMyProfile.mockResolvedValue(null)
|
||||
|
||||
const result = await gate.checkProfile()
|
||||
|
||||
expect(result).toBe(false)
|
||||
expect(gate.hasProfile.value).toBe(false)
|
||||
})
|
||||
|
||||
it('returns cached value without re-fetching', async () => {
|
||||
mockFetchApi.mockResolvedValue(mockSuccessResponse())
|
||||
|
||||
await gate.checkProfile()
|
||||
const result = await gate.checkProfile()
|
||||
|
||||
expect(result).toBe(true)
|
||||
expect(mockFetchApi).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('clears cached profile state when the authenticated user changes', async () => {
|
||||
mockFetchApi.mockResolvedValue(mockSuccessResponse())
|
||||
|
||||
await gate.checkProfile()
|
||||
mockResolvedUserInfo.value = { id: 'user-b' }
|
||||
await gate.checkProfile()
|
||||
|
||||
expect(mockFetchApi).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('createProfile', () => {
|
||||
it('sends FormData with required username', async () => {
|
||||
mockFetchApi.mockResolvedValue(mockSuccessResponse())
|
||||
|
||||
await gate.createProfile({ username: 'testuser' })
|
||||
|
||||
const [url, options] = mockFetchApi.mock.calls[0]
|
||||
expect(url).toBe('/hub/profile')
|
||||
expect(options.method).toBe('POST')
|
||||
|
||||
const body = options.body as FormData
|
||||
expect(body.get('username')).toBe('testuser')
|
||||
})
|
||||
|
||||
it('includes optional fields when provided', async () => {
|
||||
mockFetchApi.mockResolvedValue(mockSuccessResponse())
|
||||
const coverImage = new File(['img'], 'cover.png')
|
||||
it('creates profile with workspace_id and avatar token', async () => {
|
||||
const profilePicture = new File(['img'], 'avatar.png')
|
||||
|
||||
await gate.createProfile({
|
||||
username: 'testuser',
|
||||
name: 'Test User',
|
||||
description: 'Hello',
|
||||
coverImage,
|
||||
profilePicture
|
||||
})
|
||||
|
||||
const body = mockFetchApi.mock.calls[0][1].body as FormData
|
||||
expect(body.get('name')).toBe('Test User')
|
||||
expect(body.get('description')).toBe('Hello')
|
||||
expect(body.get('cover_image')).toBe(coverImage)
|
||||
expect(body.get('profile_picture')).toBe(profilePicture)
|
||||
})
|
||||
|
||||
it('sets profile state on success', async () => {
|
||||
mockFetchApi.mockResolvedValue(mockSuccessResponse())
|
||||
|
||||
await gate.createProfile({ username: 'testuser' })
|
||||
|
||||
expect(gate.hasProfile.value).toBe(true)
|
||||
expect(gate.profile.value).toEqual(mockProfile)
|
||||
})
|
||||
|
||||
it('returns the created profile', async () => {
|
||||
mockFetchApi.mockResolvedValue(
|
||||
mockSuccessResponse({
|
||||
username: 'testuser',
|
||||
name: 'Test User',
|
||||
description: 'A test profile',
|
||||
cover_image_url: 'https://example.com/cover.png',
|
||||
profile_picture_url: 'https://example.com/profile.png'
|
||||
})
|
||||
)
|
||||
|
||||
const profile = await gate.createProfile({ username: 'testuser' })
|
||||
|
||||
expect(profile).toEqual({
|
||||
...mockProfile,
|
||||
coverImageUrl: 'https://example.com/cover.png',
|
||||
profilePictureUrl: 'https://example.com/profile.png'
|
||||
expect(mockCreateProfile).toHaveBeenCalledWith({
|
||||
workspaceId: 'workspace-1',
|
||||
username: 'testuser',
|
||||
displayName: 'Test User',
|
||||
description: 'Hello',
|
||||
avatarToken: 'avatar-token'
|
||||
})
|
||||
})
|
||||
|
||||
it('throws with error message from API response', async () => {
|
||||
mockFetchApi.mockResolvedValue(mockErrorResponse(400, 'Username taken'))
|
||||
it('uploads avatar via upload-url + PUT before create', async () => {
|
||||
const profilePicture = new File(['img'], 'avatar.png', {
|
||||
type: 'image/png'
|
||||
})
|
||||
|
||||
await expect(gate.createProfile({ username: 'taken' })).rejects.toThrow(
|
||||
'Username taken'
|
||||
)
|
||||
await gate.createProfile({
|
||||
username: 'testuser',
|
||||
profilePicture
|
||||
})
|
||||
|
||||
expect(mockRequestAssetUploadUrl).toHaveBeenCalledWith({
|
||||
filename: 'avatar.png',
|
||||
contentType: 'image/png'
|
||||
})
|
||||
expect(mockUploadFileToPresignedUrl).toHaveBeenCalledWith({
|
||||
uploadUrl: 'https://upload.example.com/avatar.png',
|
||||
file: profilePicture,
|
||||
contentType: 'image/png'
|
||||
})
|
||||
const requestCallOrder =
|
||||
mockRequestAssetUploadUrl.mock.invocationCallOrder
|
||||
const uploadCallOrder =
|
||||
mockUploadFileToPresignedUrl.mock.invocationCallOrder
|
||||
const createCallOrder = mockCreateProfile.mock.invocationCallOrder
|
||||
expect(requestCallOrder[0]).toBeLessThan(uploadCallOrder[0])
|
||||
expect(uploadCallOrder[0]).toBeLessThan(createCallOrder[0])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,9 +2,9 @@ import { ref } from 'vue'
|
||||
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import { zHubProfileResponse } from '@/platform/workflow/sharing/schemas/shareSchemas'
|
||||
import { useComfyHubService } from '@/platform/workflow/sharing/services/comfyHubService'
|
||||
import { WORKSPACE_STORAGE_KEYS } from '@/platform/workspace/workspaceConstants'
|
||||
import type { ComfyHubProfile } from '@/schemas/apiSchema'
|
||||
import { api } from '@/scripts/api'
|
||||
|
||||
// TODO: Migrate to a Pinia store for proper singleton state management
|
||||
// User-scoped, session-cached profile state (module-level singleton)
|
||||
@@ -15,14 +15,43 @@ const profile = ref<ComfyHubProfile | null>(null)
|
||||
const cachedUserId = ref<string | null>(null)
|
||||
let inflightFetch: Promise<ComfyHubProfile | null> | null = null
|
||||
|
||||
function mapHubProfileResponse(payload: unknown): ComfyHubProfile | null {
|
||||
const result = zHubProfileResponse.safeParse(payload)
|
||||
return result.success ? result.data : null
|
||||
function getCurrentWorkspaceId(): string {
|
||||
const workspaceJson = sessionStorage.getItem(
|
||||
WORKSPACE_STORAGE_KEYS.CURRENT_WORKSPACE
|
||||
)
|
||||
if (!workspaceJson) {
|
||||
throw new Error('Unable to determine current workspace')
|
||||
}
|
||||
|
||||
let workspace: unknown
|
||||
try {
|
||||
workspace = JSON.parse(workspaceJson)
|
||||
} catch {
|
||||
throw new Error('Unable to determine current workspace')
|
||||
}
|
||||
|
||||
if (
|
||||
!workspace ||
|
||||
typeof workspace !== 'object' ||
|
||||
!('id' in workspace) ||
|
||||
typeof workspace.id !== 'string' ||
|
||||
workspace.id.length === 0
|
||||
) {
|
||||
throw new Error('Unable to determine current workspace')
|
||||
}
|
||||
|
||||
return workspace.id
|
||||
}
|
||||
|
||||
export function useComfyHubProfileGate() {
|
||||
const { resolvedUserInfo } = useCurrentUser()
|
||||
const { toastErrorHandler } = useErrorHandling()
|
||||
const {
|
||||
getMyProfile,
|
||||
requestAssetUploadUrl,
|
||||
uploadFileToPresignedUrl,
|
||||
createProfile: createComfyHubProfile
|
||||
} = useComfyHubService()
|
||||
|
||||
function syncCachedProfileWithCurrentUser(): void {
|
||||
const currentUserId = resolvedUserInfo.value?.id ?? null
|
||||
@@ -38,14 +67,7 @@ export function useComfyHubProfileGate() {
|
||||
async function performFetch(): Promise<ComfyHubProfile | null> {
|
||||
isFetchingProfile.value = true
|
||||
try {
|
||||
const response = await api.fetchApi('/hub/profile')
|
||||
if (!response.ok) {
|
||||
hasProfile.value = false
|
||||
profile.value = null
|
||||
return null
|
||||
}
|
||||
|
||||
const nextProfile = mapHubProfileResponse(await response.json())
|
||||
const nextProfile = await getMyProfile()
|
||||
if (!nextProfile) {
|
||||
hasProfile.value = false
|
||||
profile.value = null
|
||||
@@ -55,6 +77,7 @@ export function useComfyHubProfileGate() {
|
||||
profile.value = nextProfile
|
||||
return nextProfile
|
||||
} catch (error) {
|
||||
hasProfile.value = false
|
||||
toastErrorHandler(error)
|
||||
return null
|
||||
} finally {
|
||||
@@ -95,37 +118,35 @@ export function useComfyHubProfileGate() {
|
||||
username: string
|
||||
name?: string
|
||||
description?: string
|
||||
coverImage?: File
|
||||
profilePicture?: File
|
||||
}): Promise<ComfyHubProfile> {
|
||||
syncCachedProfileWithCurrentUser()
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append('username', data.username)
|
||||
if (data.name) formData.append('name', data.name)
|
||||
if (data.description) formData.append('description', data.description)
|
||||
if (data.coverImage) formData.append('cover_image', data.coverImage)
|
||||
if (data.profilePicture)
|
||||
formData.append('profile_picture', data.profilePicture)
|
||||
let avatarToken: string | undefined
|
||||
if (data.profilePicture) {
|
||||
const contentType = data.profilePicture.type || 'application/octet-stream'
|
||||
const upload = await requestAssetUploadUrl({
|
||||
filename: data.profilePicture.name,
|
||||
contentType
|
||||
})
|
||||
|
||||
const response = await api.fetchApi('/hub/profile', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
await uploadFileToPresignedUrl({
|
||||
uploadUrl: upload.uploadUrl,
|
||||
file: data.profilePicture,
|
||||
contentType
|
||||
})
|
||||
|
||||
avatarToken = upload.token
|
||||
}
|
||||
|
||||
const createdProfile = await createComfyHubProfile({
|
||||
workspaceId: getCurrentWorkspaceId(),
|
||||
username: data.username,
|
||||
displayName: data.name,
|
||||
description: data.description,
|
||||
avatarToken
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const body: unknown = await response.json().catch(() => ({}))
|
||||
const message =
|
||||
body && typeof body === 'object' && 'message' in body
|
||||
? String((body as Record<string, unknown>).message)
|
||||
: 'Failed to create profile'
|
||||
throw new Error(message)
|
||||
}
|
||||
|
||||
const createdProfile = mapHubProfileResponse(await response.json())
|
||||
if (!createdProfile) {
|
||||
throw new Error('Invalid profile response from server')
|
||||
}
|
||||
hasProfile.value = true
|
||||
profile.value = createdProfile
|
||||
return createdProfile
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { ComfyHubProfile } from '@/schemas/apiSchema'
|
||||
import type { ComfyHubPublishFormData } from '@/platform/workflow/sharing/types/comfyHubTypes'
|
||||
|
||||
const mockGetShareableAssets = vi.hoisted(() => vi.fn())
|
||||
const mockRequestAssetUploadUrl = vi.hoisted(() => vi.fn())
|
||||
const mockUploadFileToPresignedUrl = vi.hoisted(() => vi.fn())
|
||||
const mockPublishWorkflow = vi.hoisted(() => vi.fn())
|
||||
const mockProfile = vi.hoisted(
|
||||
() => ({ value: null }) as { value: ComfyHubProfile | null }
|
||||
)
|
||||
|
||||
vi.mock(
|
||||
'@/platform/workflow/sharing/composables/useComfyHubProfileGate',
|
||||
() => ({
|
||||
useComfyHubProfileGate: () => ({
|
||||
profile: mockProfile
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock('@/platform/workflow/sharing/services/workflowShareService', () => ({
|
||||
useWorkflowShareService: () => ({
|
||||
getShareableAssets: mockGetShareableAssets
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workflow/sharing/services/comfyHubService', () => ({
|
||||
useComfyHubService: () => ({
|
||||
requestAssetUploadUrl: mockRequestAssetUploadUrl,
|
||||
uploadFileToPresignedUrl: mockUploadFileToPresignedUrl,
|
||||
publishWorkflow: mockPublishWorkflow
|
||||
})
|
||||
}))
|
||||
|
||||
const mockWorkflowStore = vi.hoisted(() => ({
|
||||
activeWorkflow: {
|
||||
path: 'workflows/demo-workflow.json'
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
|
||||
useWorkflowStore: () => mockWorkflowStore
|
||||
}))
|
||||
|
||||
const { useComfyHubPublishSubmission } =
|
||||
await import('./useComfyHubPublishSubmission')
|
||||
|
||||
function createFormData(
|
||||
overrides: Partial<ComfyHubPublishFormData> = {}
|
||||
): ComfyHubPublishFormData {
|
||||
return {
|
||||
name: 'Demo workflow',
|
||||
description: 'A demo workflow',
|
||||
tags: ['demo'],
|
||||
models: [],
|
||||
customNodes: [],
|
||||
thumbnailType: 'image',
|
||||
thumbnailFile: null,
|
||||
comparisonBeforeFile: null,
|
||||
comparisonAfterFile: null,
|
||||
exampleImages: [],
|
||||
tutorialUrl: '',
|
||||
metadata: {},
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('useComfyHubPublishSubmission', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockProfile.value = {
|
||||
username: 'builder',
|
||||
name: 'Builder'
|
||||
}
|
||||
mockGetShareableAssets.mockResolvedValue([
|
||||
{ id: 'asset-1' },
|
||||
{ id: 'asset-2' }
|
||||
])
|
||||
|
||||
let uploadIndex = 0
|
||||
mockRequestAssetUploadUrl.mockImplementation(
|
||||
async ({ filename }: { filename: string }) => {
|
||||
uploadIndex += 1
|
||||
return {
|
||||
uploadUrl: `https://upload.example.com/${filename}`,
|
||||
publicUrl: `https://cdn.example.com/${filename}`,
|
||||
token: `token-${uploadIndex}`
|
||||
}
|
||||
}
|
||||
)
|
||||
mockUploadFileToPresignedUrl.mockResolvedValue(undefined)
|
||||
mockPublishWorkflow.mockResolvedValue({
|
||||
share_id: 'share-1',
|
||||
workflow_id: 'workflow-1'
|
||||
})
|
||||
})
|
||||
|
||||
it('passes imageComparison thumbnail type to service for normalization', async () => {
|
||||
const beforeFile = new File(['before'], 'before.png', { type: 'image/png' })
|
||||
const afterFile = new File(['after'], 'after.png', { type: 'image/png' })
|
||||
|
||||
const { submitToComfyHub } = useComfyHubPublishSubmission()
|
||||
await submitToComfyHub(
|
||||
createFormData({
|
||||
thumbnailType: 'imageComparison',
|
||||
thumbnailFile: null,
|
||||
comparisonBeforeFile: beforeFile,
|
||||
comparisonAfterFile: afterFile
|
||||
})
|
||||
)
|
||||
|
||||
expect(mockPublishWorkflow).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
thumbnailType: 'imageComparison'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('uploads thumbnail and returns thumbnail token', async () => {
|
||||
const thumbnailFile = new File(['thumbnail'], 'thumb.png', {
|
||||
type: 'image/png'
|
||||
})
|
||||
|
||||
const { submitToComfyHub } = useComfyHubPublishSubmission()
|
||||
await submitToComfyHub(
|
||||
createFormData({
|
||||
thumbnailType: 'image',
|
||||
thumbnailFile
|
||||
})
|
||||
)
|
||||
|
||||
expect(mockRequestAssetUploadUrl).toHaveBeenCalledWith({
|
||||
filename: 'thumb.png',
|
||||
contentType: 'image/png'
|
||||
})
|
||||
expect(mockUploadFileToPresignedUrl).toHaveBeenCalledWith({
|
||||
uploadUrl: 'https://upload.example.com/thumb.png',
|
||||
file: thumbnailFile,
|
||||
contentType: 'image/png'
|
||||
})
|
||||
expect(mockPublishWorkflow).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
thumbnailTokenOrUrl: 'token-1'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('uploads all example images', async () => {
|
||||
const file1 = new File(['img1'], 'img1.png', { type: 'image/png' })
|
||||
const file2 = new File(['img2'], 'img2.png', { type: 'image/png' })
|
||||
|
||||
const { submitToComfyHub } = useComfyHubPublishSubmission()
|
||||
await submitToComfyHub(
|
||||
createFormData({
|
||||
thumbnailType: 'image',
|
||||
thumbnailFile: null,
|
||||
exampleImages: [
|
||||
{ id: 'a', file: file1, url: 'blob:a' },
|
||||
{ id: 'b', file: file2, url: 'blob:b' }
|
||||
]
|
||||
})
|
||||
)
|
||||
|
||||
expect(mockRequestAssetUploadUrl).toHaveBeenCalledTimes(2)
|
||||
expect(mockPublishWorkflow).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sampleImageTokensOrUrls: ['token-1', 'token-2']
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('builds publish request with workflow filename + asset ids', async () => {
|
||||
const { submitToComfyHub } = useComfyHubPublishSubmission()
|
||||
await submitToComfyHub(createFormData())
|
||||
|
||||
expect(mockPublishWorkflow).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
username: 'builder',
|
||||
workflowFilename: 'workflows/demo-workflow.json',
|
||||
assetIds: ['asset-1', 'asset-2'],
|
||||
name: 'Demo workflow',
|
||||
description: 'A demo workflow',
|
||||
tags: ['demo']
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('throws when profile username is unavailable', async () => {
|
||||
mockProfile.value = null
|
||||
|
||||
const { submitToComfyHub } = useComfyHubPublishSubmission()
|
||||
await expect(submitToComfyHub(createFormData())).rejects.toThrow(
|
||||
'ComfyHub profile is required before publishing'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,121 @@
|
||||
import type { AssetInfo, ComfyHubProfile } from '@/schemas/apiSchema'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useComfyHubProfileGate } from '@/platform/workflow/sharing/composables/useComfyHubProfileGate'
|
||||
import { useComfyHubService } from '@/platform/workflow/sharing/services/comfyHubService'
|
||||
import { useWorkflowShareService } from '@/platform/workflow/sharing/services/workflowShareService'
|
||||
import type { ComfyHubPublishFormData } from '@/platform/workflow/sharing/types/comfyHubTypes'
|
||||
import { normalizeTags } from '@/platform/workflow/sharing/utils/normalizeTags'
|
||||
|
||||
function getFileContentType(file: File): string {
|
||||
return file.type || 'application/octet-stream'
|
||||
}
|
||||
|
||||
function getUsername(profile: ComfyHubProfile | null): string {
|
||||
const username = profile?.username?.trim()
|
||||
if (!username) {
|
||||
throw new Error('ComfyHub profile is required before publishing')
|
||||
}
|
||||
|
||||
return username
|
||||
}
|
||||
|
||||
function getWorkflowFilename(path: string | null | undefined): string {
|
||||
const workflowFilename = path?.trim()
|
||||
if (!workflowFilename) {
|
||||
throw new Error('No active workflow file available for publishing')
|
||||
}
|
||||
|
||||
return workflowFilename
|
||||
}
|
||||
|
||||
function getAssetIds(assets: AssetInfo[]): string[] {
|
||||
return assets.map((asset) => asset.id)
|
||||
}
|
||||
|
||||
function resolveThumbnailFile(
|
||||
formData: ComfyHubPublishFormData
|
||||
): File | undefined {
|
||||
if (formData.thumbnailType === 'imageComparison') {
|
||||
return formData.comparisonBeforeFile ?? undefined
|
||||
}
|
||||
return formData.thumbnailFile ?? undefined
|
||||
}
|
||||
|
||||
export function useComfyHubPublishSubmission() {
|
||||
const { profile } = useComfyHubProfileGate()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const workflowShareService = useWorkflowShareService()
|
||||
const comfyHubService = useComfyHubService()
|
||||
|
||||
async function uploadFileAndGetToken(file: File): Promise<string> {
|
||||
const contentType = getFileContentType(file)
|
||||
const upload = await comfyHubService.requestAssetUploadUrl({
|
||||
filename: file.name,
|
||||
contentType
|
||||
})
|
||||
|
||||
await comfyHubService.uploadFileToPresignedUrl({
|
||||
uploadUrl: upload.uploadUrl,
|
||||
file,
|
||||
contentType
|
||||
})
|
||||
|
||||
return upload.token
|
||||
}
|
||||
|
||||
async function submitToComfyHub(
|
||||
formData: ComfyHubPublishFormData
|
||||
): Promise<void> {
|
||||
const username = getUsername(profile.value)
|
||||
const workflowFilename = getWorkflowFilename(
|
||||
workflowStore.activeWorkflow?.path
|
||||
)
|
||||
const assetIds = getAssetIds(
|
||||
await workflowShareService.getShareableAssets()
|
||||
)
|
||||
|
||||
const thumbnailFile = resolveThumbnailFile(formData)
|
||||
const thumbnailTokenOrUrl = thumbnailFile
|
||||
? await uploadFileAndGetToken(thumbnailFile)
|
||||
: undefined
|
||||
const thumbnailComparisonTokenOrUrl =
|
||||
formData.thumbnailType === 'imageComparison' &&
|
||||
formData.comparisonAfterFile
|
||||
? await uploadFileAndGetToken(formData.comparisonAfterFile)
|
||||
: undefined
|
||||
|
||||
const sampleImageTokensOrUrls =
|
||||
formData.exampleImages.length > 0
|
||||
? await Promise.all(
|
||||
formData.exampleImages.map((image) =>
|
||||
image.file ? uploadFileAndGetToken(image.file) : image.url
|
||||
)
|
||||
)
|
||||
: undefined
|
||||
|
||||
await comfyHubService.publishWorkflow({
|
||||
username,
|
||||
name: formData.name,
|
||||
workflowFilename,
|
||||
assetIds,
|
||||
description: formData.description || undefined,
|
||||
tags: formData.tags.length > 0 ? normalizeTags(formData.tags) : undefined,
|
||||
models: formData.models.length > 0 ? formData.models : undefined,
|
||||
customNodes:
|
||||
formData.customNodes.length > 0 ? formData.customNodes : undefined,
|
||||
thumbnailType: formData.thumbnailType,
|
||||
thumbnailTokenOrUrl,
|
||||
thumbnailComparisonTokenOrUrl,
|
||||
sampleImageTokensOrUrls,
|
||||
tutorialUrl: formData.tutorialUrl || undefined,
|
||||
metadata:
|
||||
Object.keys(formData.metadata).length > 0
|
||||
? formData.metadata
|
||||
: undefined
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
submitToComfyHub
|
||||
}
|
||||
}
|
||||
@@ -35,14 +35,12 @@ describe('useComfyHubPublishWizard', () => {
|
||||
it('initialises all other form fields to defaults', () => {
|
||||
const { formData } = useComfyHubPublishWizard()
|
||||
expect(formData.value.description).toBe('')
|
||||
expect(formData.value.workflowType).toBe('')
|
||||
expect(formData.value.tags).toEqual([])
|
||||
expect(formData.value.thumbnailType).toBe('image')
|
||||
expect(formData.value.thumbnailFile).toBeNull()
|
||||
expect(formData.value.comparisonBeforeFile).toBeNull()
|
||||
expect(formData.value.comparisonAfterFile).toBeNull()
|
||||
expect(formData.value.exampleImages).toEqual([])
|
||||
expect(formData.value.selectedExampleIds).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import { useStepper } from '@vueuse/core'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import type { ComfyHubPublishFormData } from '@/platform/workflow/sharing/types/comfyHubTypes'
|
||||
import type {
|
||||
ComfyHubPublishFormData,
|
||||
ExampleImage
|
||||
} from '@/platform/workflow/sharing/types/comfyHubTypes'
|
||||
import type { PublishPrefill } from '@/platform/workflow/sharing/types/shareTypes'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { normalizeTags } from '@/platform/workflow/sharing/utils/normalizeTags'
|
||||
|
||||
const PUBLISH_STEPS = [
|
||||
'describe',
|
||||
@@ -13,22 +19,55 @@ const PUBLISH_STEPS = [
|
||||
|
||||
export type ComfyHubPublishStep = (typeof PUBLISH_STEPS)[number]
|
||||
|
||||
// TODO: Migrate to a Pinia store alongside the profile gate singleton
|
||||
const cachedPrefills = new Map<string, PublishPrefill>()
|
||||
|
||||
function createDefaultFormData(): ComfyHubPublishFormData {
|
||||
const { activeWorkflow } = useWorkflowStore()
|
||||
const workflowStore = useWorkflowStore()
|
||||
return {
|
||||
name: activeWorkflow?.filename ?? '',
|
||||
name: workflowStore.activeWorkflow?.filename ?? '',
|
||||
description: '',
|
||||
workflowType: '',
|
||||
tags: [],
|
||||
models: [],
|
||||
customNodes: [],
|
||||
thumbnailType: 'image',
|
||||
thumbnailFile: null,
|
||||
comparisonBeforeFile: null,
|
||||
comparisonAfterFile: null,
|
||||
exampleImages: [],
|
||||
selectedExampleIds: []
|
||||
tutorialUrl: '',
|
||||
metadata: {}
|
||||
}
|
||||
}
|
||||
|
||||
function createExampleImagesFromUrls(urls: string[]): ExampleImage[] {
|
||||
return urls.map((url) => ({ id: uuidv4(), url }))
|
||||
}
|
||||
|
||||
function extractPrefillFromFormData(
|
||||
formData: ComfyHubPublishFormData
|
||||
): PublishPrefill {
|
||||
return {
|
||||
description: formData.description || undefined,
|
||||
tags: formData.tags.length > 0 ? normalizeTags(formData.tags) : undefined,
|
||||
thumbnailType: formData.thumbnailType,
|
||||
sampleImageUrls: formData.exampleImages
|
||||
.map((img) => img.url)
|
||||
.filter((url) => !url.startsWith('blob:'))
|
||||
}
|
||||
}
|
||||
|
||||
export function cachePublishPrefill(
|
||||
workflowPath: string,
|
||||
formData: ComfyHubPublishFormData
|
||||
) {
|
||||
cachedPrefills.set(workflowPath, extractPrefillFromFormData(formData))
|
||||
}
|
||||
|
||||
export function getCachedPrefill(workflowPath: string): PublishPrefill | null {
|
||||
return cachedPrefills.get(workflowPath) ?? null
|
||||
}
|
||||
|
||||
export function useComfyHubPublishWizard() {
|
||||
const stepper = useStepper([...PUBLISH_STEPS])
|
||||
const formData = ref<ComfyHubPublishFormData>(createDefaultFormData())
|
||||
@@ -53,6 +92,30 @@ export function useComfyHubPublishWizard() {
|
||||
stepper.goTo('finish')
|
||||
}
|
||||
|
||||
function applyPrefill(prefill: PublishPrefill) {
|
||||
const defaults = createDefaultFormData()
|
||||
const current = formData.value
|
||||
formData.value = {
|
||||
...current,
|
||||
description:
|
||||
current.description === defaults.description
|
||||
? (prefill.description ?? current.description)
|
||||
: current.description,
|
||||
tags:
|
||||
current.tags.length === 0 && prefill.tags?.length
|
||||
? prefill.tags
|
||||
: current.tags,
|
||||
thumbnailType:
|
||||
current.thumbnailType === defaults.thumbnailType
|
||||
? (prefill.thumbnailType ?? current.thumbnailType)
|
||||
: current.thumbnailType,
|
||||
exampleImages:
|
||||
current.exampleImages.length === 0 && prefill.sampleImageUrls?.length
|
||||
? createExampleImagesFromUrls(prefill.sampleImageUrls)
|
||||
: current.exampleImages
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
currentStep: stepper.current,
|
||||
formData,
|
||||
@@ -64,6 +127,7 @@ export function useComfyHubPublishWizard() {
|
||||
goNext: stepper.goToNext,
|
||||
goBack: stepper.goToPrevious,
|
||||
openProfileCreationStep,
|
||||
closeProfileCreationStep
|
||||
closeProfileCreationStep,
|
||||
applyPrefill
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,7 +53,6 @@ const COMFY_HUB_TAG_FREQUENCIES = [
|
||||
{ tag: 'Lip Sync', count: 2 },
|
||||
{ tag: 'Multiple Angles', count: 2 },
|
||||
{ tag: 'Remove Background', count: 2 },
|
||||
{ tag: 'Text-to-Image', count: 2 },
|
||||
{ tag: 'Vector', count: 2 },
|
||||
{ tag: 'Brand', count: 1 },
|
||||
{ tag: 'Canny', count: 1 },
|
||||
|
||||
@@ -10,6 +10,15 @@ export const zPublishRecordResponse = z.object({
|
||||
assets: z.array(zAssetInfo).optional()
|
||||
})
|
||||
|
||||
export const zHubWorkflowPrefillResponse = z.object({
|
||||
description: z.string().nullish(),
|
||||
tags: z.array(z.string()).nullish(),
|
||||
sample_image_urls: z.array(z.string()).nullish(),
|
||||
thumbnail_type: z.enum(['image', 'video', 'image_comparison']).nullish(),
|
||||
thumbnail_url: z.string().nullish(),
|
||||
thumbnail_comparison_url: z.string().nullish()
|
||||
})
|
||||
|
||||
/**
|
||||
* Strips path separators and control characters from a workflow name to prevent
|
||||
* path traversal when the name is later used as part of a file path.
|
||||
@@ -36,9 +45,28 @@ export const zHubProfileResponse = z.preprocess((data) => {
|
||||
const d = data as Record<string, unknown>
|
||||
return {
|
||||
username: d.username,
|
||||
name: d.name,
|
||||
name: d.name ?? d.display_name,
|
||||
description: d.description,
|
||||
coverImageUrl: d.coverImageUrl ?? d.cover_image_url,
|
||||
profilePictureUrl: d.profilePictureUrl ?? d.profile_picture_url
|
||||
profilePictureUrl:
|
||||
d.profilePictureUrl ?? d.profile_picture_url ?? d.avatar_url
|
||||
}
|
||||
}, zComfyHubProfile)
|
||||
|
||||
export const zHubAssetUploadUrlResponse = z
|
||||
.object({
|
||||
upload_url: z.string(),
|
||||
public_url: z.string(),
|
||||
token: z.string()
|
||||
})
|
||||
.transform((response) => ({
|
||||
uploadUrl: response.upload_url,
|
||||
publicUrl: response.public_url,
|
||||
token: response.token
|
||||
}))
|
||||
|
||||
export const zHubWorkflowPublishResponse = z.object({
|
||||
share_id: z.string(),
|
||||
workflow_id: z.string(),
|
||||
thumbnail_type: z.enum(['image', 'video', 'image_comparison']).optional()
|
||||
})
|
||||
|
||||
198
src/platform/workflow/sharing/services/comfyHubService.test.ts
Normal file
198
src/platform/workflow/sharing/services/comfyHubService.test.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mockFetchApi = vi.hoisted(() => vi.fn())
|
||||
const mockGlobalFetch = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
fetchApi: (...args: unknown[]) => mockFetchApi(...args)
|
||||
}
|
||||
}))
|
||||
|
||||
const { useComfyHubService } = await import('./comfyHubService')
|
||||
|
||||
function mockJsonResponse(payload: unknown, ok = true, status = 200): Response {
|
||||
return {
|
||||
ok,
|
||||
status,
|
||||
json: async () => payload
|
||||
} as Response
|
||||
}
|
||||
|
||||
function mockUploadResponse(ok = true, status = 200): Response {
|
||||
return {
|
||||
ok,
|
||||
status,
|
||||
json: async () => ({})
|
||||
} as Response
|
||||
}
|
||||
|
||||
describe('useComfyHubService', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
vi.stubGlobal('fetch', mockGlobalFetch)
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('requests upload url and returns token payload', async () => {
|
||||
mockFetchApi.mockResolvedValue(
|
||||
mockJsonResponse({
|
||||
upload_url: 'https://upload.example.com/object',
|
||||
public_url: 'https://cdn.example.com/object',
|
||||
token: 'upload-token'
|
||||
})
|
||||
)
|
||||
|
||||
const service = useComfyHubService()
|
||||
const result = await service.requestAssetUploadUrl({
|
||||
filename: 'thumb.png',
|
||||
contentType: 'image/png'
|
||||
})
|
||||
|
||||
expect(mockFetchApi).toHaveBeenCalledWith('/hub/assets/upload-url', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
filename: 'thumb.png',
|
||||
content_type: 'image/png'
|
||||
})
|
||||
})
|
||||
expect(result).toEqual({
|
||||
uploadUrl: 'https://upload.example.com/object',
|
||||
publicUrl: 'https://cdn.example.com/object',
|
||||
token: 'upload-token'
|
||||
})
|
||||
})
|
||||
|
||||
it('uploads file to presigned url with PUT', async () => {
|
||||
mockGlobalFetch.mockResolvedValue(mockUploadResponse())
|
||||
|
||||
const service = useComfyHubService()
|
||||
const file = new File(['payload'], 'avatar.png', { type: 'image/png' })
|
||||
await service.uploadFileToPresignedUrl({
|
||||
uploadUrl: 'https://upload.example.com/object',
|
||||
file,
|
||||
contentType: 'image/png'
|
||||
})
|
||||
|
||||
expect(mockGlobalFetch).toHaveBeenCalledWith(
|
||||
'https://upload.example.com/object',
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'image/png'
|
||||
},
|
||||
body: file
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('creates profile with workspace_id JSON body', async () => {
|
||||
mockFetchApi.mockResolvedValue(
|
||||
mockJsonResponse({
|
||||
id: 'profile-1',
|
||||
username: 'builder',
|
||||
display_name: 'Builder',
|
||||
description: 'Builds workflows',
|
||||
avatar_url: 'https://cdn.example.com/avatar.png',
|
||||
website_urls: []
|
||||
})
|
||||
)
|
||||
|
||||
const service = useComfyHubService()
|
||||
const profile = await service.createProfile({
|
||||
workspaceId: 'workspace-1',
|
||||
username: 'builder',
|
||||
displayName: 'Builder',
|
||||
description: 'Builds workflows',
|
||||
avatarToken: 'avatar-token'
|
||||
})
|
||||
|
||||
expect(mockFetchApi).toHaveBeenCalledWith('/hub/profiles', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
workspace_id: 'workspace-1',
|
||||
username: 'builder',
|
||||
display_name: 'Builder',
|
||||
description: 'Builds workflows',
|
||||
avatar_token: 'avatar-token'
|
||||
})
|
||||
})
|
||||
expect(profile).toEqual({
|
||||
username: 'builder',
|
||||
name: 'Builder',
|
||||
description: 'Builds workflows',
|
||||
profilePictureUrl: 'https://cdn.example.com/avatar.png',
|
||||
coverImageUrl: undefined
|
||||
})
|
||||
})
|
||||
|
||||
it('publishes workflow with mapped thumbnail enum', async () => {
|
||||
mockFetchApi.mockResolvedValue(
|
||||
mockJsonResponse({
|
||||
share_id: 'share-1',
|
||||
workflow_id: 'workflow-1',
|
||||
thumbnail_type: 'image_comparison'
|
||||
})
|
||||
)
|
||||
|
||||
const service = useComfyHubService()
|
||||
await service.publishWorkflow({
|
||||
username: 'builder',
|
||||
name: 'My Flow',
|
||||
workflowFilename: 'workflows/my-flow.json',
|
||||
assetIds: ['asset-1'],
|
||||
thumbnailType: 'imageComparison',
|
||||
thumbnailTokenOrUrl: 'thumb-token',
|
||||
thumbnailComparisonTokenOrUrl: 'thumb-compare-token',
|
||||
sampleImageTokensOrUrls: ['sample-1']
|
||||
})
|
||||
|
||||
const [, options] = mockFetchApi.mock.calls[0]
|
||||
const body = JSON.parse(options.body as string)
|
||||
expect(body).toMatchObject({
|
||||
username: 'builder',
|
||||
name: 'My Flow',
|
||||
workflow_filename: 'workflows/my-flow.json',
|
||||
asset_ids: ['asset-1'],
|
||||
thumbnail_type: 'image_comparison',
|
||||
thumbnail_token_or_url: 'thumb-token',
|
||||
thumbnail_comparison_token_or_url: 'thumb-compare-token',
|
||||
sample_image_tokens_or_urls: ['sample-1']
|
||||
})
|
||||
expect(mockFetchApi).toHaveBeenCalledWith('/hub/workflows', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
})
|
||||
|
||||
it('fetches current profile from /hub/profiles/me', async () => {
|
||||
mockFetchApi.mockResolvedValue(
|
||||
mockJsonResponse({
|
||||
id: 'profile-1',
|
||||
username: 'builder',
|
||||
display_name: 'Builder',
|
||||
description: 'Builds workflows',
|
||||
avatar_url: 'https://cdn.example.com/avatar.png',
|
||||
website_urls: []
|
||||
})
|
||||
)
|
||||
|
||||
const service = useComfyHubService()
|
||||
const profile = await service.getMyProfile()
|
||||
|
||||
expect(mockFetchApi).toHaveBeenCalledWith('/hub/profiles/me')
|
||||
expect(profile).toEqual({
|
||||
username: 'builder',
|
||||
name: 'Builder',
|
||||
description: 'Builds workflows',
|
||||
profilePictureUrl: 'https://cdn.example.com/avatar.png',
|
||||
coverImageUrl: undefined
|
||||
})
|
||||
})
|
||||
})
|
||||
223
src/platform/workflow/sharing/services/comfyHubService.ts
Normal file
223
src/platform/workflow/sharing/services/comfyHubService.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
import type { ComfyHubProfile } from '@/schemas/apiSchema'
|
||||
import {
|
||||
zHubAssetUploadUrlResponse,
|
||||
zHubProfileResponse,
|
||||
zHubWorkflowPublishResponse
|
||||
} from '@/platform/workflow/sharing/schemas/shareSchemas'
|
||||
import { api } from '@/scripts/api'
|
||||
|
||||
type HubThumbnailType = 'image' | 'video' | 'image_comparison'
|
||||
|
||||
type ThumbnailTypeInput = HubThumbnailType | 'imageComparison'
|
||||
|
||||
interface CreateProfileInput {
|
||||
workspaceId: string
|
||||
username: string
|
||||
displayName?: string
|
||||
description?: string
|
||||
avatarToken?: string
|
||||
}
|
||||
|
||||
interface PublishWorkflowInput {
|
||||
username: string
|
||||
name: string
|
||||
workflowFilename: string
|
||||
assetIds: string[]
|
||||
description?: string
|
||||
tags?: string[]
|
||||
models?: string[]
|
||||
customNodes?: string[]
|
||||
thumbnailType?: ThumbnailTypeInput
|
||||
thumbnailTokenOrUrl?: string
|
||||
thumbnailComparisonTokenOrUrl?: string
|
||||
sampleImageTokensOrUrls?: string[]
|
||||
tutorialUrl?: string
|
||||
metadata?: Record<string, unknown>
|
||||
}
|
||||
|
||||
function normalizeThumbnailType(type: ThumbnailTypeInput): HubThumbnailType {
|
||||
if (type === 'imageComparison') {
|
||||
return 'image_comparison'
|
||||
}
|
||||
|
||||
return type
|
||||
}
|
||||
|
||||
async function parseErrorMessage(
|
||||
response: Response,
|
||||
fallbackMessage: string
|
||||
): Promise<string> {
|
||||
const body = await response.json().catch(() => null)
|
||||
if (!body || typeof body !== 'object') {
|
||||
return fallbackMessage
|
||||
}
|
||||
|
||||
if ('message' in body && typeof body.message === 'string') {
|
||||
return body.message
|
||||
}
|
||||
|
||||
return fallbackMessage
|
||||
}
|
||||
|
||||
async function parseRequiredJson<T>(
|
||||
response: Response,
|
||||
parser: {
|
||||
safeParse: (
|
||||
value: unknown
|
||||
) => { success: true; data: T } | { success: false }
|
||||
},
|
||||
fallbackMessage: string
|
||||
): Promise<T> {
|
||||
const payload = await response.json().catch(() => null)
|
||||
const parsed = parser.safeParse(payload)
|
||||
if (!parsed.success) {
|
||||
throw new Error(fallbackMessage)
|
||||
}
|
||||
|
||||
return parsed.data
|
||||
}
|
||||
|
||||
export function useComfyHubService() {
|
||||
async function requestAssetUploadUrl(input: {
|
||||
filename: string
|
||||
contentType: string
|
||||
}) {
|
||||
const response = await api.fetchApi('/hub/assets/upload-url', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
filename: input.filename,
|
||||
content_type: input.contentType
|
||||
})
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
await parseErrorMessage(response, 'Failed to request upload URL')
|
||||
)
|
||||
}
|
||||
|
||||
return parseRequiredJson(
|
||||
response,
|
||||
zHubAssetUploadUrlResponse,
|
||||
'Invalid upload URL response from server'
|
||||
)
|
||||
}
|
||||
|
||||
async function uploadFileToPresignedUrl(input: {
|
||||
uploadUrl: string
|
||||
file: File
|
||||
contentType: string
|
||||
}): Promise<void> {
|
||||
const response = await fetch(input.uploadUrl, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': input.contentType
|
||||
},
|
||||
body: input.file
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const message = await parseErrorMessage(
|
||||
response,
|
||||
'Failed to upload file to presigned URL'
|
||||
)
|
||||
throw new Error(message)
|
||||
}
|
||||
}
|
||||
|
||||
async function getMyProfile(): Promise<ComfyHubProfile | null> {
|
||||
const response = await api.fetchApi('/hub/profiles/me')
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
return null
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
await parseErrorMessage(response, 'Failed to load ComfyHub profile')
|
||||
)
|
||||
}
|
||||
|
||||
return parseRequiredJson(
|
||||
response,
|
||||
zHubProfileResponse,
|
||||
'Invalid profile response from server'
|
||||
)
|
||||
}
|
||||
|
||||
async function createProfile(
|
||||
input: CreateProfileInput
|
||||
): Promise<ComfyHubProfile> {
|
||||
const response = await api.fetchApi('/hub/profiles', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
workspace_id: input.workspaceId,
|
||||
username: input.username,
|
||||
display_name: input.displayName,
|
||||
description: input.description,
|
||||
avatar_token: input.avatarToken
|
||||
})
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
await parseErrorMessage(response, 'Failed to create ComfyHub profile')
|
||||
)
|
||||
}
|
||||
|
||||
return parseRequiredJson(
|
||||
response,
|
||||
zHubProfileResponse,
|
||||
'Invalid profile response from server'
|
||||
)
|
||||
}
|
||||
|
||||
async function publishWorkflow(input: PublishWorkflowInput) {
|
||||
const body = {
|
||||
username: input.username,
|
||||
name: input.name,
|
||||
workflow_filename: input.workflowFilename,
|
||||
asset_ids: input.assetIds,
|
||||
description: input.description,
|
||||
tags: input.tags,
|
||||
models: input.models,
|
||||
custom_nodes: input.customNodes,
|
||||
thumbnail_type: input.thumbnailType
|
||||
? normalizeThumbnailType(input.thumbnailType)
|
||||
: undefined,
|
||||
thumbnail_token_or_url: input.thumbnailTokenOrUrl,
|
||||
thumbnail_comparison_token_or_url: input.thumbnailComparisonTokenOrUrl,
|
||||
sample_image_tokens_or_urls: input.sampleImageTokensOrUrls,
|
||||
tutorial_url: input.tutorialUrl,
|
||||
metadata: input.metadata
|
||||
}
|
||||
|
||||
const response = await api.fetchApi('/hub/workflows', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
await parseErrorMessage(response, 'Failed to publish workflow')
|
||||
)
|
||||
}
|
||||
|
||||
return parseRequiredJson(
|
||||
response,
|
||||
zHubWorkflowPublishResponse,
|
||||
'Invalid publish response from server'
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
requestAssetUploadUrl,
|
||||
uploadFileToPresignedUrl,
|
||||
getMyProfile,
|
||||
createProfile,
|
||||
publishWorkflow
|
||||
}
|
||||
}
|
||||
@@ -163,6 +163,82 @@ describe(useWorkflowShareService, () => {
|
||||
expect(status.publishedAt).toBeInstanceOf(Date)
|
||||
})
|
||||
|
||||
it('includes prefill data from hub workflow details', async () => {
|
||||
mockFetchApi.mockImplementation(async (path: string) => {
|
||||
if (path === '/userdata/wf-prefill/publish') {
|
||||
return mockJsonResponse({
|
||||
workflow_id: 'wf-prefill',
|
||||
share_id: 'wf-prefill',
|
||||
publish_time: '2026-02-23T00:00:00Z',
|
||||
listed: true
|
||||
})
|
||||
}
|
||||
|
||||
if (path === '/hub/workflows/wf-prefill') {
|
||||
return mockJsonResponse({
|
||||
description: 'A cool workflow',
|
||||
tags: ['art', 'upscale'],
|
||||
thumbnail_type: 'image_comparison',
|
||||
sample_image_urls: ['https://example.com/img1.png']
|
||||
})
|
||||
}
|
||||
|
||||
return mockJsonResponse({}, false, 404)
|
||||
})
|
||||
|
||||
const service = useWorkflowShareService()
|
||||
const status = await service.getPublishStatus('wf-prefill')
|
||||
|
||||
expect(status.isPublished).toBe(true)
|
||||
expect(status.prefill).toEqual({
|
||||
description: 'A cool workflow',
|
||||
tags: ['art', 'upscale'],
|
||||
thumbnailType: 'imageComparison',
|
||||
sampleImageUrls: ['https://example.com/img1.png']
|
||||
})
|
||||
expect(mockFetchApi).toHaveBeenNthCalledWith(2, '/hub/workflows/wf-prefill')
|
||||
})
|
||||
|
||||
it('returns null prefill when hub workflow details are unavailable', async () => {
|
||||
mockFetchApi.mockImplementation(async (path: string) => {
|
||||
if (path === '/userdata/wf-no-meta/publish') {
|
||||
return mockJsonResponse({
|
||||
workflow_id: 'wf-no-meta',
|
||||
share_id: 'wf-no-meta',
|
||||
publish_time: '2026-02-23T00:00:00Z',
|
||||
listed: true
|
||||
})
|
||||
}
|
||||
|
||||
return mockJsonResponse({}, false, 500)
|
||||
})
|
||||
|
||||
const service = useWorkflowShareService()
|
||||
const status = await service.getPublishStatus('wf-no-meta')
|
||||
|
||||
expect(status.isPublished).toBe(true)
|
||||
expect(status.prefill).toBeNull()
|
||||
})
|
||||
|
||||
it('does not fetch hub workflow details when publish record is unlisted', async () => {
|
||||
mockFetchApi.mockResolvedValue(
|
||||
mockJsonResponse({
|
||||
workflow_id: 'wf-unlisted',
|
||||
share_id: 'wf-unlisted',
|
||||
publish_time: '2026-02-23T00:00:00Z',
|
||||
listed: false
|
||||
})
|
||||
)
|
||||
|
||||
const service = useWorkflowShareService()
|
||||
const status = await service.getPublishStatus('wf-unlisted')
|
||||
|
||||
expect(status.isPublished).toBe(true)
|
||||
expect(status.prefill).toBeNull()
|
||||
expect(mockFetchApi).toHaveBeenCalledTimes(1)
|
||||
expect(mockFetchApi).toHaveBeenCalledWith('/userdata/wf-unlisted/publish')
|
||||
})
|
||||
|
||||
it('preserves app subpath when normalizing publish status share URLs', async () => {
|
||||
window.history.replaceState({}, '', '/comfy/subpath/')
|
||||
mockFetchApi.mockResolvedValue(
|
||||
@@ -303,7 +379,8 @@ describe(useWorkflowShareService, () => {
|
||||
isPublished: false,
|
||||
shareId: null,
|
||||
shareUrl: null,
|
||||
publishedAt: null
|
||||
publishedAt: null,
|
||||
prefill: null
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import type {
|
||||
PublishPrefill,
|
||||
SharedWorkflowPayload,
|
||||
WorkflowPublishResult,
|
||||
WorkflowPublishStatus
|
||||
} from '@/platform/workflow/sharing/types/shareTypes'
|
||||
import type { ThumbnailType } from '@/platform/workflow/sharing/types/comfyHubTypes'
|
||||
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import { validateComfyWorkflow } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import type { AssetInfo } from '@/schemas/apiSchema'
|
||||
import {
|
||||
zHubWorkflowPrefillResponse,
|
||||
zPublishRecordResponse,
|
||||
zSharedWorkflowResponse
|
||||
} from '@/platform/workflow/sharing/schemas/shareSchemas'
|
||||
@@ -28,6 +31,45 @@ class SharedWorkflowLoadError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
function mapApiThumbnailType(
|
||||
value: 'image' | 'video' | 'image_comparison' | null | undefined
|
||||
): ThumbnailType | undefined {
|
||||
if (!value) return undefined
|
||||
if (value === 'image_comparison') return 'imageComparison'
|
||||
return value
|
||||
}
|
||||
|
||||
interface PrefillMetadataFields {
|
||||
description?: string | null
|
||||
tags?: string[] | null
|
||||
thumbnail_type?: 'image' | 'video' | 'image_comparison' | null
|
||||
sample_image_urls?: string[] | null
|
||||
}
|
||||
|
||||
function extractPrefill(fields: PrefillMetadataFields): PublishPrefill | null {
|
||||
const description = fields.description ?? undefined
|
||||
const tags = fields.tags ?? undefined
|
||||
const thumbnailType = mapApiThumbnailType(fields.thumbnail_type)
|
||||
const sampleImageUrls = fields.sample_image_urls ?? undefined
|
||||
|
||||
if (
|
||||
!description &&
|
||||
!tags?.length &&
|
||||
!thumbnailType &&
|
||||
!sampleImageUrls?.length
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
return { description, tags, thumbnailType, sampleImageUrls }
|
||||
}
|
||||
|
||||
function decodeHubWorkflowPrefill(payload: unknown): PublishPrefill | null {
|
||||
const result = zHubWorkflowPrefillResponse.safeParse(payload)
|
||||
if (!result.success) return null
|
||||
return extractPrefill(result.data)
|
||||
}
|
||||
|
||||
function decodePublishRecord(payload: unknown) {
|
||||
const result = zPublishRecordResponse.safeParse(payload)
|
||||
if (!result.success) return null
|
||||
@@ -37,7 +79,8 @@ function decodePublishRecord(payload: unknown) {
|
||||
shareId: r.share_id ?? undefined,
|
||||
listed: r.listed,
|
||||
publishedAt: parsePublishedAt(r.publish_time),
|
||||
shareUrl: r.share_id ? normalizeShareUrl(r.share_id) : undefined
|
||||
shareUrl: r.share_id ? normalizeShareUrl(r.share_id) : undefined,
|
||||
prefill: null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,10 +124,27 @@ const UNPUBLISHED = {
|
||||
isPublished: false,
|
||||
shareId: null,
|
||||
shareUrl: null,
|
||||
publishedAt: null
|
||||
publishedAt: null,
|
||||
prefill: null
|
||||
} as const satisfies WorkflowPublishStatus
|
||||
|
||||
export function useWorkflowShareService() {
|
||||
async function fetchHubWorkflowPrefill(
|
||||
shareId: string
|
||||
): Promise<PublishPrefill | null> {
|
||||
const response = await api.fetchApi(
|
||||
`/hub/workflows/${encodeURIComponent(shareId)}`
|
||||
)
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to fetch hub workflow details: ${response.status}`
|
||||
)
|
||||
}
|
||||
|
||||
const prefill = decodeHubWorkflowPrefill(await response.json())
|
||||
return prefill
|
||||
}
|
||||
|
||||
async function publishWorkflow(
|
||||
workflowPath: string,
|
||||
shareableAssets: AssetInfo[]
|
||||
@@ -132,11 +192,21 @@ export function useWorkflowShareService() {
|
||||
const record = decodePublishRecord(json)
|
||||
if (!record || !record.shareId || !record.publishedAt) return UNPUBLISHED
|
||||
|
||||
let prefill: PublishPrefill | null = record.prefill
|
||||
if (!prefill && record.listed) {
|
||||
try {
|
||||
prefill = await fetchHubWorkflowPrefill(record.shareId)
|
||||
} catch {
|
||||
prefill = null
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isPublished: true,
|
||||
shareId: record.shareId,
|
||||
shareUrl: normalizeShareUrl(record.shareId),
|
||||
publishedAt: record.publishedAt
|
||||
publishedAt: record.publishedAt,
|
||||
prefill
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
export type ThumbnailType = 'image' | 'video' | 'imageComparison'
|
||||
|
||||
export type ComfyHubWorkflowType =
|
||||
| 'imageGeneration'
|
||||
| 'videoGeneration'
|
||||
| 'upscaling'
|
||||
| 'editing'
|
||||
|
||||
export interface ExampleImage {
|
||||
id: string
|
||||
url: string
|
||||
@@ -15,12 +9,14 @@ export interface ExampleImage {
|
||||
export interface ComfyHubPublishFormData {
|
||||
name: string
|
||||
description: string
|
||||
workflowType: ComfyHubWorkflowType | ''
|
||||
tags: string[]
|
||||
models: string[]
|
||||
customNodes: string[]
|
||||
thumbnailType: ThumbnailType
|
||||
thumbnailFile: File | null
|
||||
comparisonBeforeFile: File | null
|
||||
comparisonAfterFile: File | null
|
||||
exampleImages: ExampleImage[]
|
||||
selectedExampleIds: string[]
|
||||
tutorialUrl: string
|
||||
metadata: Record<string, unknown>
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { AssetInfo } from '@/schemas/apiSchema'
|
||||
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import type { ThumbnailType } from '@/platform/workflow/sharing/types/comfyHubTypes'
|
||||
|
||||
export interface WorkflowPublishResult {
|
||||
publishedAt: Date
|
||||
@@ -7,13 +8,27 @@ export interface WorkflowPublishResult {
|
||||
shareUrl: string
|
||||
}
|
||||
|
||||
export interface PublishPrefill {
|
||||
description?: string
|
||||
tags?: string[]
|
||||
thumbnailType?: ThumbnailType
|
||||
sampleImageUrls?: string[]
|
||||
}
|
||||
|
||||
export type WorkflowPublishStatus =
|
||||
| { isPublished: false; publishedAt: null; shareId: null; shareUrl: null }
|
||||
| {
|
||||
isPublished: false
|
||||
publishedAt: null
|
||||
shareId: null
|
||||
shareUrl: null
|
||||
prefill: null
|
||||
}
|
||||
| {
|
||||
isPublished: true
|
||||
publishedAt: Date
|
||||
shareId: string
|
||||
shareUrl: string
|
||||
prefill: PublishPrefill | null
|
||||
}
|
||||
|
||||
export interface SharedWorkflowPayload {
|
||||
|
||||
55
src/platform/workflow/sharing/utils/normalizeTags.test.ts
Normal file
55
src/platform/workflow/sharing/utils/normalizeTags.test.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { normalizeTag, normalizeTags } from './normalizeTags'
|
||||
|
||||
describe('normalizeTag', () => {
|
||||
it.for([
|
||||
{ input: 'Text to Image', expected: 'text-to-image', name: 'spaces' },
|
||||
{ input: 'API', expected: 'api', name: 'single word' },
|
||||
{
|
||||
input: 'text-to-image',
|
||||
expected: 'text-to-image',
|
||||
name: 'already normalized'
|
||||
},
|
||||
{
|
||||
input: 'Image Upscale',
|
||||
expected: 'image-upscale',
|
||||
name: 'multiple spaces'
|
||||
},
|
||||
{
|
||||
input: ' Video ',
|
||||
expected: 'video',
|
||||
name: 'leading/trailing whitespace'
|
||||
},
|
||||
{ input: ' ', expected: '', name: 'whitespace-only' }
|
||||
])('$name: "$input" → "$expected"', ({ input, expected }) => {
|
||||
expect(normalizeTag(input)).toBe(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe('normalizeTags', () => {
|
||||
it.for([
|
||||
{
|
||||
name: 'normalizes all tags',
|
||||
input: ['Text to Image', 'API', 'Video'],
|
||||
expected: ['text-to-image', 'api', 'video']
|
||||
},
|
||||
{
|
||||
name: 'deduplicates tags with the same slug',
|
||||
input: ['Text to Image', 'Text-to-Image'],
|
||||
expected: ['text-to-image']
|
||||
},
|
||||
{
|
||||
name: 'filters out empty tags',
|
||||
input: ['Video', '', ' ', 'Audio'],
|
||||
expected: ['video', 'audio']
|
||||
},
|
||||
{
|
||||
name: 'returns empty array for empty input',
|
||||
input: [],
|
||||
expected: []
|
||||
}
|
||||
])('$name', ({ input, expected }) => {
|
||||
expect(normalizeTags(input)).toEqual(expected)
|
||||
})
|
||||
})
|
||||
14
src/platform/workflow/sharing/utils/normalizeTags.ts
Normal file
14
src/platform/workflow/sharing/utils/normalizeTags.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Normalizes a tag to its slug form for the ComfyHub API.
|
||||
* Converts display names like "Text to Image" to "text-to-image".
|
||||
*/
|
||||
export function normalizeTag(tag: string): string {
|
||||
return tag.trim().toLowerCase().replace(/\s+/g, '-')
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes and deduplicates an array of tags for API submission.
|
||||
*/
|
||||
export function normalizeTags(tags: string[]): string[] {
|
||||
return [...new Set(tags.map(normalizeTag).filter(Boolean))]
|
||||
}
|
||||
@@ -580,6 +580,9 @@ export class ComfyApp {
|
||||
// Get prompt from dropped PNG or json
|
||||
useEventListener(document, 'drop', async (event: DragEvent) => {
|
||||
try {
|
||||
// Skip if already handled (e.g. file drop onto publish dialog tiles)
|
||||
if (event.defaultPrevented) return
|
||||
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
|
||||
@@ -123,12 +123,13 @@ export const useCustomerEventsService = () => {
|
||||
|
||||
function formatJsonValue(value: unknown) {
|
||||
if (typeof value === 'number') {
|
||||
// Format numbers with commas and decimals if needed
|
||||
return value.toLocaleString()
|
||||
}
|
||||
if (typeof value === 'string' && value.match(/^\d{4}-\d{2}-\d{2}/)) {
|
||||
// Format dates nicely
|
||||
return new Date(value).toLocaleString()
|
||||
if (typeof value === 'string') {
|
||||
const date = new Date(value)
|
||||
if (!Number.isNaN(date.getTime()) && /^\d{4}-\d{2}-\d{2}T/.test(value)) {
|
||||
return d(date, { dateStyle: 'medium', timeStyle: 'short' })
|
||||
}
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import type { DialogPassThroughOptions } from 'primevue/dialog'
|
||||
import { markRaw, ref } from 'vue'
|
||||
import type { Component } from 'vue'
|
||||
|
||||
import type GlobalDialog from '@/components/dialog/GlobalDialog.vue'
|
||||
import type { ComponentAttrs } from 'vue-component-type-helpers'
|
||||
|
||||
type DialogPosition =
|
||||
@@ -34,23 +33,19 @@ interface CustomDialogComponentProps {
|
||||
headless?: boolean
|
||||
}
|
||||
|
||||
export type DialogComponentProps = ComponentAttrs<typeof GlobalDialog> &
|
||||
CustomDialogComponentProps
|
||||
export type DialogComponentProps = CustomDialogComponentProps &
|
||||
Record<string, unknown>
|
||||
|
||||
export interface DialogInstance<
|
||||
H extends Component = Component,
|
||||
B extends Component = Component,
|
||||
F extends Component = Component
|
||||
> {
|
||||
export interface DialogInstance {
|
||||
key: string
|
||||
visible: boolean
|
||||
title?: string
|
||||
headerComponent?: H
|
||||
headerProps?: ComponentAttrs<H>
|
||||
component: B
|
||||
contentProps: ComponentAttrs<B>
|
||||
footerComponent?: F
|
||||
footerProps?: ComponentAttrs<F>
|
||||
headerComponent?: Component
|
||||
headerProps?: Record<string, unknown>
|
||||
component: Component
|
||||
contentProps: Record<string, unknown>
|
||||
footerComponent?: Component
|
||||
footerProps?: Record<string, unknown>
|
||||
dialogComponentProps: DialogComponentProps
|
||||
priority: number
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user