mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-15 20:21:04 +00:00
Compare commits
1 Commits
dev/remote
...
fix/subgra
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b20c8ee11 |
@@ -11,11 +11,10 @@ Cherry-pick backport management for Comfy-Org/ComfyUI_frontend stable release br
|
||||
|
||||
1. **Discover** — Collect candidates from Slack bot + git log gap (`reference/discovery.md`)
|
||||
2. **Analyze** — Categorize MUST/SHOULD/SKIP, check deps (`reference/analysis.md`)
|
||||
3. **Human Review** — Present candidates in batches for interactive approval (see Interactive Approval Flow)
|
||||
4. **Plan** — Order by dependency (leaf fixes first), group into waves per branch
|
||||
5. **Execute** — Label-driven automation → worktree fallback for conflicts (`reference/execution.md`)
|
||||
6. **Verify** — After each wave, verify branch integrity before proceeding
|
||||
7. **Log & Report** — Generate session report (`reference/logging.md`)
|
||||
3. **Plan** — Order by dependency (leaf fixes first), group into waves per branch
|
||||
4. **Execute** — Label-driven automation → worktree fallback for conflicts (`reference/execution.md`)
|
||||
5. **Verify** — After each wave, verify branch integrity before proceeding
|
||||
6. **Log & Report** — Generate session report with mermaid diagram (`reference/logging.md`)
|
||||
|
||||
## System Context
|
||||
|
||||
@@ -38,29 +37,16 @@ Cherry-pick backport management for Comfy-Org/ComfyUI_frontend stable release br
|
||||
|
||||
**Critical: Match PRs to the correct target branches.**
|
||||
|
||||
| Branch prefix | Scope | Example |
|
||||
| ------------- | ------------------------------ | ------------------------------------------------- |
|
||||
| `cloud/*` | Cloud-hosted ComfyUI only | Team workspaces, cloud queue, cloud-only login |
|
||||
| `core/*` | Local/self-hosted ComfyUI only | Core editor, local workflows, node system |
|
||||
| Both | Shared infrastructure | App mode, Firebase auth (API nodes), payment URLs |
|
||||
| Branch prefix | Scope | Example |
|
||||
| ------------- | ------------------------------ | ----------------------------------------- |
|
||||
| `cloud/*` | Cloud-hosted ComfyUI only | App mode, cloud auth, cloud-specific UI |
|
||||
| `core/*` | Local/self-hosted ComfyUI only | Core editor, local workflows, node system |
|
||||
|
||||
### What Goes Where
|
||||
**⚠️ NEVER backport cloud-only PRs to `core/*` branches.** Cloud-only changes (app mode, cloud auth, cloud billing UI, cloud-specific API calls) are irrelevant to local users and waste effort. Before backporting any PR to a `core/*` branch, check:
|
||||
|
||||
**Both core + cloud:**
|
||||
|
||||
- **App mode** PRs — app mode is NOT cloud-only
|
||||
- **Firebase auth** PRs — Firebase auth is on core for API nodes
|
||||
- **Payment redirect** PRs — payment infrastructure shared
|
||||
- **Bug fixes** touching shared components
|
||||
|
||||
**Cloud-only (skip for core):**
|
||||
|
||||
- Team workspaces
|
||||
- Cloud queue virtualization
|
||||
- Hide API key login
|
||||
- Cloud-specific UI behind cloud feature flags
|
||||
|
||||
**⚠️ NEVER backport cloud-only PRs to `core/*` branches.** But do NOT assume "app mode" or "Firebase" = cloud-only. Check the actual files changed.
|
||||
- Does the PR title/description mention "app mode", "cloud", or cloud-specific features?
|
||||
- Does the PR only touch files like `appModeStore.ts`, cloud auth, or cloud-specific components?
|
||||
- If yes → skip for `core/*` branches (may still apply to `cloud/*` branches)
|
||||
|
||||
## ⚠️ Gotchas (Learn from Past Sessions)
|
||||
|
||||
@@ -81,32 +67,6 @@ The `pr-backport.yaml` action reports more conflicts than reality. `git cherry-p
|
||||
|
||||
12 or 27 conflicting files can be trivial (snapshots, new files). **Categorize conflicts first**, then decide. See Conflict Triage below.
|
||||
|
||||
### Accept-Theirs Can Produce Broken Hybrids
|
||||
|
||||
When a PR **rewrites a component** (e.g., PrimeVue → Reka UI), the accept-theirs regex produces a broken mix of old and new code. The template may reference new APIs while the script still has old imports, or vice versa.
|
||||
|
||||
**Detection:** Content conflicts with 4+ conflict markers in a single `.vue` file, especially when imports change between component libraries.
|
||||
|
||||
**Fix:** Instead of accept-theirs regex, use `git show MERGE_SHA:path/to/file > path/to/file` to get the complete correct version from the merge commit on main. This bypasses the conflict entirely.
|
||||
|
||||
### Cherry-Picks Can Reference Missing Dependencies
|
||||
|
||||
When PR A on main depends on code introduced by PR B (which was merged before A), cherry-picking A brings in code that references B's additions. The cherry-pick succeeds but the branch is broken.
|
||||
|
||||
**Common pattern:** Composables, component files, or type definitions introduced by an earlier PR and used by the cherry-picked PR.
|
||||
|
||||
**Detection:** `pnpm typecheck` fails with "Cannot find module" or "is not defined" errors after cherry-pick.
|
||||
|
||||
**Fix:** Use `git show MERGE_SHA:path/to/missing/file > path/to/missing/file` to bring the missing files from main. Always verify with typecheck.
|
||||
|
||||
### Use `--no-verify` for Worktree Pushes
|
||||
|
||||
Husky hooks fail in worktrees (can't find lint-staged config). Always use `git push --no-verify` and `git commit --no-verify` when working in `/tmp/` worktrees.
|
||||
|
||||
### Automation Success Varies Wildly by Branch
|
||||
|
||||
In the 2026-04-06 session: core/1.42 got 18/26 auto-PRs, cloud/1.42 got only 1/25. The cloud branch has more divergence. **Always plan for manual fallback** — don't assume automation will handle most PRs.
|
||||
|
||||
## Conflict Triage
|
||||
|
||||
**Always categorize before deciding to skip. High conflict count ≠ hard conflicts.**
|
||||
@@ -117,8 +77,6 @@ In the 2026-04-06 session: core/1.42 got 18/26 auto-PRs, cloud/1.42 got only 1/2
|
||||
| **Modify/delete (new file)** | PR introduces files not on target | `git add $FILE` — keep the new file |
|
||||
| **Modify/delete (removed)** | Target removed files the PR modifies | `git rm $FILE` — file no longer relevant |
|
||||
| **Content conflicts** | Marker-based (`<<<<<<<`) | Accept theirs via python regex (see below) |
|
||||
| **Component rewrites** | 4+ markers in `.vue`, library change | Use `git show SHA:path > path` — do NOT accept-theirs |
|
||||
| **Import-only conflicts** | Only import lines differ | Keep both imports if both used; remove unused after |
|
||||
| **Add/add** | Both sides added same file | Accept theirs, verify no logic conflict |
|
||||
| **Locale/JSON files** | i18n key additions | Accept theirs, validate JSON after |
|
||||
|
||||
@@ -145,7 +103,7 @@ Skip these without discussion:
|
||||
- **Test-only / lint rule changes** — Not user-facing
|
||||
- **Revert pairs** — If PR A reverted by PR B, skip both. If fixed version (PR C) exists, backport only C.
|
||||
- **Features not on target branch** — e.g., Painter, GLSLShader, appModeStore on core/1.40
|
||||
- **Cloud-only PRs on core/\* branches** — Team workspaces, cloud queue, cloud-only login. (Note: app mode and Firebase auth are NOT cloud-only — see Branch Scope Rules)
|
||||
- **Cloud-only PRs on core/\* branches** — App mode, cloud auth, cloud billing. These only affect cloud-hosted ComfyUI.
|
||||
|
||||
## Wave Verification
|
||||
|
||||
@@ -164,18 +122,6 @@ git worktree remove /tmp/verify-TARGET --force
|
||||
|
||||
If typecheck or tests fail, stop and investigate before continuing. A broken branch after wave N means all subsequent waves will compound the problem.
|
||||
|
||||
### Fix PRs Are Normal
|
||||
|
||||
Expect to create 1 fix PR per branch after verification. Common issues:
|
||||
|
||||
1. **Component rewrite hybrids** — accept-theirs produced broken `.vue` files. Fix: overwrite with correct version from merge commit via `git show SHA:path > path`
|
||||
2. **Missing dependency files** — cherry-pick brought in code referencing composables/components not on the branch. Fix: add missing files from merge commit
|
||||
3. **Missing type properties** — cherry-picked code uses interface properties not yet on the branch (e.g., `key` on `ConfirmDialogOptions`). Fix: add the property to the interface
|
||||
4. **Unused imports** — conflict resolution kept imports that the branch doesn't use. Fix: remove unused imports
|
||||
5. **Wrong types from conflict resolution** — e.g., `{ top: number; right: number }` vs `{ top: number; left: number }`. Fix: match the return type of the actual function
|
||||
|
||||
Create a fix PR on a branch from the target, verify typecheck passes, then merge with `--squash --admin`.
|
||||
|
||||
### Never Admin-Merge Without CI
|
||||
|
||||
In a previous bulk session, all 69 backport PRs were merged with `gh pr merge --squash --admin`, bypassing required CI checks. This shipped 3 test failures to a release branch. **Lesson: `--admin` skips all branch protection, including required status checks.** Only use `--admin` after confirming CI has passed (e.g., `gh pr checks $PR` shows all green), or rely on auto-merge (`--auto --squash`) which waits for CI by design.
|
||||
@@ -189,43 +135,6 @@ Large backport sessions (50+ PRs) are expensive and error-prone. Prefer continuo
|
||||
- Reserve session-style bulk backporting for catching up after gaps
|
||||
- When a release branch is created, immediately start the continuous process
|
||||
|
||||
## Interactive Approval Flow
|
||||
|
||||
After analysis, present ALL candidates (MUST, SHOULD, and borderline) to the human for interactive review before execution. Do not write a static decisions.md — collect approvals in conversation.
|
||||
|
||||
### Batch Presentation
|
||||
|
||||
Present PRs in batches of 5-10, grouped by theme (visual bugs, interaction bugs, cloud/auth, data correctness, etc.). Use this table format:
|
||||
|
||||
```
|
||||
# | PR | Title | Target | Rec | Context
|
||||
----+--------+------------------------------------------+---------------+------+--------
|
||||
1 | #12345 | fix: broken thing | core+cloud/42 | Y | Description here. Why it matters. Agent reasoning.
|
||||
2 | #12346 | fix: another issue | core/42 | N | Only affects removed feature. Not on target branch.
|
||||
```
|
||||
|
||||
Each row includes:
|
||||
|
||||
- PR number and title
|
||||
- Target branches
|
||||
- Agent recommendation: `Rec: Y` or `Rec: N` with brief reasoning
|
||||
- 2-3 sentence context: what the PR does, why it matters (or doesn't)
|
||||
|
||||
### Human Response Format
|
||||
|
||||
- `Y` — approve for backport
|
||||
- `N` — skip
|
||||
- `?` — investigate (agent shows PR description, files changed, detailed take, then re-asks)
|
||||
- Any freeform question or comment triggers discussion before moving on
|
||||
- Bulk responses accepted (e.g. `1 Y, 2 Y, 3 N, 4 ?`)
|
||||
|
||||
### Rules
|
||||
|
||||
- ALL candidates are reviewed, not just MUST items
|
||||
- When human responds `?`, show the PR description, files changed, and agent's detailed analysis, then re-ask for their decision
|
||||
- When human asks a question about a PR, answer with context and recommendation, then wait for their decision
|
||||
- Do not proceed to execution until all batches are reviewed and every candidate has a Y or N
|
||||
|
||||
## Quick Reference
|
||||
|
||||
### Label-Driven Automation (default path)
|
||||
@@ -241,96 +150,13 @@ gh api repos/Comfy-Org/ComfyUI_frontend/issues/$PR/labels \
|
||||
```bash
|
||||
git worktree add /tmp/backport-$BRANCH origin/$BRANCH
|
||||
cd /tmp/backport-$BRANCH
|
||||
|
||||
# For each PR:
|
||||
git fetch origin $BRANCH
|
||||
git checkout -b backport-$PR-to-$BRANCH origin/$BRANCH
|
||||
git cherry-pick -m 1 $MERGE_SHA
|
||||
# Resolve conflicts (see Conflict Triage)
|
||||
git push origin backport-$PR-to-$BRANCH --no-verify
|
||||
gh pr create --base $BRANCH --head backport-$PR-to-$BRANCH \
|
||||
--title "[backport $BRANCH] $TITLE (#$PR)" \
|
||||
--body "Backport of #$PR. [conflict notes]"
|
||||
gh pr merge $NEW_PR --squash --admin
|
||||
sleep 25
|
||||
# Resolve conflicts, push, create PR, merge
|
||||
```
|
||||
|
||||
### Efficient Batch: Test-Then-Resolve Pattern
|
||||
|
||||
When many PRs need manual cherry-pick (e.g., cloud branches), test all first:
|
||||
|
||||
```bash
|
||||
cd /tmp/backport-$BRANCH
|
||||
for pr in "${ORDER[@]}"; do
|
||||
git checkout -b test-$pr origin/$BRANCH
|
||||
if git cherry-pick -m 1 $SHA 2>/dev/null; then
|
||||
echo "CLEAN: $pr"
|
||||
else
|
||||
echo "CONFLICT: $pr"
|
||||
git cherry-pick --abort
|
||||
fi
|
||||
git checkout --detach HEAD
|
||||
git branch -D test-$pr
|
||||
done
|
||||
```
|
||||
|
||||
Then process clean PRs in a batch loop, conflicts individually.
|
||||
|
||||
### PR Title Convention
|
||||
|
||||
```
|
||||
[backport TARGET_BRANCH] Original Title (#ORIGINAL_PR)
|
||||
```
|
||||
|
||||
## Final Deliverables (Slack-Compatible)
|
||||
|
||||
After execution completes, generate two files in `~/temp/backport-session/`. Both must be **Slack-compatible plain text** — no emojis, no markdown tables, no headers (`#`), no bold (`**`), no inline code. Use plain dashes, indentation, and line breaks only.
|
||||
|
||||
### 1. Author Accountability Report
|
||||
|
||||
File: `backport-author-accountability.md`
|
||||
|
||||
Lists all backported PRs grouped by original author (via `gh pr view $PR --json author`). Surfaces who should be self-labeling.
|
||||
|
||||
```
|
||||
Backport Session YYYY-MM-DD -- PRs that should have been labeled by authors
|
||||
|
||||
- author-login
|
||||
- #1234 fix: short title
|
||||
- #5678 fix: another title
|
||||
- other-author
|
||||
- #9012 fix: some other fix
|
||||
```
|
||||
|
||||
Authors sorted alphabetically, 4-space indent for nested items.
|
||||
|
||||
### 2. Slack Status Update
|
||||
|
||||
File: `slack-status-update.md`
|
||||
|
||||
A shareable summary of the session. Structure:
|
||||
|
||||
```
|
||||
Backport session complete -- YYYY-MM-DD
|
||||
|
||||
[1-sentence summary: N PRs backported to which branches. All pass typecheck.]
|
||||
|
||||
Branches updated:
|
||||
- core/X.XX: N PRs + N fix PRs (N auto, N manual)
|
||||
- cloud/X.XX: N PRs + N fix PRs (N auto, N manual)
|
||||
- ...
|
||||
|
||||
N total PRs created and merged (N backports + N fix PRs).
|
||||
|
||||
Notable fixes included:
|
||||
- [category]: [list of fixes]
|
||||
- ...
|
||||
|
||||
Conflict patterns encountered:
|
||||
- [pattern and how it was resolved]
|
||||
- ...
|
||||
|
||||
N authors had PRs backported. See author accountability list for details.
|
||||
```
|
||||
|
||||
No emojis, no tables, no bold, no headers. Plain text that pastes cleanly into Slack.
|
||||
|
||||
@@ -23,10 +23,10 @@ For SHOULD items with conflicts: if conflict resolution requires more than trivi
|
||||
|
||||
**Before categorizing, filter by branch scope:**
|
||||
|
||||
| Target branch | Skip if PR is... |
|
||||
| ------------- | ----------------------------------------------------------------------------------------------------------------- |
|
||||
| `core/*` | Cloud-only (team workspaces, cloud queue, cloud-only login). Note: app mode and Firebase auth are NOT cloud-only. |
|
||||
| `cloud/*` | Local-only features not present on cloud branch |
|
||||
| Target branch | Skip if PR is... |
|
||||
| ------------- | ------------------------------------------------------------------- |
|
||||
| `core/*` | Cloud-only (app mode, cloud auth, cloud billing, cloud-specific UI) |
|
||||
| `cloud/*` | Local-only features not present on cloud branch |
|
||||
|
||||
Cloud-only PRs backported to `core/*` are wasted effort — `core/*` branches serve local/self-hosted users who never see cloud features. Check PR titles, descriptions, and files changed for cloud-specific indicators.
|
||||
|
||||
@@ -61,6 +61,8 @@ done
|
||||
|
||||
## Human Review Checkpoint
|
||||
|
||||
Use the Interactive Approval Flow (see SKILL.md) to review all candidates interactively. Do not write a static decisions.md for the human to edit — instead, present batches of 5-10 PRs with context and recommendations, and collect Y/N/? responses in conversation.
|
||||
Present decisions.md before execution. Include:
|
||||
|
||||
All candidates must be reviewed (MUST, SHOULD, and borderline items), not just a subset.
|
||||
1. All MUST/SHOULD/SKIP categorizations with rationale
|
||||
2. Questions for human (feature existence, scope, deps)
|
||||
3. Estimated effort per branch
|
||||
|
||||
@@ -73,22 +73,14 @@ for PR in ${CONFLICT_PRS[@]}; do
|
||||
git cherry-pick -m 1 $MERGE_SHA
|
||||
|
||||
# If conflict — NEVER skip based on file count alone!
|
||||
# Categorize conflicts first: binary PNGs, modify/delete, content, add/add, component rewrites
|
||||
# Categorize conflicts first: binary PNGs, modify/delete, content, add/add
|
||||
# See SKILL.md Conflict Triage table for resolution per type.
|
||||
|
||||
# For component rewrites (4+ markers in a .vue file, library migration):
|
||||
# DO NOT use accept-theirs regex — it produces broken hybrids.
|
||||
# Instead, use the complete file from the merge commit:
|
||||
# git show $MERGE_SHA:path/to/file > path/to/file
|
||||
|
||||
# For simple content conflicts, accept theirs:
|
||||
# python3 -c "import re; ..."
|
||||
|
||||
# Resolve all conflicts, then:
|
||||
git add .
|
||||
GIT_EDITOR=true git cherry-pick --continue
|
||||
|
||||
git push origin backport-$PR-to-TARGET --no-verify
|
||||
git push origin backport-$PR-to-TARGET
|
||||
NEW_PR=$(gh pr create --base TARGET_BRANCH --head backport-$PR-to-TARGET \
|
||||
--title "[backport TARGET] TITLE (#$PR)" \
|
||||
--body "Backport of #$PR..." | grep -oP '\d+$')
|
||||
@@ -122,30 +114,7 @@ source ~/.nvm/nvm.sh && nvm use 24 && pnpm install && pnpm typecheck && pnpm tes
|
||||
git worktree remove /tmp/verify-TARGET --force
|
||||
```
|
||||
|
||||
If verification fails, **do not skip** — create a fix PR:
|
||||
|
||||
```bash
|
||||
# Stay in the verify worktree
|
||||
git checkout -b fix-backport-TARGET origin/TARGET_BRANCH
|
||||
|
||||
# Common fixes:
|
||||
# 1. Component rewrite hybrids: overwrite with merge commit version
|
||||
git show MERGE_SHA:path/to/Component.vue > path/to/Component.vue
|
||||
|
||||
# 2. Missing dependency files
|
||||
git show MERGE_SHA:path/to/missing.ts > path/to/missing.ts
|
||||
|
||||
# 3. Missing type properties: edit the interface
|
||||
# 4. Unused imports: delete the import lines
|
||||
|
||||
git add -A
|
||||
git commit --no-verify -m "fix: resolve backport typecheck issues on TARGET"
|
||||
git push origin fix-backport-TARGET --no-verify
|
||||
gh pr create --base TARGET --head fix-backport-TARGET --title "fix: resolve backport typecheck issues on TARGET" --body "..."
|
||||
gh pr merge $PR --squash --admin
|
||||
```
|
||||
|
||||
Do not proceed to the next branch until typecheck passes.
|
||||
If verification fails, stop and fix before proceeding to the next wave. Do not compound problems across waves.
|
||||
|
||||
## Conflict Resolution Patterns
|
||||
|
||||
@@ -173,35 +142,7 @@ git rm $FILE
|
||||
git checkout --theirs $FILE && git add $FILE
|
||||
```
|
||||
|
||||
### 4. Component Rewrites (DO NOT accept-theirs)
|
||||
|
||||
When a PR completely rewrites a component (e.g., PrimeVue → Reka UI), accept-theirs produces
|
||||
a broken hybrid with mismatched template/script sections.
|
||||
|
||||
```bash
|
||||
# Use the complete correct file from the merge commit instead:
|
||||
git show $MERGE_SHA:src/components/input/MultiSelect.vue > src/components/input/MultiSelect.vue
|
||||
git show $MERGE_SHA:src/components/input/SingleSelect.vue > src/components/input/SingleSelect.vue
|
||||
git add src/components/input/MultiSelect.vue src/components/input/SingleSelect.vue
|
||||
```
|
||||
|
||||
**Detection:** 4+ conflict markers in a single `.vue` file, imports changing between component
|
||||
libraries (PrimeVue → Reka UI, etc.), template structure completely different on each side.
|
||||
|
||||
### 5. Missing Dependencies After Cherry-Pick
|
||||
|
||||
Cherry-picks can succeed but leave the branch broken because the PR's code on main
|
||||
references composables/components introduced by an earlier PR.
|
||||
|
||||
```bash
|
||||
# Add the missing file from the merge commit:
|
||||
git show $MERGE_SHA:src/composables/queue/useJobDetailsHover.ts > src/composables/queue/useJobDetailsHover.ts
|
||||
git show $MERGE_SHA:src/components/builder/BuilderSaveDialogContent.vue > src/components/builder/BuilderSaveDialogContent.vue
|
||||
```
|
||||
|
||||
**Detection:** `pnpm typecheck` fails with "Cannot find module" or "X is not defined" after cherry-pick succeeds cleanly.
|
||||
|
||||
### 6. Locale Files
|
||||
### 4. Locale Files
|
||||
|
||||
Usually adding new i18n keys — accept theirs, validate JSON:
|
||||
|
||||
@@ -235,14 +176,8 @@ gh pr checks $PR --watch --fail-fast && gh pr merge $PR --squash --admin
|
||||
8. **Always validate JSON** after resolving locale file conflicts
|
||||
9. **Dep refresh PRs** — skip on stable branches. Risk of transitive dep regressions outweighs audit cleanup. Cherry-pick individual CVE fixes instead.
|
||||
10. **Verify after each wave** — run `pnpm typecheck && pnpm test:unit` on the target branch after merging a batch. Catching breakage early prevents compounding errors.
|
||||
11. **App mode and Firebase auth are NOT cloud-only** — they go to both core and cloud branches. Only team workspaces, cloud queue, and cloud-specific login are cloud-only.
|
||||
11. **Cloud-only PRs don't belong on core/\* branches** — app mode, cloud auth, and cloud-specific UI changes are irrelevant to local users. Always check PR scope against branch scope before backporting.
|
||||
12. **Never admin-merge without CI** — `--admin` bypasses all branch protections including required status checks. A bulk session of 69 admin-merges shipped 3 test failures. Always wait for CI to pass first, or use `--auto --squash` which waits by design.
|
||||
13. **Accept-theirs regex breaks component rewrites** — when a PR migrates between component libraries (PrimeVue → Reka UI), the regex produces a broken hybrid. Use `git show SHA:path > path` to get the complete correct version instead.
|
||||
14. **Cherry-picks can silently bring in missing-dependency code** — if PR A references a composable introduced by PR B, cherry-picking A succeeds but typecheck fails. Always run typecheck after each wave and add missing files from the merge commit.
|
||||
15. **Fix PRs are expected** — plan for 1 fix PR per branch to resolve typecheck issues from conflict resolutions. This is normal, not a failure.
|
||||
16. **Use `--no-verify` in worktrees** — husky hooks fail in `/tmp/` worktrees. Always push/commit with `--no-verify`.
|
||||
17. **Automation success varies by branch** — core/1.42 got 18/26 auto-PRs (69%), cloud/1.42 got 1/25 (4%). Cloud branches diverge more. Plan for manual fallback.
|
||||
18. **Test-then-resolve pattern** — for branches with low automation success, run a dry-run loop to classify clean vs conflict PRs before processing. This is much faster than resolving conflicts serially.
|
||||
|
||||
## CI Failure Triage
|
||||
|
||||
|
||||
@@ -2,25 +2,26 @@
|
||||
|
||||
## During Execution
|
||||
|
||||
Maintain `execution-log.md` with per-branch tables (this is internal, markdown tables are fine here):
|
||||
Maintain `execution-log.md` with per-branch tables:
|
||||
|
||||
```markdown
|
||||
| PR# | Title | Status | Backport PR | Notes |
|
||||
| ----- | ----- | ------ | ----------- | ------- |
|
||||
| #XXXX | Title | merged | #YYYY | Details |
|
||||
| PR# | Title | CI Status | Status | Backport PR | Notes |
|
||||
| ----- | ----- | ------------------------------ | --------------------------------- | ----------- | ------- |
|
||||
| #XXXX | Title | ✅ Pass / ❌ Fail / ⏳ Pending | ✅ Merged / ⏭️ Skip / ⏸️ Deferred | #YYYY | Details |
|
||||
```
|
||||
|
||||
## Wave Verification Log
|
||||
|
||||
Track verification results per wave within execution-log.md:
|
||||
Track verification results per wave:
|
||||
|
||||
```markdown
|
||||
Wave N Verification -- TARGET_BRANCH
|
||||
## Wave N Verification — TARGET_BRANCH
|
||||
|
||||
- PRs merged: #A, #B, #C
|
||||
- Typecheck: pass / fail
|
||||
- Fix PR: #YYYY (if needed)
|
||||
- Typecheck: ✅ Pass / ❌ Fail
|
||||
- Unit tests: ✅ Pass / ❌ Fail
|
||||
- Issues found: (if any)
|
||||
- Human review needed: (list any non-trivial conflict resolutions)
|
||||
```
|
||||
|
||||
## Session Report Template
|
||||
@@ -62,42 +63,40 @@ Wave N Verification -- TARGET_BRANCH
|
||||
- Feature branches that need tracking for future sessions?
|
||||
```
|
||||
|
||||
## Final Deliverables
|
||||
## Final Deliverable: Visual Summary
|
||||
|
||||
After all branches are complete and verified, generate these files in `~/temp/backport-session/`:
|
||||
At session end, generate a **mermaid diagram** showing all backported PRs organized by target branch and category (MUST/SHOULD), plus a summary table. Present this to the user as the final output.
|
||||
|
||||
### 1. execution-log.md (internal)
|
||||
```mermaid
|
||||
graph TD
|
||||
subgraph branch1["☁️ cloud/X.XX — N PRs"]
|
||||
C1["#XXXX title"]
|
||||
C2["#XXXX title"]
|
||||
end
|
||||
|
||||
Per-branch tables with PR#, title, status, backport PR#, notes. Markdown tables are fine — this is for internal tracking, not Slack.
|
||||
subgraph branch2must["🔴 core/X.XX MUST — N PRs"]
|
||||
M1["#XXXX title"]
|
||||
end
|
||||
|
||||
### 2. backport-author-accountability.md (Slack-compatible)
|
||||
subgraph branch2should["🟡 core/X.XX SHOULD — N PRs"]
|
||||
S1["#XXXX-#XXXX N auto-merged"]
|
||||
S2["#XXXX-#XXXX N manual picks"]
|
||||
end
|
||||
|
||||
See SKILL.md "Final Deliverables" section. Plain text, no emojis/tables/headers/bold. Authors sorted alphabetically with PRs nested under each.
|
||||
classDef cloudStyle fill:#1a3a5c,stroke:#4da6ff,color:#e0f0ff
|
||||
classDef coreStyle fill:#1a4a2e,stroke:#4dff88,color:#e0ffe8
|
||||
classDef mustStyle fill:#5c1a1a,stroke:#ff4d4d,color:#ffe0e0
|
||||
classDef shouldStyle fill:#4a3a1a,stroke:#ffcc4d,color:#fff5e0
|
||||
```
|
||||
|
||||
### 3. slack-status-update.md (Slack-compatible)
|
||||
|
||||
See SKILL.md "Final Deliverables" section. Plain text summary that pastes cleanly into Slack. Includes branch counts, notable fixes, conflict patterns, author count.
|
||||
|
||||
## Slack Formatting Rules
|
||||
|
||||
Both shareable files (author accountability + status update) must follow these rules:
|
||||
|
||||
- No emojis (no checkmarks, no arrows, no icons)
|
||||
- No markdown tables (use plain lists with dashes)
|
||||
- No headers (no # or ##)
|
||||
- No bold (\*_) or italic (_)
|
||||
- No inline code backticks
|
||||
- Use -- instead of em dash
|
||||
- Use plain dashes (-) for lists with 4-space indent for nesting
|
||||
- Line breaks between sections for readability
|
||||
|
||||
These files should paste directly into a Slack message and look clean.
|
||||
Use the `mermaid` tool to render this diagram and present it alongside the summary table as the session's final deliverable.
|
||||
|
||||
## Files to Track
|
||||
|
||||
All in `~/temp/backport-session/`:
|
||||
- `candidate_list.md` — all candidates per branch
|
||||
- `decisions.md` — MUST/SHOULD/SKIP with rationale
|
||||
- `wave-plan.md` — execution order
|
||||
- `execution-log.md` — real-time status
|
||||
- `backport-session-report.md` — final summary
|
||||
|
||||
- `execution-plan.md` -- approved PRs with merge SHAs (input)
|
||||
- `execution-log.md` -- real-time status with per-branch tables (internal)
|
||||
- `backport-author-accountability.md` -- PRs grouped by author (Slack-compatible)
|
||||
- `slack-status-update.md` -- session summary (Slack-compatible)
|
||||
All in `~/temp/backport-session/`.
|
||||
|
||||
107
.github/workflows/api-update-registry-api-types.yaml
vendored
Normal file
107
.github/workflows/api-update-registry-api-types.yaml
vendored
Normal file
@@ -0,0 +1,107 @@
|
||||
# Description: When upstream comfy-api is updated, click dispatch to update the TypeScript type definitions in this repo
|
||||
name: 'Api: Update Registry API Types'
|
||||
|
||||
on:
|
||||
# Manual trigger
|
||||
workflow_dispatch:
|
||||
|
||||
# Triggered from comfy-api repo
|
||||
repository_dispatch:
|
||||
types: [comfy-api-updated]
|
||||
|
||||
jobs:
|
||||
update-registry-types:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Checkout comfy-api repository
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
repository: Comfy-Org/comfy-api
|
||||
path: comfy-api
|
||||
token: ${{ secrets.COMFY_API_PAT }}
|
||||
clean: true
|
||||
|
||||
- name: Get API commit information
|
||||
id: api-info
|
||||
run: |
|
||||
cd comfy-api
|
||||
API_COMMIT=$(git rev-parse --short HEAD)
|
||||
echo "commit=${API_COMMIT}" >> $GITHUB_OUTPUT
|
||||
cd ..
|
||||
|
||||
- name: Generate API types
|
||||
run: |
|
||||
echo "Generating TypeScript types from comfy-api@${{ steps.api-info.outputs.commit }}..."
|
||||
mkdir -p ./packages/registry-types/src
|
||||
pnpm dlx openapi-typescript ./comfy-api/openapi.yml --output ./packages/registry-types/src/comfyRegistryTypes.ts
|
||||
|
||||
- name: Validate generated types
|
||||
run: |
|
||||
if [ ! -f ./packages/registry-types/src/comfyRegistryTypes.ts ]; then
|
||||
echo "Error: Types file was not generated."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if file is not empty
|
||||
if [ ! -s ./packages/registry-types/src/comfyRegistryTypes.ts ]; then
|
||||
echo "Error: Generated types file is empty."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Lint generated types
|
||||
run: |
|
||||
echo "Linting generated Comfy Registry API types..."
|
||||
pnpm lint:fix:no-cache -- ./packages/registry-types/src/comfyRegistryTypes.ts
|
||||
|
||||
- name: Check for changes
|
||||
id: check-changes
|
||||
run: |
|
||||
if [[ -z $(git status --porcelain ./packages/registry-types/src/comfyRegistryTypes.ts) ]]; then
|
||||
echo "No changes to Comfy Registry API types detected."
|
||||
echo "changed=false" >> $GITHUB_OUTPUT
|
||||
exit 0
|
||||
else
|
||||
echo "Changes detected in Comfy Registry API types."
|
||||
echo "changed=true" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Create Pull Request
|
||||
if: steps.check-changes.outputs.changed == 'true'
|
||||
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
|
||||
with:
|
||||
token: ${{ secrets.PR_GH_TOKEN }}
|
||||
commit-message: '[chore] Update Comfy Registry API types from comfy-api@${{ steps.api-info.outputs.commit }}'
|
||||
title: '[chore] Update Comfy Registry API types from comfy-api@${{ steps.api-info.outputs.commit }}'
|
||||
body: |
|
||||
## Automated API Type Update
|
||||
|
||||
This PR updates the Comfy Registry API types from the latest comfy-api OpenAPI specification.
|
||||
|
||||
- API commit: ${{ steps.api-info.outputs.commit }}
|
||||
- Generated on: ${{ github.event.repository.updated_at }}
|
||||
|
||||
These types are automatically generated using openapi-typescript.
|
||||
branch: update-registry-types-${{ steps.api-info.outputs.commit }}
|
||||
base: main
|
||||
labels: CNR
|
||||
delete-branch: true
|
||||
add-paths: |
|
||||
packages/registry-types/src/comfyRegistryTypes.ts
|
||||
33
.github/workflows/ci-website-build.yaml
vendored
33
.github/workflows/ci-website-build.yaml
vendored
@@ -1,33 +0,0 @@
|
||||
# Description: Build and validate the marketing website (apps/website)
|
||||
name: 'CI: Website Build'
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, master, website/*]
|
||||
paths:
|
||||
- 'apps/website/**'
|
||||
- 'packages/design-system/**'
|
||||
- 'pnpm-lock.yaml'
|
||||
pull_request:
|
||||
branches-ignore: [wip/*, draft/*, temp/*]
|
||||
paths:
|
||||
- 'apps/website/**'
|
||||
- 'packages/design-system/**'
|
||||
- 'pnpm-lock.yaml'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Setup frontend
|
||||
uses: ./.github/actions/setup-frontend
|
||||
|
||||
- name: Build website
|
||||
run: pnpm --filter @comfyorg/website build
|
||||
@@ -1,12 +1,11 @@
|
||||
import { defineConfig } from 'astro/config'
|
||||
import sitemap from '@astrojs/sitemap'
|
||||
import vue from '@astrojs/vue'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
|
||||
export default defineConfig({
|
||||
site: 'https://comfy.org',
|
||||
output: 'static',
|
||||
integrations: [vue(), sitemap()],
|
||||
integrations: [vue()],
|
||||
vite: {
|
||||
plugins: [tailwindcss()]
|
||||
},
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
"preview": "astro preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/sitemap": "catalog:",
|
||||
"@comfyorg/design-system": "workspace:*",
|
||||
"@vercel/analytics": "catalog:",
|
||||
"vue": "catalog:"
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
User-agent: *
|
||||
Allow: /
|
||||
|
||||
Sitemap: https://comfy.org/sitemap-index.xml
|
||||
@@ -96,10 +96,6 @@ const socials = [
|
||||
v-for="link in column.links"
|
||||
:key="link.href"
|
||||
:href="link.href"
|
||||
:target="link.href.startsWith('http') ? '_blank' : undefined"
|
||||
:rel="
|
||||
link.href.startsWith('http') ? 'noopener noreferrer' : undefined
|
||||
"
|
||||
class="text-sm text-smoke-700 transition-colors hover:text-white"
|
||||
>
|
||||
{{ link.label }}
|
||||
|
||||
@@ -7,14 +7,12 @@ interface Props {
|
||||
title: string
|
||||
description?: string
|
||||
ogImage?: string
|
||||
noindex?: boolean
|
||||
}
|
||||
|
||||
const {
|
||||
title,
|
||||
description = 'Comfy is the AI creation engine for visual professionals who demand control.',
|
||||
ogImage = '/og-default.png',
|
||||
noindex = false,
|
||||
} = Astro.props
|
||||
|
||||
const siteBase = Astro.site ?? 'https://comfy.org'
|
||||
@@ -23,29 +21,6 @@ const ogImageURL = new URL(ogImage, siteBase)
|
||||
const locale = Astro.currentLocale ?? 'en'
|
||||
const gtmId = 'GTM-NP9JM6K7'
|
||||
const gtmEnabled = import.meta.env.PROD
|
||||
|
||||
const organizationJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Organization',
|
||||
name: 'Comfy Org',
|
||||
url: 'https://comfy.org',
|
||||
logo: 'https://comfy.org/favicon.svg',
|
||||
sameAs: [
|
||||
'https://github.com/comfyanonymous/ComfyUI',
|
||||
'https://discord.gg/comfyorg',
|
||||
'https://x.com/comaboratory',
|
||||
'https://reddit.com/r/comfyui',
|
||||
'https://linkedin.com/company/comfyorg',
|
||||
'https://instagram.com/comfyorg',
|
||||
],
|
||||
}
|
||||
|
||||
const websiteJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'WebSite',
|
||||
name: 'Comfy',
|
||||
url: 'https://comfy.org',
|
||||
}
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
@@ -54,36 +29,26 @@ const websiteJsonLd = {
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="description" content={description} />
|
||||
{noindex && <meta name="robots" content="noindex, nofollow" />}
|
||||
<title>{title}</title>
|
||||
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
<link rel="canonical" href={canonicalURL.href} />
|
||||
<link rel="preconnect" href="https://www.googletagmanager.com" />
|
||||
<link rel="dns-prefetch" href="https://www.googletagmanager.com" />
|
||||
|
||||
<!-- Open Graph -->
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:title" content={title} />
|
||||
<meta property="og:description" content={description} />
|
||||
<meta property="og:image" content={ogImageURL.href} />
|
||||
<meta property="og:image:width" content="1200" />
|
||||
<meta property="og:image:height" content="630" />
|
||||
<meta property="og:url" content={canonicalURL.href} />
|
||||
<meta property="og:locale" content={locale} />
|
||||
<meta property="og:site_name" content="Comfy" />
|
||||
|
||||
<!-- Twitter -->
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:site" content="@comaboratory" />
|
||||
<meta name="twitter:title" content={title} />
|
||||
<meta name="twitter:description" content={description} />
|
||||
<meta name="twitter:image" content={ogImageURL.href} />
|
||||
|
||||
<!-- Structured Data -->
|
||||
<script type="application/ld+json" set:html={JSON.stringify(organizationJsonLd)} />
|
||||
<script type="application/ld+json" set:html={JSON.stringify(websiteJsonLd)} />
|
||||
|
||||
<!-- Google Tag Manager -->
|
||||
{gtmEnabled && (
|
||||
<script is:inline define:vars={{ gtmId }}>
|
||||
|
||||
@@ -7,7 +7,6 @@ import SiteFooter from '../components/SiteFooter.vue'
|
||||
<BaseLayout
|
||||
title="Privacy Policy — Comfy"
|
||||
description="Comfy privacy policy. Learn how we collect, use, and protect your personal information."
|
||||
noindex
|
||||
>
|
||||
<SiteNav client:load />
|
||||
<main class="mx-auto max-w-3xl px-6 py-24">
|
||||
|
||||
@@ -7,7 +7,6 @@ import SiteFooter from '../components/SiteFooter.vue'
|
||||
<BaseLayout
|
||||
title="Terms of Service — Comfy"
|
||||
description="Terms of Service for ComfyUI and related Comfy services."
|
||||
noindex
|
||||
>
|
||||
<SiteNav client:load />
|
||||
<main class="mx-auto max-w-3xl px-6 py-24 sm:py-32">
|
||||
|
||||
@@ -7,7 +7,6 @@ import SiteFooter from '../../components/SiteFooter.vue'
|
||||
<BaseLayout
|
||||
title="隐私政策 — Comfy"
|
||||
description="Comfy 隐私政策。了解我们如何收集、使用和保护您的个人信息。"
|
||||
noindex
|
||||
>
|
||||
<SiteNav client:load />
|
||||
<main class="mx-auto max-w-3xl px-6 py-24">
|
||||
|
||||
@@ -7,7 +7,6 @@ import SiteFooter from '../../components/SiteFooter.vue'
|
||||
<BaseLayout
|
||||
title="服务条款 — Comfy"
|
||||
description="ComfyUI 及相关 Comfy 服务的服务条款。"
|
||||
noindex
|
||||
>
|
||||
<SiteNav client:load />
|
||||
<main class="mx-auto max-w-3xl px-6 py-24 sm:py-32">
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
{
|
||||
"$schema": "https://openapi.vercel.sh/vercel.json",
|
||||
"buildCommand": "pnpm --filter @comfyorg/website build",
|
||||
"outputDirectory": "apps/website/dist",
|
||||
"installCommand": "pnpm install --frozen-lockfile",
|
||||
"framework": null,
|
||||
"redirects": [
|
||||
{
|
||||
"source": "/pricing",
|
||||
"destination": "/cloud/pricing",
|
||||
"permanent": true
|
||||
},
|
||||
{
|
||||
"source": "/enterprise",
|
||||
"destination": "/cloud/enterprise",
|
||||
"permanent": true
|
||||
},
|
||||
{
|
||||
"source": "/blog",
|
||||
"destination": "https://blog.comfy.org/",
|
||||
"permanent": true
|
||||
},
|
||||
{ "source": "/contact", "destination": "/about", "permanent": true },
|
||||
{ "source": "/press", "destination": "/about", "permanent": true }
|
||||
]
|
||||
}
|
||||
@@ -1,14 +1,11 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
export class ComfyNodeSearchFilterSelectionPanel {
|
||||
readonly root: Locator
|
||||
|
||||
constructor(public readonly page: Page) {
|
||||
this.root = page.getByRole('dialog')
|
||||
}
|
||||
constructor(public readonly page: Page) {}
|
||||
|
||||
get header() {
|
||||
return this.root
|
||||
return this.page
|
||||
.getByRole('dialog')
|
||||
.locator('div')
|
||||
.filter({ hasText: 'Add node filter condition' })
|
||||
}
|
||||
|
||||
@@ -83,14 +83,6 @@ export class AppModeHelper {
|
||||
return this.page.locator('[data-testid="linear-widgets"]')
|
||||
}
|
||||
|
||||
/** The PrimeVue Popover for the image picker (renders with role="dialog"). */
|
||||
get imagePickerPopover(): Locator {
|
||||
return this.page
|
||||
.getByRole('dialog')
|
||||
.filter({ has: this.page.getByRole('button', { name: 'All' }) })
|
||||
.first()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the actions menu trigger for a widget in the app mode widget list.
|
||||
* @param widgetName Text shown in the widget label (e.g. "seed").
|
||||
|
||||
@@ -484,16 +484,7 @@ export class SubgraphHelper {
|
||||
await this.comfyPage.vueNodes.enterSubgraph(hostNodeId)
|
||||
await this.comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', false)
|
||||
await this.comfyPage.nextFrame()
|
||||
await this.comfyPage.canvas.dispatchEvent('pointerdown', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
button: 0
|
||||
})
|
||||
await this.comfyPage.canvas.dispatchEvent('pointerup', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
button: 0
|
||||
})
|
||||
await this.comfyPage.canvas.click()
|
||||
await this.comfyPage.canvas.press('Control+a')
|
||||
await this.comfyPage.nextFrame()
|
||||
await this.page.evaluate(() => {
|
||||
@@ -502,16 +493,7 @@ export class SubgraphHelper {
|
||||
})
|
||||
await this.comfyPage.nextFrame()
|
||||
await this.exitViaBreadcrumb()
|
||||
await this.comfyPage.canvas.dispatchEvent('pointerdown', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
button: 0
|
||||
})
|
||||
await this.comfyPage.canvas.dispatchEvent('pointerup', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
button: 0
|
||||
})
|
||||
await this.comfyPage.canvas.click()
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
|
||||
@@ -58,16 +58,6 @@ export class WorkflowHelper {
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
async waitForDraftPersisted({ timeout = 5000 } = {}) {
|
||||
await this.comfyPage.page.waitForFunction(
|
||||
() =>
|
||||
Object.keys(localStorage).some((k) =>
|
||||
k.startsWith('Comfy.Workflow.Draft.v2:')
|
||||
),
|
||||
{ timeout }
|
||||
)
|
||||
}
|
||||
|
||||
async loadWorkflow(workflowName: string) {
|
||||
await this.comfyPage.workflowUploadInput.setInputFiles(
|
||||
assetPath(`${workflowName}.json`)
|
||||
|
||||
@@ -145,7 +145,10 @@ test.describe('App mode dropdown clipping', { tag: '@ui' }, () => {
|
||||
|
||||
// The unstyled PrimeVue Popover renders with role="dialog".
|
||||
// Locate the one containing the image grid (filter buttons like "All", "Inputs").
|
||||
const popover = comfyPage.appMode.imagePickerPopover
|
||||
const popover = comfyPage.page
|
||||
.getByRole('dialog')
|
||||
.filter({ has: comfyPage.page.getByRole('button', { name: 'All' }) })
|
||||
.first()
|
||||
await expect(popover).toBeVisible({ timeout: 5000 })
|
||||
|
||||
const isInViewport = await popover.evaluate((el) => {
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
|
||||
|
||||
test.describe(
|
||||
'Subgraph node positions after draft reload',
|
||||
{ tag: ['@subgraph'] },
|
||||
() => {
|
||||
test('Node positions are preserved after draft reload with subgraph auto-entry', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
test.setTimeout(30000)
|
||||
|
||||
// Enable workflow persistence explicitly
|
||||
await comfyPage.settings.setSetting('Comfy.Workflow.Persist', true)
|
||||
|
||||
// Load a workflow containing a subgraph
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
|
||||
// Enter the subgraph programmatically (fixture node is too small for UI click)
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const sg = [...window.app!.rootGraph.subgraphs.values()][0]
|
||||
if (sg) window.app!.canvas.setGraph(sg)
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
|
||||
|
||||
const positionsBefore = await comfyPage.page.evaluate(() => {
|
||||
const sg = [...window.app!.rootGraph.subgraphs.values()][0]
|
||||
return sg.nodes.map((n) => ({
|
||||
id: n.id,
|
||||
x: n.pos[0],
|
||||
y: n.pos[1]
|
||||
}))
|
||||
})
|
||||
|
||||
expect(positionsBefore.length).toBeGreaterThan(0)
|
||||
|
||||
// Wait for the debounced draft persistence to flush to localStorage
|
||||
await comfyPage.workflow.waitForDraftPersisted()
|
||||
|
||||
// Reload the page (draft auto-loads with hash preserved)
|
||||
await comfyPage.page.reload({ waitUntil: 'networkidle' })
|
||||
await comfyPage.page.waitForFunction(
|
||||
() => window.app && window.app.extensionManager
|
||||
)
|
||||
await comfyPage.page.waitForSelector('.p-blockui-mask', {
|
||||
state: 'hidden'
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Wait for subgraph auto-entry via hash navigation
|
||||
await expect
|
||||
.poll(() => comfyPage.subgraph.isInSubgraph(), { timeout: 10000 })
|
||||
.toBe(true)
|
||||
|
||||
// Verify all internal node positions are preserved
|
||||
const positionsAfter = await comfyPage.page.evaluate(() => {
|
||||
const sg = [...window.app!.rootGraph.subgraphs.values()][0]
|
||||
return sg.nodes.map((n) => ({
|
||||
id: n.id,
|
||||
x: n.pos[0],
|
||||
y: n.pos[1]
|
||||
}))
|
||||
})
|
||||
|
||||
for (const before of positionsBefore) {
|
||||
const after = positionsAfter.find((n) => n.id === before.id)
|
||||
expect(
|
||||
after,
|
||||
`Node ${before.id} should exist after reload`
|
||||
).toBeDefined()
|
||||
expect(after!.x).toBeCloseTo(before.x, 0)
|
||||
expect(after!.y).toBeCloseTo(before.y, 0)
|
||||
}
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -196,56 +196,6 @@ body: JSON.stringify([mockRelease])
|
||||
body: JSON.stringify([{ id: 1, project: 'comfyui', version: 'v0.3.44', ... }])
|
||||
```
|
||||
|
||||
## When to Use `page.evaluate`
|
||||
|
||||
### Acceptable (use sparingly)
|
||||
|
||||
Reading internal state that has no UI representation (prefer locator
|
||||
assertions whenever possible):
|
||||
|
||||
```typescript
|
||||
// Reading graph/store values
|
||||
const nodeCount = await page.evaluate(() => window.app!.graph!.nodes.length)
|
||||
const linkSlot = await page.evaluate(() => window.app!.graph!.links.get(1)?.target_slot)
|
||||
|
||||
// Reading computed properties or store state
|
||||
await page.evaluate(() => useWorkflowStore().activeWorkflow)
|
||||
|
||||
// Setting up test fixtures (registering extensions, mock error handlers)
|
||||
await page.evaluate(() => {
|
||||
window.app!.registerExtension({ name: 'TestExt', settings: [...] })
|
||||
})
|
||||
```
|
||||
|
||||
### Avoid
|
||||
|
||||
Performing actions that have a UI equivalent — use Playwright locators and user
|
||||
interactions instead:
|
||||
|
||||
```typescript
|
||||
// Bad: setting a widget value programmatically
|
||||
await page.evaluate(() => { node.widgets![0].value = 512 })
|
||||
// Good: click the widget and type the value
|
||||
await widgetLocator.click()
|
||||
await widgetLocator.fill('512')
|
||||
|
||||
// Bad: dispatching synthetic DOM events
|
||||
await page.evaluate(() => { btn.dispatchEvent(new MouseEvent('click', ...)) })
|
||||
// Good: use Playwright's click
|
||||
await page.getByTestId('more-options-button').click()
|
||||
|
||||
// Bad: calling store actions that correspond to user interactions
|
||||
await page.evaluate(() => { app.queuePrompt(0) })
|
||||
// Good: click the Queue button
|
||||
await page.getByRole('button', { name: 'Queue' }).click()
|
||||
```
|
||||
|
||||
### Preferred
|
||||
|
||||
Use helper methods from `browser_tests/fixtures/helpers/` that wrap real user
|
||||
interactions (e.g., `comfyPage.settings.setSetting`, `comfyPage.nodeOps`,
|
||||
`comfyPage.workflow.loadWorkflow`).
|
||||
|
||||
## Running Tests
|
||||
|
||||
```bash
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.43.14",
|
||||
"version": "1.43.13",
|
||||
"private": true,
|
||||
"description": "Official front-end implementation of ComfyUI",
|
||||
"homepage": "https://comfy.org",
|
||||
|
||||
44
pnpm-lock.yaml
generated
44
pnpm-lock.yaml
generated
@@ -9,9 +9,6 @@ catalogs:
|
||||
'@alloc/quick-lru':
|
||||
specifier: ^5.2.0
|
||||
version: 5.2.0
|
||||
'@astrojs/sitemap':
|
||||
specifier: ^3.7.1
|
||||
version: 3.7.1
|
||||
'@astrojs/vue':
|
||||
specifier: ^5.0.0
|
||||
version: 5.1.4
|
||||
@@ -913,9 +910,6 @@ importers:
|
||||
|
||||
apps/website:
|
||||
dependencies:
|
||||
'@astrojs/sitemap':
|
||||
specifier: 'catalog:'
|
||||
version: 3.7.1
|
||||
'@comfyorg/design-system':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/design-system
|
||||
@@ -1101,9 +1095,6 @@ packages:
|
||||
resolution: {integrity: sha512-q8VwfU/fDZNoDOf+r7jUnMC2//H2l0TuQ6FkGJL8vD8nw/q5KiL3DS1KKBI3QhI9UQhpJ5dc7AtqfbXWuOgLCQ==}
|
||||
engines: {node: 18.20.8 || ^20.3.0 || >=22.0.0}
|
||||
|
||||
'@astrojs/sitemap@3.7.1':
|
||||
resolution: {integrity: sha512-IzQqdTeskaMX+QDZCzMuJIp8A8C1vgzMBp/NmHNnadepHYNHcxQdGLQZYfkbd2EbRXUfOS+UDIKx8sKg0oWVdw==}
|
||||
|
||||
'@astrojs/telemetry@3.3.0':
|
||||
resolution: {integrity: sha512-UFBgfeldP06qu6khs/yY+q1cDAaArM2/7AEIqQ9Cuvf7B1hNLq0xDrZkct+QoIGyjq56y8IaE2I3CTvG99mlhQ==}
|
||||
engines: {node: 18.20.8 || ^20.3.0 || >=22.0.0}
|
||||
@@ -4403,9 +4394,6 @@ packages:
|
||||
'@types/react@19.1.9':
|
||||
resolution: {integrity: sha512-WmdoynAX8Stew/36uTSVMcLJJ1KRh6L3IZRx1PZ7qJtBqT3dYTgyDTx8H1qoRghErydW7xw9mSJ3wS//tCRpFA==}
|
||||
|
||||
'@types/sax@1.2.7':
|
||||
resolution: {integrity: sha512-rO73L89PJxeYM3s3pPPjiPgVVcymqU490g0YO5n5By0k2Erzj6tay/4lr1CHAAU4JyOWd1rpQ8bCf6cZfHU96A==}
|
||||
|
||||
'@types/semver@7.7.0':
|
||||
resolution: {integrity: sha512-k107IF4+Xr7UHjwDc7Cfd6PRQfbdkiRabXGRjo07b4WyPahFBZCZ1sE+BNxYIJPPg73UkfOsVOLwqVc/6ETrIA==}
|
||||
|
||||
@@ -5103,9 +5091,6 @@ packages:
|
||||
resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==}
|
||||
engines: {node: '>= 8'}
|
||||
|
||||
arg@5.0.2:
|
||||
resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==}
|
||||
|
||||
argparse@1.0.10:
|
||||
resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==}
|
||||
|
||||
@@ -8713,11 +8698,6 @@ packages:
|
||||
sisteransi@1.0.5:
|
||||
resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==}
|
||||
|
||||
sitemap@9.0.1:
|
||||
resolution: {integrity: sha512-S6hzjGJSG3d6if0YoF5kTyeRJvia6FSTBroE5fQ0bu1QNxyJqhhinfUsXi9fH3MgtXODWvwo2BDyQSnhPQ88uQ==}
|
||||
engines: {node: '>=20.19.5', npm: '>=10.8.2'}
|
||||
hasBin: true
|
||||
|
||||
slash@3.0.0:
|
||||
resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -8799,9 +8779,6 @@ packages:
|
||||
prettier:
|
||||
optional: true
|
||||
|
||||
stream-replace-string@2.0.0:
|
||||
resolution: {integrity: sha512-TlnjJ1C0QrmxRNrON00JvaFFlNh5TTG00APw23j74ET7gkQpTASi6/L2fuiav8pzK715HXtUeClpBTw2NPSn6w==}
|
||||
|
||||
string-argv@0.3.2:
|
||||
resolution: {integrity: sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==}
|
||||
engines: {node: '>=0.6.19'}
|
||||
@@ -10113,12 +10090,6 @@ snapshots:
|
||||
dependencies:
|
||||
prismjs: 1.30.0
|
||||
|
||||
'@astrojs/sitemap@3.7.1':
|
||||
dependencies:
|
||||
sitemap: 9.0.1
|
||||
stream-replace-string: 2.0.0
|
||||
zod: 4.3.6
|
||||
|
||||
'@astrojs/telemetry@3.3.0':
|
||||
dependencies:
|
||||
ci-info: 4.4.0
|
||||
@@ -13471,10 +13442,6 @@ snapshots:
|
||||
dependencies:
|
||||
csstype: 3.2.3
|
||||
|
||||
'@types/sax@1.2.7':
|
||||
dependencies:
|
||||
'@types/node': 25.0.3
|
||||
|
||||
'@types/semver@7.7.0': {}
|
||||
|
||||
'@types/stats.js@0.17.3': {}
|
||||
@@ -14281,8 +14248,6 @@ snapshots:
|
||||
normalize-path: 3.0.0
|
||||
picomatch: 2.3.1
|
||||
|
||||
arg@5.0.2: {}
|
||||
|
||||
argparse@1.0.10:
|
||||
dependencies:
|
||||
sprintf-js: 1.0.3
|
||||
@@ -18849,13 +18814,6 @@ snapshots:
|
||||
|
||||
sisteransi@1.0.5: {}
|
||||
|
||||
sitemap@9.0.1:
|
||||
dependencies:
|
||||
'@types/node': 24.10.4
|
||||
'@types/sax': 1.2.7
|
||||
arg: 5.0.2
|
||||
sax: 1.4.4
|
||||
|
||||
slash@3.0.0: {}
|
||||
|
||||
slice-ansi@4.0.0:
|
||||
@@ -18944,8 +18902,6 @@ snapshots:
|
||||
- react-dom
|
||||
- utf-8-validate
|
||||
|
||||
stream-replace-string@2.0.0: {}
|
||||
|
||||
string-argv@0.3.2: {}
|
||||
|
||||
string-width@4.2.3:
|
||||
|
||||
@@ -4,7 +4,6 @@ packages:
|
||||
|
||||
catalog:
|
||||
'@alloc/quick-lru': ^5.2.0
|
||||
'@astrojs/sitemap': ^3.7.1
|
||||
'@astrojs/vue': ^5.0.0
|
||||
'@comfyorg/comfyui-electron-types': 0.6.2
|
||||
'@eslint/js': ^9.39.1
|
||||
|
||||
@@ -5006,36 +5006,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"ImageHistogram": {
|
||||
"display_name": "مخطط تكراري للصورة",
|
||||
"inputs": {
|
||||
"image": {
|
||||
"name": "صورة"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "RGB",
|
||||
"tooltip": null
|
||||
},
|
||||
"1": {
|
||||
"name": "الإضاءة",
|
||||
"tooltip": null
|
||||
},
|
||||
"2": {
|
||||
"name": "أحمر",
|
||||
"tooltip": null
|
||||
},
|
||||
"3": {
|
||||
"name": "أخضر",
|
||||
"tooltip": null
|
||||
},
|
||||
"4": {
|
||||
"name": "أزرق",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"ImageInvert": {
|
||||
"display_name": "عكس الصورة",
|
||||
"inputs": {
|
||||
|
||||
@@ -5006,36 +5006,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"ImageHistogram": {
|
||||
"display_name": "Image Histogram",
|
||||
"inputs": {
|
||||
"image": {
|
||||
"name": "image"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "rgb",
|
||||
"tooltip": null
|
||||
},
|
||||
"1": {
|
||||
"name": "luminance",
|
||||
"tooltip": null
|
||||
},
|
||||
"2": {
|
||||
"name": "red",
|
||||
"tooltip": null
|
||||
},
|
||||
"3": {
|
||||
"name": "green",
|
||||
"tooltip": null
|
||||
},
|
||||
"4": {
|
||||
"name": "blue",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"ImageInvert": {
|
||||
"display_name": "Invert Image",
|
||||
"inputs": {
|
||||
|
||||
@@ -5006,36 +5006,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"ImageHistogram": {
|
||||
"display_name": "Histograma de imagen",
|
||||
"inputs": {
|
||||
"image": {
|
||||
"name": "imagen"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "rgb",
|
||||
"tooltip": null
|
||||
},
|
||||
"1": {
|
||||
"name": "luminancia",
|
||||
"tooltip": null
|
||||
},
|
||||
"2": {
|
||||
"name": "rojo",
|
||||
"tooltip": null
|
||||
},
|
||||
"3": {
|
||||
"name": "verde",
|
||||
"tooltip": null
|
||||
},
|
||||
"4": {
|
||||
"name": "azul",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"ImageInvert": {
|
||||
"display_name": "Invertir Imagen",
|
||||
"inputs": {
|
||||
|
||||
@@ -5006,36 +5006,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"ImageHistogram": {
|
||||
"display_name": "هیستوگرام تصویر",
|
||||
"inputs": {
|
||||
"image": {
|
||||
"name": "تصویر"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "RGB",
|
||||
"tooltip": null
|
||||
},
|
||||
"1": {
|
||||
"name": "درخشندگی",
|
||||
"tooltip": null
|
||||
},
|
||||
"2": {
|
||||
"name": "قرمز",
|
||||
"tooltip": null
|
||||
},
|
||||
"3": {
|
||||
"name": "سبز",
|
||||
"tooltip": null
|
||||
},
|
||||
"4": {
|
||||
"name": "آبی",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"ImageInvert": {
|
||||
"display_name": "معکوسسازی تصویر",
|
||||
"inputs": {
|
||||
|
||||
@@ -5006,36 +5006,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"ImageHistogram": {
|
||||
"display_name": "Histogramme d'image",
|
||||
"inputs": {
|
||||
"image": {
|
||||
"name": "image"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "rvb",
|
||||
"tooltip": null
|
||||
},
|
||||
"1": {
|
||||
"name": "luminance",
|
||||
"tooltip": null
|
||||
},
|
||||
"2": {
|
||||
"name": "rouge",
|
||||
"tooltip": null
|
||||
},
|
||||
"3": {
|
||||
"name": "vert",
|
||||
"tooltip": null
|
||||
},
|
||||
"4": {
|
||||
"name": "bleu",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"ImageInvert": {
|
||||
"display_name": "Inverser l'image",
|
||||
"inputs": {
|
||||
|
||||
@@ -5006,36 +5006,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"ImageHistogram": {
|
||||
"display_name": "画像ヒストグラム",
|
||||
"inputs": {
|
||||
"image": {
|
||||
"name": "画像"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "RGB",
|
||||
"tooltip": null
|
||||
},
|
||||
"1": {
|
||||
"name": "輝度",
|
||||
"tooltip": null
|
||||
},
|
||||
"2": {
|
||||
"name": "赤",
|
||||
"tooltip": null
|
||||
},
|
||||
"3": {
|
||||
"name": "緑",
|
||||
"tooltip": null
|
||||
},
|
||||
"4": {
|
||||
"name": "青",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"ImageInvert": {
|
||||
"display_name": "画像を反転",
|
||||
"inputs": {
|
||||
|
||||
@@ -5006,36 +5006,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"ImageHistogram": {
|
||||
"display_name": "이미지 히스토그램",
|
||||
"inputs": {
|
||||
"image": {
|
||||
"name": "이미지"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "RGB",
|
||||
"tooltip": null
|
||||
},
|
||||
"1": {
|
||||
"name": "휘도",
|
||||
"tooltip": null
|
||||
},
|
||||
"2": {
|
||||
"name": "레드",
|
||||
"tooltip": null
|
||||
},
|
||||
"3": {
|
||||
"name": "그린",
|
||||
"tooltip": null
|
||||
},
|
||||
"4": {
|
||||
"name": "블루",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"ImageInvert": {
|
||||
"display_name": "이미지 반전",
|
||||
"inputs": {
|
||||
|
||||
@@ -5006,36 +5006,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"ImageHistogram": {
|
||||
"display_name": "Histograma de Imagem",
|
||||
"inputs": {
|
||||
"image": {
|
||||
"name": "imagem"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "rgb",
|
||||
"tooltip": null
|
||||
},
|
||||
"1": {
|
||||
"name": "luminância",
|
||||
"tooltip": null
|
||||
},
|
||||
"2": {
|
||||
"name": "vermelho",
|
||||
"tooltip": null
|
||||
},
|
||||
"3": {
|
||||
"name": "verde",
|
||||
"tooltip": null
|
||||
},
|
||||
"4": {
|
||||
"name": "azul",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"ImageInvert": {
|
||||
"display_name": "Inverter Imagem",
|
||||
"inputs": {
|
||||
|
||||
@@ -5006,36 +5006,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"ImageHistogram": {
|
||||
"display_name": "Гистограмма изображения",
|
||||
"inputs": {
|
||||
"image": {
|
||||
"name": "изображение"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "rgb",
|
||||
"tooltip": null
|
||||
},
|
||||
"1": {
|
||||
"name": "яркость",
|
||||
"tooltip": null
|
||||
},
|
||||
"2": {
|
||||
"name": "красный",
|
||||
"tooltip": null
|
||||
},
|
||||
"3": {
|
||||
"name": "зелёный",
|
||||
"tooltip": null
|
||||
},
|
||||
"4": {
|
||||
"name": "синий",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"ImageInvert": {
|
||||
"display_name": "Инвертировать изображение",
|
||||
"inputs": {
|
||||
|
||||
@@ -5006,36 +5006,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"ImageHistogram": {
|
||||
"display_name": "Görüntü Histogramı",
|
||||
"inputs": {
|
||||
"image": {
|
||||
"name": "görüntü"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "rgb",
|
||||
"tooltip": null
|
||||
},
|
||||
"1": {
|
||||
"name": "parlaklık",
|
||||
"tooltip": null
|
||||
},
|
||||
"2": {
|
||||
"name": "kırmızı",
|
||||
"tooltip": null
|
||||
},
|
||||
"3": {
|
||||
"name": "yeşil",
|
||||
"tooltip": null
|
||||
},
|
||||
"4": {
|
||||
"name": "mavi",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"ImageInvert": {
|
||||
"display_name": "Görüntüyü Ters Çevir",
|
||||
"inputs": {
|
||||
|
||||
@@ -5006,36 +5006,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"ImageHistogram": {
|
||||
"display_name": "影像直方圖",
|
||||
"inputs": {
|
||||
"image": {
|
||||
"name": "影像"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "RGB",
|
||||
"tooltip": null
|
||||
},
|
||||
"1": {
|
||||
"name": "亮度",
|
||||
"tooltip": null
|
||||
},
|
||||
"2": {
|
||||
"name": "紅色",
|
||||
"tooltip": null
|
||||
},
|
||||
"3": {
|
||||
"name": "綠色",
|
||||
"tooltip": null
|
||||
},
|
||||
"4": {
|
||||
"name": "藍色",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"ImageInvert": {
|
||||
"display_name": "反轉影像",
|
||||
"inputs": {
|
||||
|
||||
@@ -5006,36 +5006,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"ImageHistogram": {
|
||||
"display_name": "图像直方图",
|
||||
"inputs": {
|
||||
"image": {
|
||||
"name": "图像"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "RGB",
|
||||
"tooltip": null
|
||||
},
|
||||
"1": {
|
||||
"name": "亮度",
|
||||
"tooltip": null
|
||||
},
|
||||
"2": {
|
||||
"name": "红色",
|
||||
"tooltip": null
|
||||
},
|
||||
"3": {
|
||||
"name": "绿色",
|
||||
"tooltip": null
|
||||
},
|
||||
"4": {
|
||||
"name": "蓝色",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"ImageInvert": {
|
||||
"display_name": "反转图像",
|
||||
"inputs": {
|
||||
|
||||
@@ -61,15 +61,6 @@ describe('ModelInfoPanel', () => {
|
||||
expect(wrapper.text()).toContain('my-model.safetensors')
|
||||
})
|
||||
|
||||
it('prefers user_metadata.filename over asset.name for filename field', () => {
|
||||
const asset = createMockAsset({
|
||||
name: 'registry-display-name',
|
||||
user_metadata: { filename: 'checkpoints/real-file.safetensors' }
|
||||
})
|
||||
const wrapper = mountPanel(asset)
|
||||
expect(wrapper.text()).toContain('checkpoints/real-file.safetensors')
|
||||
})
|
||||
|
||||
it('displays name from user_metadata when present', () => {
|
||||
const asset = createMockAsset({
|
||||
user_metadata: { name: 'My Custom Model' }
|
||||
|
||||
@@ -32,9 +32,7 @@
|
||||
</div>
|
||||
</ModelInfoField>
|
||||
<ModelInfoField :label="t('assetBrowser.modelInfo.fileName')">
|
||||
<span class="break-all text-muted-foreground">{{
|
||||
getAssetFilename(asset)
|
||||
}}</span>
|
||||
<span class="break-all text-muted-foreground">{{ asset.name }}</span>
|
||||
</ModelInfoField>
|
||||
<ModelInfoField
|
||||
v-if="sourceUrl"
|
||||
@@ -234,7 +232,6 @@ import {
|
||||
getAssetBaseModels,
|
||||
getAssetDescription,
|
||||
getAssetDisplayName,
|
||||
getAssetFilename,
|
||||
getAssetModelType,
|
||||
getAssetSourceUrl,
|
||||
getAssetTriggerPhrases,
|
||||
|
||||
@@ -230,7 +230,7 @@ describe('GtmTelemetryProvider', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('pushes normalized email inside the auth event payload', () => {
|
||||
it('pushes normalized email as user_data before auth event', () => {
|
||||
const provider = createInitializedProvider()
|
||||
|
||||
provider.trackAuth({
|
||||
@@ -241,21 +241,20 @@ describe('GtmTelemetryProvider', () => {
|
||||
})
|
||||
|
||||
const dl = window.dataLayer as Record<string, unknown>[]
|
||||
const authEvent = dl.find((entry) => entry.event === 'sign_up')
|
||||
expect(authEvent).toMatchObject({
|
||||
event: 'sign_up',
|
||||
method: 'email',
|
||||
user_id: 'uid-123',
|
||||
user_data: {
|
||||
email: 'test@example.com'
|
||||
}
|
||||
const userData = dl.find((entry) => 'user_data' in entry)
|
||||
expect(userData).toMatchObject({
|
||||
user_data: { email: 'test@example.com' }
|
||||
})
|
||||
expect(
|
||||
dl.some((entry) => 'user_data' in entry && !('event' in entry))
|
||||
).toBe(false)
|
||||
|
||||
// Verify user_data is pushed before the sign_up event
|
||||
const userDataIndex = dl.findIndex((entry) => 'user_data' in entry)
|
||||
const signUpIndex = dl.findIndex(
|
||||
(entry) => (entry as Record<string, unknown>).event === 'sign_up'
|
||||
)
|
||||
expect(userDataIndex).toBeLessThan(signUpIndex)
|
||||
})
|
||||
|
||||
it('omits user_data when email is absent', () => {
|
||||
it('does not push user_data when email is absent', () => {
|
||||
const provider = createInitializedProvider()
|
||||
|
||||
provider.trackAuth({
|
||||
@@ -264,12 +263,9 @@ describe('GtmTelemetryProvider', () => {
|
||||
user_id: 'uid-456'
|
||||
})
|
||||
|
||||
expect(lastDataLayerEntry()).toMatchObject({
|
||||
event: 'login',
|
||||
method: 'google',
|
||||
user_id: 'uid-456'
|
||||
})
|
||||
expect(lastDataLayerEntry()).not.toHaveProperty('user_data')
|
||||
const dl = window.dataLayer as Record<string, unknown>[]
|
||||
const userData = dl.find((entry) => 'user_data' in entry)
|
||||
expect(userData).toBeUndefined()
|
||||
})
|
||||
|
||||
it('does not push events when not initialized', () => {
|
||||
|
||||
@@ -135,19 +135,23 @@ export class GtmTelemetryProvider implements TelemetryProvider {
|
||||
}
|
||||
|
||||
trackAuth(metadata: AuthMetadata): void {
|
||||
const payload = {
|
||||
const basePayload = {
|
||||
method: metadata.method,
|
||||
...(metadata.user_id ? { user_id: metadata.user_id } : {}),
|
||||
...(metadata.email
|
||||
? {
|
||||
user_data: {
|
||||
email: metadata.email.trim().toLowerCase()
|
||||
}
|
||||
}
|
||||
: {})
|
||||
...(metadata.user_id ? { user_id: metadata.user_id } : {})
|
||||
}
|
||||
|
||||
this.pushEvent(metadata.is_new_user ? 'sign_up' : 'login', payload)
|
||||
if (metadata.email) {
|
||||
window.dataLayer?.push({
|
||||
user_data: { email: metadata.email.trim().toLowerCase() }
|
||||
})
|
||||
}
|
||||
|
||||
if (metadata.is_new_user) {
|
||||
this.pushEvent('sign_up', basePayload)
|
||||
return
|
||||
}
|
||||
|
||||
this.pushEvent('login', basePayload)
|
||||
}
|
||||
|
||||
trackBeginCheckout(metadata: BeginCheckoutMetadata): void {
|
||||
|
||||
@@ -343,10 +343,6 @@ export const useWorkspaceAuthStore = defineStore('workspaceAuth', () => {
|
||||
}
|
||||
}
|
||||
|
||||
function getWorkspaceToken(): string | undefined {
|
||||
return workspaceToken.value ?? undefined
|
||||
}
|
||||
|
||||
function clearWorkspaceContext(): void {
|
||||
// Increment request ID to invalidate any in-flight stale refresh operations
|
||||
refreshRequestId++
|
||||
@@ -374,7 +370,6 @@ export const useWorkspaceAuthStore = defineStore('workspaceAuth', () => {
|
||||
switchWorkspace,
|
||||
refreshToken,
|
||||
getWorkspaceAuthHeader,
|
||||
getWorkspaceToken,
|
||||
clearWorkspaceContext
|
||||
}
|
||||
})
|
||||
|
||||
@@ -181,8 +181,6 @@ describe('useVueNodeResizeTracking', () => {
|
||||
|
||||
resizeObserverState.callback?.([entry], createObserverMock())
|
||||
|
||||
// When layout store already has correct position, getBoundingClientRect
|
||||
// is not needed — position is read from the store instead.
|
||||
expect(rectSpy).not.toHaveBeenCalled()
|
||||
expect(testState.setSource).not.toHaveBeenCalled()
|
||||
expect(testState.batchUpdateNodeBounds).not.toHaveBeenCalled()
|
||||
@@ -200,7 +198,7 @@ describe('useVueNodeResizeTracking', () => {
|
||||
expect(testState.syncNodeSlotLayoutsFromDOM).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('preserves layout store position when size matches but DOM position differs', () => {
|
||||
it('uses layout store position, ignoring DOM position differences', () => {
|
||||
const nodeId = 'test-node'
|
||||
const width = 240
|
||||
const height = 180
|
||||
@@ -222,7 +220,6 @@ describe('useVueNodeResizeTracking', () => {
|
||||
|
||||
resizeObserverState.callback?.([entry], createObserverMock())
|
||||
|
||||
// Position from DOM should NOT override layout store position
|
||||
expect(rectSpy).not.toHaveBeenCalled()
|
||||
expect(testState.setSource).not.toHaveBeenCalled()
|
||||
expect(testState.batchUpdateNodeBounds).not.toHaveBeenCalled()
|
||||
|
||||
@@ -186,11 +186,6 @@ const resizeObserver = new ResizeObserver((entries) => {
|
||||
continue
|
||||
}
|
||||
|
||||
// Use existing position from layout store (source of truth) rather than
|
||||
// converting screen-space getBoundingClientRect() back to canvas coords.
|
||||
// The DOM→canvas conversion depends on the current canvas scale/offset,
|
||||
// which can be stale during graph transitions (e.g. entering a subgraph
|
||||
// before fitView runs), producing corrupted positions.
|
||||
const existingPos = nodeLayout?.position
|
||||
let posX: number
|
||||
let posY: number
|
||||
|
||||
@@ -1,184 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, provide, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type { FilterOption } from '@/platform/assets/types/filterTypes'
|
||||
import { isComboInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
|
||||
import FormDropdown from './form/dropdown/FormDropdown.vue'
|
||||
import type { FormDropdownItem, LayoutMode } from './form/dropdown/types'
|
||||
import { AssetKindKey } from './form/dropdown/types'
|
||||
import {
|
||||
buildSearchText,
|
||||
extractFilterValues,
|
||||
getByPath,
|
||||
mapToDropdownItem
|
||||
} from '../utils/resolveItemSchema'
|
||||
import { fetchRemoteRoute } from '../utils/resolveRemoteRoute'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue?: string
|
||||
widget: SimplifiedWidget<string | undefined>
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const comboSpec = computed(() => {
|
||||
if (props.widget.spec && isComboInputSpec(props.widget.spec)) {
|
||||
return props.widget.spec
|
||||
}
|
||||
return undefined
|
||||
})
|
||||
const remoteConfig = computed(() => comboSpec.value?.remote!)
|
||||
const itemSchema = computed(() => remoteConfig.value?.item_schema!)
|
||||
|
||||
const rawItems = ref<unknown[]>([])
|
||||
const loading = ref(false)
|
||||
|
||||
async function fetchItems() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await fetchRemoteRoute(remoteConfig.value.route, {
|
||||
params: remoteConfig.value.query_params,
|
||||
timeout: remoteConfig.value.timeout ?? 30000,
|
||||
useComfyApi: remoteConfig.value.use_comfy_api
|
||||
})
|
||||
const data = remoteConfig.value.response_key
|
||||
? res.data[remoteConfig.value.response_key]
|
||||
: res.data
|
||||
rawItems.value = Array.isArray(data) ? data : []
|
||||
} catch (err) {
|
||||
console.error('RichComboWidget: fetch error', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void fetchItems()
|
||||
})
|
||||
|
||||
const assetKind = computed(() => {
|
||||
const pt = itemSchema.value.preview_type ?? 'image'
|
||||
return pt as 'image' | 'video' | 'audio'
|
||||
})
|
||||
|
||||
provide(AssetKindKey, assetKind)
|
||||
|
||||
const items = computed<FormDropdownItem[]>(() =>
|
||||
rawItems.value.map((raw) => mapToDropdownItem(raw, itemSchema.value))
|
||||
)
|
||||
|
||||
const searchIndex = computed(() => {
|
||||
const schema = itemSchema.value
|
||||
const fields = schema.search_fields ?? [schema.label_field]
|
||||
const index = new Map<string, string>()
|
||||
for (const raw of rawItems.value) {
|
||||
const id = String(getByPath(raw, schema.value_field) ?? '')
|
||||
index.set(id, buildSearchText(raw, fields))
|
||||
}
|
||||
return index
|
||||
})
|
||||
|
||||
const filterOptions = computed<FilterOption[]>(() => {
|
||||
const schema = itemSchema.value
|
||||
if (!schema.filter_field) return []
|
||||
const values = extractFilterValues(rawItems.value, schema.filter_field)
|
||||
return [
|
||||
{ name: 'All', value: 'all' },
|
||||
...values.map((v) => ({ name: v, value: v }))
|
||||
]
|
||||
})
|
||||
|
||||
const filterSelected = ref('all')
|
||||
const layoutMode = ref<LayoutMode>('list')
|
||||
const selectedSet = ref<Set<string>>(new Set())
|
||||
|
||||
const filteredItems = computed<FormDropdownItem[]>(() => {
|
||||
const schema = itemSchema.value
|
||||
if (filterSelected.value === 'all' || !schema.filter_field) {
|
||||
return items.value
|
||||
}
|
||||
const filterField = schema.filter_field
|
||||
return rawItems.value
|
||||
.filter(
|
||||
(raw) =>
|
||||
String(getByPath(raw, filterField) ?? '') === filterSelected.value
|
||||
)
|
||||
.map((raw) => mapToDropdownItem(raw, schema))
|
||||
})
|
||||
|
||||
async function searcher(
|
||||
query: string,
|
||||
searchItems: FormDropdownItem[],
|
||||
_onCleanup: (cleanupFn: () => void) => void
|
||||
): Promise<FormDropdownItem[]> {
|
||||
if (!query.trim()) return searchItems
|
||||
const q = query.toLowerCase()
|
||||
return searchItems.filter((item) => {
|
||||
const text = searchIndex.value.get(item.id) ?? item.name.toLowerCase()
|
||||
return text.includes(q)
|
||||
})
|
||||
}
|
||||
|
||||
watch(
|
||||
[() => props.modelValue, items],
|
||||
([val]) => {
|
||||
selectedSet.value.clear()
|
||||
if (val) {
|
||||
const item = items.value.find((i) => i.id === val)
|
||||
if (item) selectedSet.value.add(item.id)
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
function handleRefresh() {
|
||||
void fetchItems()
|
||||
}
|
||||
|
||||
function handleSelection(selected: Set<string>) {
|
||||
const id = selected.values().next().value
|
||||
if (id) {
|
||||
emit('update:modelValue', id)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex w-full items-center gap-1">
|
||||
<FormDropdown
|
||||
v-model:selected="selectedSet"
|
||||
v-model:filter-selected="filterSelected"
|
||||
v-model:layout-mode="layoutMode"
|
||||
:items="filteredItems"
|
||||
:placeholder="loading ? 'Loading...' : t('widgets.uploadSelect.placeholder')"
|
||||
:multiple="false"
|
||||
:filter-options="[]"
|
||||
:show-sort="false"
|
||||
:show-layout-switcher="false"
|
||||
:searcher="searcher"
|
||||
class="flex-1"
|
||||
@update:selected="handleSelection"
|
||||
/>
|
||||
<button
|
||||
v-if="remoteConfig?.refresh_button !== false"
|
||||
class="flex size-7 shrink-0 items-center justify-center rounded text-secondary hover:bg-component-node-widget-background-hovered"
|
||||
title="Refresh"
|
||||
@pointerdown.stop
|
||||
@click.stop="handleRefresh"
|
||||
>
|
||||
<i
|
||||
:class="[
|
||||
'icon-[lucide--refresh-cw] size-3.5',
|
||||
loading && 'animate-spin'
|
||||
]"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,11 +1,6 @@
|
||||
<template>
|
||||
<RichComboWidget
|
||||
v-if="hasItemSchema"
|
||||
v-model="modelValue"
|
||||
:widget
|
||||
/>
|
||||
<WidgetSelectDropdown
|
||||
v-else-if="isDropdownUIWidget"
|
||||
v-if="isDropdownUIWidget"
|
||||
v-model="modelValue"
|
||||
:widget
|
||||
:node-type="widget.nodeType ?? nodeType"
|
||||
@@ -29,7 +24,6 @@
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { assetService } from '@/platform/assets/services/assetService'
|
||||
import RichComboWidget from '@/renderer/extensions/vueNodes/widgets/components/RichComboWidget.vue'
|
||||
import WidgetSelectDefault from '@/renderer/extensions/vueNodes/widgets/components/WidgetSelectDefault.vue'
|
||||
import WidgetSelectDropdown from '@/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.vue'
|
||||
import WidgetWithControl from '@/renderer/extensions/vueNodes/widgets/components/WidgetWithControl.vue'
|
||||
@@ -59,10 +53,6 @@ const comboSpec = computed<ComboInputSpec | undefined>(() => {
|
||||
return undefined
|
||||
})
|
||||
|
||||
const hasItemSchema = computed(
|
||||
() => !!comboSpec.value?.remote?.item_schema
|
||||
)
|
||||
|
||||
const specDescriptor = computed<{
|
||||
kind: AssetKind
|
||||
allowUpload: boolean
|
||||
|
||||
@@ -34,8 +34,6 @@ interface Props {
|
||||
accept?: string
|
||||
filterOptions?: FilterOption[]
|
||||
sortOptions?: SortOption[]
|
||||
showSort?: boolean
|
||||
showLayoutSwitcher?: boolean
|
||||
showOwnershipFilter?: boolean
|
||||
ownershipOptions?: OwnershipFilterOption[]
|
||||
showBaseModelFilter?: boolean
|
||||
@@ -63,8 +61,6 @@ const {
|
||||
accept,
|
||||
filterOptions = [],
|
||||
sortOptions = getDefaultSortOptions(),
|
||||
showSort = true,
|
||||
showLayoutSwitcher = true,
|
||||
showOwnershipFilter,
|
||||
ownershipOptions,
|
||||
showBaseModelFilter,
|
||||
@@ -236,8 +232,6 @@ function handleSelection(item: FormDropdownItem, index: number) {
|
||||
v-model:base-model-selected="baseModelSelected"
|
||||
:filter-options
|
||||
:sort-options
|
||||
:show-sort
|
||||
:show-layout-switcher="showLayoutSwitcher"
|
||||
:show-ownership-filter
|
||||
:ownership-options
|
||||
:show-base-model-filter
|
||||
|
||||
@@ -20,8 +20,6 @@ interface Props {
|
||||
isSelected: (item: FormDropdownItem, index: number) => boolean
|
||||
filterOptions: FilterOption[]
|
||||
sortOptions: SortOption[]
|
||||
showSort?: boolean
|
||||
showLayoutSwitcher?: boolean
|
||||
showOwnershipFilter?: boolean
|
||||
ownershipOptions?: OwnershipFilterOption[]
|
||||
showBaseModelFilter?: boolean
|
||||
@@ -33,8 +31,6 @@ const {
|
||||
isSelected,
|
||||
filterOptions,
|
||||
sortOptions,
|
||||
showSort = true,
|
||||
showLayoutSwitcher = true,
|
||||
showOwnershipFilter,
|
||||
ownershipOptions,
|
||||
showBaseModelFilter,
|
||||
@@ -116,8 +112,6 @@ const virtualItems = computed<VirtualDropdownItem[]>(() =>
|
||||
v-model:ownership-selected="ownershipSelected"
|
||||
v-model:base-model-selected="baseModelSelected"
|
||||
:sort-options
|
||||
:show-sort
|
||||
:show-layout-switcher="showLayoutSwitcher"
|
||||
:show-ownership-filter
|
||||
:ownership-options
|
||||
:show-base-model-filter
|
||||
@@ -151,7 +145,6 @@ const virtualItems = computed<VirtualDropdownItem[]>(() =>
|
||||
:preview-url="item.preview_url ?? ''"
|
||||
:name="item.name"
|
||||
:label="item.label"
|
||||
:description="item.description"
|
||||
:layout="layoutMode"
|
||||
@click="emit('item-click', item, index)"
|
||||
/>
|
||||
|
||||
@@ -18,13 +18,8 @@ import type { LayoutMode, SortOption } from './types'
|
||||
const { t } = useI18n()
|
||||
const overlayProps = useTransformCompatOverlayProps()
|
||||
|
||||
const {
|
||||
showSort = true,
|
||||
showLayoutSwitcher = true
|
||||
} = defineProps<{
|
||||
defineProps<{
|
||||
sortOptions: SortOption[]
|
||||
showSort?: boolean
|
||||
showLayoutSwitcher?: boolean
|
||||
showOwnershipFilter?: boolean
|
||||
ownershipOptions?: OwnershipFilterOption[]
|
||||
showBaseModelFilter?: boolean
|
||||
@@ -119,7 +114,6 @@ function toggleBaseModelSelection(item: FilterOption) {
|
||||
/>
|
||||
|
||||
<Button
|
||||
v-if="showSort"
|
||||
ref="sortTriggerRef"
|
||||
variant="textonly"
|
||||
size="icon"
|
||||
@@ -138,7 +132,6 @@ function toggleBaseModelSelection(item: FilterOption) {
|
||||
<i class="icon-[lucide--arrow-up-down] size-4" />
|
||||
</Button>
|
||||
<Popover
|
||||
v-if="showSort"
|
||||
ref="sortPopoverRef"
|
||||
:dismissable="true"
|
||||
:close-on-escape="true"
|
||||
@@ -316,7 +309,6 @@ function toggleBaseModelSelection(item: FilterOption) {
|
||||
</Popover>
|
||||
|
||||
<div
|
||||
v-if="showLayoutSwitcher"
|
||||
:class="
|
||||
cn(
|
||||
actionButtonStyle,
|
||||
|
||||
@@ -12,7 +12,6 @@ interface Props {
|
||||
previewUrl: string
|
||||
name: string
|
||||
label?: string
|
||||
description?: string
|
||||
layout?: LayoutMode
|
||||
}
|
||||
|
||||
@@ -28,31 +27,11 @@ const actualDimensions = ref<string | null>(null)
|
||||
const assetKind = inject(AssetKindKey)
|
||||
|
||||
const isVideo = computed(() => assetKind?.value === 'video')
|
||||
const isAudio = computed(() => assetKind?.value === 'audio')
|
||||
|
||||
const audioRef = ref<HTMLAudioElement | null>(null)
|
||||
const isPlayingAudio = ref(false)
|
||||
|
||||
function handleClick() {
|
||||
emit('click', props.index)
|
||||
}
|
||||
|
||||
function toggleAudioPreview(event: Event) {
|
||||
event.stopPropagation()
|
||||
if (!audioRef.value) return
|
||||
if (isPlayingAudio.value) {
|
||||
audioRef.value.pause()
|
||||
isPlayingAudio.value = false
|
||||
} else {
|
||||
void audioRef.value.play()
|
||||
isPlayingAudio.value = true
|
||||
}
|
||||
}
|
||||
|
||||
function handleAudioEnded() {
|
||||
isPlayingAudio.value = false
|
||||
}
|
||||
|
||||
function handleImageLoad(event: Event) {
|
||||
emit('mediaLoad', event)
|
||||
if (!event.target || !(event.target instanceof HTMLImageElement)) return
|
||||
@@ -128,25 +107,6 @@ function handleVideoLoad(event: Event) {
|
||||
muted
|
||||
@loadeddata="handleVideoLoad"
|
||||
/>
|
||||
<div
|
||||
v-else-if="previewUrl && isAudio"
|
||||
class="flex size-full cursor-pointer items-center justify-center bg-gradient-to-tr from-violet-500 via-purple-500 to-fuchsia-400"
|
||||
@click.stop="toggleAudioPreview"
|
||||
>
|
||||
<audio
|
||||
ref="audioRef"
|
||||
:src="previewUrl"
|
||||
preload="none"
|
||||
@ended="handleAudioEnded"
|
||||
/>
|
||||
<i
|
||||
:class="
|
||||
isPlayingAudio
|
||||
? 'icon-[lucide--pause] size-5 text-white'
|
||||
: 'icon-[lucide--play] size-5 text-white'
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<img
|
||||
v-else-if="previewUrl"
|
||||
:src="previewUrl"
|
||||
@@ -184,13 +144,6 @@ function handleVideoLoad(event: Event) {
|
||||
>
|
||||
{{ label ?? name }}
|
||||
</span>
|
||||
<!-- Description -->
|
||||
<span
|
||||
v-if="description && layout !== 'grid'"
|
||||
class="text-secondary line-clamp-1 block overflow-hidden text-xs"
|
||||
>
|
||||
{{ description }}
|
||||
</span>
|
||||
<!-- Meta Data -->
|
||||
<span v-if="actualDimensions" class="text-secondary block text-xs">
|
||||
{{ actualDimensions }}
|
||||
|
||||
@@ -12,9 +12,7 @@ export interface FormDropdownItem {
|
||||
name: string
|
||||
/** Original/alternate label (e.g., original filename) */
|
||||
label?: string
|
||||
/** Short description shown below the name in list view */
|
||||
description?: string
|
||||
/** Preview image/video/audio URL */
|
||||
/** Preview image/video URL */
|
||||
preview_url?: string
|
||||
/** Whether the item is immutable (public model) - used for ownership filtering */
|
||||
is_immutable?: boolean
|
||||
|
||||
@@ -214,9 +214,7 @@ const addComboWidget = (
|
||||
}
|
||||
)
|
||||
|
||||
if (inputSpec.remote && !inputSpec.remote.item_schema) {
|
||||
// Skip useRemoteWidget when item_schema is present —
|
||||
// RichComboWidget handles its own data fetching and rendering.
|
||||
if (inputSpec.remote) {
|
||||
if (!isComboWidget(widget)) {
|
||||
throw new Error(`Expected combo widget but received ${widget.type}`)
|
||||
}
|
||||
|
||||
@@ -2,12 +2,10 @@ import axios from 'axios'
|
||||
|
||||
import { useChainCallback } from '@/composables/functional/useChainCallback'
|
||||
import type { IWidget, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import type { RemoteWidgetConfig } from '@/schemas/nodeDefSchema'
|
||||
import { api } from '@/scripts/api'
|
||||
import {
|
||||
getRemoteAuthHeaders,
|
||||
resolveRoute
|
||||
} from '../utils/resolveRemoteRoute'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
|
||||
const MAX_RETRIES = 5
|
||||
const TIMEOUT = 4096
|
||||
@@ -23,6 +21,17 @@ interface CacheEntry<T> {
|
||||
failed?: boolean
|
||||
}
|
||||
|
||||
async function getAuthHeaders() {
|
||||
if (isCloud) {
|
||||
const authStore = useAuthStore()
|
||||
const authHeader = await authStore.getAuthHeader()
|
||||
return {
|
||||
...(authHeader && { headers: authHeader })
|
||||
}
|
||||
}
|
||||
return {}
|
||||
}
|
||||
|
||||
const dataCache = new Map<string, CacheEntry<unknown>>()
|
||||
|
||||
const createCacheKey = (config: RemoteWidgetConfig): string => {
|
||||
@@ -64,10 +73,9 @@ const fetchData = async (
|
||||
) => {
|
||||
const { route, response_key, query_params, timeout = TIMEOUT } = config
|
||||
|
||||
const url = resolveRoute(route, config.use_comfy_api)
|
||||
const authHeaders = await getRemoteAuthHeaders(config.use_comfy_api)
|
||||
const authHeaders = await getAuthHeaders()
|
||||
|
||||
const res = await axios.get(url, {
|
||||
const res = await axios.get(route, {
|
||||
params: query_params,
|
||||
signal: controller.signal,
|
||||
timeout,
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
import type { RemoteItemSchema } from '@/schemas/nodeDefSchema'
|
||||
import type { FormDropdownItem } from '@/renderer/extensions/vueNodes/widgets/components/form/dropdown/types'
|
||||
|
||||
/** Traverse an object by dot-path, treating numeric segments as array indices */
|
||||
export function getByPath(obj: unknown, path: string): unknown {
|
||||
return path.split('.').reduce((acc: unknown, key: string) => {
|
||||
if (acc == null) return undefined
|
||||
const idx = Number(key)
|
||||
if (Number.isInteger(idx) && idx >= 0 && Array.isArray(acc)) return acc[idx]
|
||||
return (acc as Record<string, unknown>)[key]
|
||||
}, obj)
|
||||
}
|
||||
|
||||
/** Resolve a label — either dot-path or template with {field.path} placeholders */
|
||||
export function resolveLabel(template: string, item: unknown): string {
|
||||
if (!template.includes('{')) {
|
||||
return String(getByPath(item, template) ?? '')
|
||||
}
|
||||
return template.replace(/\{([^}]+)\}/g, (_, path: string) =>
|
||||
String(getByPath(item, path) ?? '')
|
||||
)
|
||||
}
|
||||
|
||||
/** Map a raw API object to a FormDropdownItem using the item_schema */
|
||||
export function mapToDropdownItem(
|
||||
raw: unknown,
|
||||
schema: RemoteItemSchema
|
||||
): FormDropdownItem {
|
||||
return {
|
||||
id: String(getByPath(raw, schema.value_field) ?? ''),
|
||||
name: resolveLabel(schema.label_field, raw),
|
||||
description: schema.description_field
|
||||
? resolveLabel(schema.description_field, raw)
|
||||
: undefined,
|
||||
preview_url: schema.preview_url_field
|
||||
? String(getByPath(raw, schema.preview_url_field) ?? '')
|
||||
: undefined
|
||||
}
|
||||
}
|
||||
|
||||
/** Extract items array from full API response using response_key */
|
||||
export function extractItems(
|
||||
response: unknown,
|
||||
responseKey?: string
|
||||
): unknown[] {
|
||||
const data = responseKey ? getByPath(response, responseKey) : response
|
||||
return Array.isArray(data) ? data : []
|
||||
}
|
||||
|
||||
/** Build search text for an item from the specified search fields */
|
||||
export function buildSearchText(raw: unknown, searchFields: string[]): string {
|
||||
return searchFields
|
||||
.map((field) => String(getByPath(raw, field) ?? ''))
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
}
|
||||
|
||||
/** Extract unique filter values from items */
|
||||
export function extractFilterValues(
|
||||
items: unknown[],
|
||||
filterField: string
|
||||
): string[] {
|
||||
const values = new Set<string>()
|
||||
for (const item of items) {
|
||||
const value = getByPath(item, filterField)
|
||||
if (value != null) values.add(String(value))
|
||||
}
|
||||
return Array.from(values).sort()
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
import axios from 'axios'
|
||||
|
||||
import { getComfyApiBaseUrl } from '@/config/comfyApi'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
|
||||
/**
|
||||
* Resolve a RemoteOptions route to a full URL.
|
||||
* - useComfyApi=true → prepend getComfyApiBaseUrl()
|
||||
* - Otherwise → use as-is
|
||||
*/
|
||||
export function resolveRoute(
|
||||
route: string,
|
||||
useComfyApi?: boolean
|
||||
): string {
|
||||
if (useComfyApi) {
|
||||
return getComfyApiBaseUrl() + route
|
||||
}
|
||||
return route
|
||||
}
|
||||
|
||||
/**
|
||||
* Get auth headers for a remote request.
|
||||
* - useComfyApi=true → inject auth headers (comfy-api requires it)
|
||||
* - Otherwise → no auth headers injected
|
||||
*/
|
||||
export async function getRemoteAuthHeaders(
|
||||
useComfyApi?: boolean
|
||||
): Promise<Record<string, any>> {
|
||||
if (useComfyApi) {
|
||||
const authStore = useAuthStore()
|
||||
const authHeader = await authStore.getAuthHeader()
|
||||
if (authHeader) {
|
||||
return { headers: authHeader }
|
||||
}
|
||||
}
|
||||
return {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience: make an authenticated GET request to a remote route.
|
||||
*/
|
||||
export async function fetchRemoteRoute(
|
||||
route: string,
|
||||
options: {
|
||||
params?: Record<string, string>
|
||||
timeout?: number
|
||||
signal?: AbortSignal
|
||||
useComfyApi?: boolean
|
||||
} = {}
|
||||
) {
|
||||
const { useComfyApi, ...requestOptions } = options
|
||||
const url = resolveRoute(route, useComfyApi)
|
||||
const authHeaders = await getRemoteAuthHeaders(useComfyApi)
|
||||
return axios.get(url, { ...requestOptions, ...authHeaders })
|
||||
}
|
||||
@@ -5,16 +5,6 @@ import { resultItemType } from '@/schemas/apiSchema'
|
||||
import { CONTROL_OPTIONS } from '@/types/simplifiedWidget'
|
||||
|
||||
const zComboOption = z.union([z.string(), z.number()])
|
||||
const zRemoteItemSchema = z.object({
|
||||
value_field: z.string(),
|
||||
label_field: z.string(),
|
||||
preview_url_field: z.string().optional(),
|
||||
preview_type: z.enum(['image', 'video', 'audio']).default('image'),
|
||||
description_field: z.string().optional(),
|
||||
search_fields: z.array(z.string()).optional(),
|
||||
filter_field: z.string().optional()
|
||||
})
|
||||
|
||||
const zRemoteWidgetConfig = z.object({
|
||||
route: z.string().url().or(z.string().startsWith('/')),
|
||||
refresh: z.number().gte(128).safe().or(z.number().lte(0).safe()).optional(),
|
||||
@@ -23,9 +13,7 @@ const zRemoteWidgetConfig = z.object({
|
||||
refresh_button: z.boolean().optional(),
|
||||
control_after_refresh: z.enum(['first', 'last']).optional(),
|
||||
timeout: z.number().gte(0).optional(),
|
||||
max_retries: z.number().gte(0).optional(),
|
||||
item_schema: zRemoteItemSchema.optional(),
|
||||
use_comfy_api: z.boolean().optional()
|
||||
max_retries: z.number().gte(0).optional()
|
||||
})
|
||||
const zMultiSelectOption = z.object({
|
||||
placeholder: z.string().optional(),
|
||||
@@ -366,7 +354,6 @@ export const zMatchTypeOptions = z.object({
|
||||
export type ComfyInputsSpec = z.infer<typeof zComfyInputsSpec>
|
||||
export type ComfyOutputTypesSpec = z.infer<typeof zComfyOutputTypesSpec>
|
||||
export type ComfyNodeDef = z.infer<typeof zComfyNodeDef>
|
||||
export type RemoteItemSchema = z.infer<typeof zRemoteItemSchema>
|
||||
export type RemoteWidgetConfig = z.infer<typeof zRemoteWidgetConfig>
|
||||
|
||||
export type ComboInputOptions = z.infer<typeof zComboInputOptions>
|
||||
|
||||
@@ -1,211 +0,0 @@
|
||||
import type { User } from 'firebase/auth'
|
||||
import * as firebaseAuth from 'firebase/auth'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import type { Mock } from 'vitest'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import * as vuefire from 'vuefire'
|
||||
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
|
||||
const { mockFeatureFlags } = vi.hoisted(() => ({
|
||||
mockFeatureFlags: {
|
||||
teamWorkspacesEnabled: false
|
||||
}
|
||||
}))
|
||||
|
||||
const { mockDistributionTypes } = vi.hoisted(() => ({
|
||||
mockDistributionTypes: {
|
||||
isCloud: true,
|
||||
isDesktop: true
|
||||
}
|
||||
}))
|
||||
|
||||
const mockWorkspaceAuthHeader = vi.fn().mockReturnValue(null)
|
||||
const mockGetWorkspaceToken = vi.fn().mockReturnValue(undefined)
|
||||
const mockClearWorkspaceContext = vi.fn()
|
||||
|
||||
vi.mock('@/platform/workspace/stores/workspaceAuthStore', () => ({
|
||||
useWorkspaceAuthStore: () => ({
|
||||
getWorkspaceAuthHeader: mockWorkspaceAuthHeader,
|
||||
getWorkspaceToken: mockGetWorkspaceToken,
|
||||
clearWorkspaceContext: mockClearWorkspaceContext
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useFeatureFlags', () => ({
|
||||
useFeatureFlags: () => ({
|
||||
flags: mockFeatureFlags
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('vuefire', () => ({
|
||||
useFirebaseAuth: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({ t: (key: string) => key }),
|
||||
createI18n: () => ({ global: { t: (key: string) => key } })
|
||||
}))
|
||||
|
||||
vi.mock('firebase/auth', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof firebaseAuth>()
|
||||
return {
|
||||
...actual,
|
||||
signInWithEmailAndPassword: vi.fn(),
|
||||
createUserWithEmailAndPassword: vi.fn(),
|
||||
signOut: vi.fn(),
|
||||
onAuthStateChanged: vi.fn(),
|
||||
onIdTokenChanged: vi.fn(),
|
||||
signInWithPopup: vi.fn(),
|
||||
GoogleAuthProvider: class {
|
||||
addScope = vi.fn()
|
||||
setCustomParameters = vi.fn()
|
||||
},
|
||||
GithubAuthProvider: class {
|
||||
addScope = vi.fn()
|
||||
setCustomParameters = vi.fn()
|
||||
},
|
||||
getAdditionalUserInfo: vi.fn(),
|
||||
setPersistence: vi.fn().mockResolvedValue(undefined)
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: () => ({ trackAuth: vi.fn() })
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/toastStore', () => ({
|
||||
useToastStore: () => ({ add: vi.fn() })
|
||||
}))
|
||||
|
||||
vi.mock('@/services/dialogService')
|
||||
vi.mock('@/platform/distribution/types', () => mockDistributionTypes)
|
||||
|
||||
const mockApiKeyGetAuthHeader = vi.fn().mockReturnValue(null)
|
||||
vi.mock('@/stores/apiKeyAuthStore', () => ({
|
||||
useApiKeyAuthStore: () => ({
|
||||
getAuthHeader: mockApiKeyGetAuthHeader,
|
||||
getApiKey: vi.fn(),
|
||||
currentUser: null,
|
||||
isAuthenticated: false,
|
||||
storeApiKey: vi.fn(),
|
||||
clearStoredApiKey: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
type MockUser = Omit<User, 'getIdToken'> & { getIdToken: Mock }
|
||||
|
||||
describe('auth token priority chain', () => {
|
||||
let store: ReturnType<typeof useAuthStore>
|
||||
let authStateCallback: (user: User | null) => void
|
||||
|
||||
const mockAuth: Record<string, unknown> = {}
|
||||
|
||||
const mockUser: MockUser = {
|
||||
uid: 'test-user-id',
|
||||
email: 'test@example.com',
|
||||
getIdToken: vi.fn().mockResolvedValue('firebase-token')
|
||||
} as Partial<User> as MockUser
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
|
||||
mockFeatureFlags.teamWorkspacesEnabled = false
|
||||
mockWorkspaceAuthHeader.mockReturnValue(null)
|
||||
mockGetWorkspaceToken.mockReturnValue(undefined)
|
||||
mockApiKeyGetAuthHeader.mockReturnValue(null)
|
||||
mockUser.getIdToken.mockResolvedValue('firebase-token')
|
||||
|
||||
vi.mocked(vuefire.useFirebaseAuth).mockReturnValue(
|
||||
mockAuth as unknown as ReturnType<typeof vuefire.useFirebaseAuth>
|
||||
)
|
||||
|
||||
vi.mocked(firebaseAuth.onAuthStateChanged).mockImplementation(
|
||||
(_, callback) => {
|
||||
authStateCallback = callback as (user: User | null) => void
|
||||
;(callback as (user: User | null) => void)(mockUser)
|
||||
return vi.fn()
|
||||
}
|
||||
)
|
||||
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
store = useAuthStore()
|
||||
})
|
||||
|
||||
describe('getAuthHeader priority', () => {
|
||||
it('returns workspace auth header when workspace is active and feature enabled', async () => {
|
||||
mockFeatureFlags.teamWorkspacesEnabled = true
|
||||
mockWorkspaceAuthHeader.mockReturnValue({
|
||||
Authorization: 'Bearer workspace-token'
|
||||
})
|
||||
|
||||
const header = await store.getAuthHeader()
|
||||
|
||||
expect(header).toEqual({
|
||||
Authorization: 'Bearer workspace-token'
|
||||
})
|
||||
})
|
||||
|
||||
it('returns Firebase token when workspace is not active but user is authenticated', async () => {
|
||||
mockFeatureFlags.teamWorkspacesEnabled = true
|
||||
mockWorkspaceAuthHeader.mockReturnValue(null)
|
||||
|
||||
const header = await store.getAuthHeader()
|
||||
|
||||
expect(header).toEqual({
|
||||
Authorization: 'Bearer firebase-token'
|
||||
})
|
||||
})
|
||||
|
||||
it('returns API key when neither workspace nor Firebase are available', async () => {
|
||||
authStateCallback(null)
|
||||
mockApiKeyGetAuthHeader.mockReturnValue({ 'X-API-KEY': 'test-key' })
|
||||
|
||||
const header = await store.getAuthHeader()
|
||||
|
||||
expect(header).toEqual({ 'X-API-KEY': 'test-key' })
|
||||
})
|
||||
|
||||
it('returns null when no auth method is available', async () => {
|
||||
authStateCallback(null)
|
||||
|
||||
const header = await store.getAuthHeader()
|
||||
|
||||
expect(header).toBeNull()
|
||||
})
|
||||
|
||||
it('skips workspace header when team_workspaces feature is disabled', async () => {
|
||||
mockFeatureFlags.teamWorkspacesEnabled = false
|
||||
mockWorkspaceAuthHeader.mockReturnValue({
|
||||
Authorization: 'Bearer workspace-token'
|
||||
})
|
||||
|
||||
const header = await store.getAuthHeader()
|
||||
|
||||
expect(header).toEqual({
|
||||
Authorization: 'Bearer firebase-token'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('getAuthToken priority', () => {
|
||||
it('returns workspace token when workspace is active and feature enabled', async () => {
|
||||
mockFeatureFlags.teamWorkspacesEnabled = true
|
||||
mockGetWorkspaceToken.mockReturnValue('workspace-raw-token')
|
||||
|
||||
const token = await store.getAuthToken()
|
||||
|
||||
expect(token).toBe('workspace-raw-token')
|
||||
})
|
||||
|
||||
it('returns Firebase token when workspace token is not available', async () => {
|
||||
mockFeatureFlags.teamWorkspacesEnabled = true
|
||||
mockGetWorkspaceToken.mockReturnValue(undefined)
|
||||
|
||||
const token = await store.getAuthToken()
|
||||
|
||||
expect(token).toBe('firebase-token')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -22,10 +22,10 @@ import { useFirebaseAuth } from 'vuefire'
|
||||
|
||||
import { getComfyApiBaseUrl } from '@/config/comfyApi'
|
||||
import { t } from '@/i18n'
|
||||
import { WORKSPACE_STORAGE_KEYS } from '@/platform/workspace/workspaceConstants'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useWorkspaceAuthStore } from '@/platform/workspace/stores/workspaceAuthStore'
|
||||
import { useApiKeyAuthStore } from '@/stores/apiKeyAuthStore'
|
||||
import type { AuthHeader } from '@/types/authTypes'
|
||||
import type { operations } from '@/types/comfyRegistryTypes'
|
||||
@@ -110,7 +110,15 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
isInitialized.value = true
|
||||
if (user === null) {
|
||||
lastTokenUserId.value = null
|
||||
useWorkspaceAuthStore().clearWorkspaceContext()
|
||||
|
||||
// Clear workspace sessionStorage on logout to prevent stale tokens
|
||||
try {
|
||||
sessionStorage.removeItem(WORKSPACE_STORAGE_KEYS.CURRENT_WORKSPACE)
|
||||
sessionStorage.removeItem(WORKSPACE_STORAGE_KEYS.TOKEN)
|
||||
sessionStorage.removeItem(WORKSPACE_STORAGE_KEYS.EXPIRES_AT)
|
||||
} catch {
|
||||
// Ignore sessionStorage errors (e.g., in private browsing mode)
|
||||
}
|
||||
}
|
||||
|
||||
// Reset balance when auth state changes
|
||||
@@ -167,8 +175,21 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
*/
|
||||
const getAuthHeader = async (): Promise<AuthHeader | null> => {
|
||||
if (flags.teamWorkspacesEnabled) {
|
||||
const wsHeader = useWorkspaceAuthStore().getWorkspaceAuthHeader()
|
||||
if (wsHeader) return wsHeader
|
||||
const workspaceToken = sessionStorage.getItem(
|
||||
WORKSPACE_STORAGE_KEYS.TOKEN
|
||||
)
|
||||
const expiresAt = sessionStorage.getItem(
|
||||
WORKSPACE_STORAGE_KEYS.EXPIRES_AT
|
||||
)
|
||||
|
||||
if (workspaceToken && expiresAt) {
|
||||
const expiryTime = parseInt(expiresAt, 10)
|
||||
if (Date.now() < expiryTime) {
|
||||
return {
|
||||
Authorization: `Bearer ${workspaceToken}`
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const token = await getIdToken()
|
||||
@@ -197,8 +218,19 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
*/
|
||||
const getAuthToken = async (): Promise<string | undefined> => {
|
||||
if (flags.teamWorkspacesEnabled) {
|
||||
const wsToken = useWorkspaceAuthStore().getWorkspaceToken()
|
||||
if (wsToken) return wsToken
|
||||
const workspaceToken = sessionStorage.getItem(
|
||||
WORKSPACE_STORAGE_KEYS.TOKEN
|
||||
)
|
||||
const expiresAt = sessionStorage.getItem(
|
||||
WORKSPACE_STORAGE_KEYS.EXPIRES_AT
|
||||
)
|
||||
|
||||
if (workspaceToken && expiresAt) {
|
||||
const expiryTime = parseInt(expiresAt, 10)
|
||||
if (Date.now() < expiryTime) {
|
||||
return workspaceToken
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return await getIdToken()
|
||||
|
||||
@@ -1,197 +0,0 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
|
||||
vi.mock('@/composables/useErrorHandling', () => ({
|
||||
useErrorHandling: () => ({
|
||||
wrapWithErrorHandlingAsync:
|
||||
(fn: () => Promise<void>, errorHandler?: (e: unknown) => void) =>
|
||||
async () => {
|
||||
try {
|
||||
await fn()
|
||||
} catch (e) {
|
||||
if (errorHandler) errorHandler(e)
|
||||
else throw e
|
||||
}
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/keybindings/keybindingStore', () => ({
|
||||
useKeybindingStore: () => ({
|
||||
getKeybindingByCommandId: () => null
|
||||
})
|
||||
}))
|
||||
|
||||
describe('commandStore', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
describe('registerCommand', () => {
|
||||
it('registers a command by id', () => {
|
||||
const store = useCommandStore()
|
||||
store.registerCommand({
|
||||
id: 'test.command',
|
||||
function: vi.fn()
|
||||
})
|
||||
expect(store.isRegistered('test.command')).toBe(true)
|
||||
})
|
||||
|
||||
it('warns on duplicate registration and overwrites with new function', async () => {
|
||||
const store = useCommandStore()
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
|
||||
const originalFn = vi.fn()
|
||||
const replacementFn = vi.fn()
|
||||
store.registerCommand({ id: 'dup', function: originalFn })
|
||||
store.registerCommand({ id: 'dup', function: replacementFn })
|
||||
|
||||
expect(warnSpy).toHaveBeenCalledWith('Command dup already registered')
|
||||
warnSpy.mockRestore()
|
||||
|
||||
await store.getCommand('dup')?.function()
|
||||
expect(replacementFn).toHaveBeenCalled()
|
||||
expect(originalFn).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('getCommand', () => {
|
||||
it('returns the registered command', () => {
|
||||
const store = useCommandStore()
|
||||
const fn = vi.fn()
|
||||
store.registerCommand({ id: 'get.test', function: fn, label: 'Test' })
|
||||
const cmd = store.getCommand('get.test')
|
||||
expect(cmd).toBeDefined()
|
||||
expect(cmd?.label).toBe('Test')
|
||||
})
|
||||
|
||||
it('returns undefined for unregistered command', () => {
|
||||
const store = useCommandStore()
|
||||
expect(store.getCommand('nonexistent')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('execute', () => {
|
||||
it('executes a registered command', async () => {
|
||||
const store = useCommandStore()
|
||||
const fn = vi.fn()
|
||||
store.registerCommand({ id: 'exec.test', function: fn })
|
||||
await store.execute('exec.test')
|
||||
expect(fn).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('throws for unregistered command', async () => {
|
||||
const store = useCommandStore()
|
||||
await expect(store.execute('missing')).rejects.toThrow(
|
||||
'Command missing not found'
|
||||
)
|
||||
})
|
||||
|
||||
it('passes metadata to the command function', async () => {
|
||||
const store = useCommandStore()
|
||||
const fn = vi.fn()
|
||||
store.registerCommand({ id: 'meta.test', function: fn })
|
||||
await store.execute('meta.test', { metadata: { source: 'keyboard' } })
|
||||
expect(fn).toHaveBeenCalledWith({ source: 'keyboard' })
|
||||
})
|
||||
|
||||
it('calls errorHandler on failure', async () => {
|
||||
const store = useCommandStore()
|
||||
const error = new Error('fail')
|
||||
store.registerCommand({
|
||||
id: 'err.test',
|
||||
function: () => {
|
||||
throw error
|
||||
}
|
||||
})
|
||||
const handler = vi.fn()
|
||||
await store.execute('err.test', { errorHandler: handler })
|
||||
expect(handler).toHaveBeenCalledWith(error)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isRegistered', () => {
|
||||
it('returns false for unregistered command', () => {
|
||||
const store = useCommandStore()
|
||||
expect(store.isRegistered('nope')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('loadExtensionCommands', () => {
|
||||
it('registers commands from an extension', () => {
|
||||
const store = useCommandStore()
|
||||
store.loadExtensionCommands({
|
||||
name: 'test-ext',
|
||||
commands: [
|
||||
{ id: 'ext.cmd1', function: vi.fn(), label: 'Cmd 1' },
|
||||
{ id: 'ext.cmd2', function: vi.fn(), label: 'Cmd 2' }
|
||||
]
|
||||
})
|
||||
expect(store.isRegistered('ext.cmd1')).toBe(true)
|
||||
expect(store.isRegistered('ext.cmd2')).toBe(true)
|
||||
expect(store.getCommand('ext.cmd1')?.source).toBe('test-ext')
|
||||
expect(store.getCommand('ext.cmd2')?.source).toBe('test-ext')
|
||||
})
|
||||
|
||||
it('skips extensions without commands', () => {
|
||||
const store = useCommandStore()
|
||||
store.loadExtensionCommands({ name: 'no-commands' })
|
||||
expect(store.commands).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getCommand resolves dynamic properties', () => {
|
||||
it('resolves label as function', () => {
|
||||
const store = useCommandStore()
|
||||
store.registerCommand({
|
||||
id: 'label.fn',
|
||||
function: vi.fn(),
|
||||
label: () => 'Dynamic'
|
||||
})
|
||||
expect(store.getCommand('label.fn')?.label).toBe('Dynamic')
|
||||
})
|
||||
|
||||
it('resolves tooltip as function', () => {
|
||||
const store = useCommandStore()
|
||||
store.registerCommand({
|
||||
id: 'tip.fn',
|
||||
function: vi.fn(),
|
||||
tooltip: () => 'Dynamic tip'
|
||||
})
|
||||
expect(store.getCommand('tip.fn')?.tooltip).toBe('Dynamic tip')
|
||||
})
|
||||
|
||||
it('uses explicit menubarLabel over label', () => {
|
||||
const store = useCommandStore()
|
||||
store.registerCommand({
|
||||
id: 'mbl.explicit',
|
||||
function: vi.fn(),
|
||||
label: 'Label',
|
||||
menubarLabel: 'Menu Label'
|
||||
})
|
||||
expect(store.getCommand('mbl.explicit')?.menubarLabel).toBe('Menu Label')
|
||||
})
|
||||
|
||||
it('falls back menubarLabel to label', () => {
|
||||
const store = useCommandStore()
|
||||
store.registerCommand({
|
||||
id: 'mbl.default',
|
||||
function: vi.fn(),
|
||||
label: 'My Label'
|
||||
})
|
||||
expect(store.getCommand('mbl.default')?.menubarLabel).toBe('My Label')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatKeySequence', () => {
|
||||
it('returns empty string when command has no keybinding', () => {
|
||||
const store = useCommandStore()
|
||||
store.registerCommand({ id: 'no.kb', function: vi.fn() })
|
||||
const cmd = store.getCommand('no.kb')!
|
||||
expect(store.formatKeySequence(cmd)).toBe('')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,152 +0,0 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useExtensionStore } from '@/stores/extensionStore'
|
||||
|
||||
describe('extensionStore', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
describe('registerExtension', () => {
|
||||
it('registers an extension by name', () => {
|
||||
const store = useExtensionStore()
|
||||
store.registerExtension({ name: 'test.ext' })
|
||||
expect(store.isExtensionInstalled('test.ext')).toBe(true)
|
||||
})
|
||||
|
||||
it('throws for extension without name', () => {
|
||||
const store = useExtensionStore()
|
||||
expect(() => store.registerExtension({ name: '' })).toThrow(
|
||||
"Extensions must have a 'name' property."
|
||||
)
|
||||
})
|
||||
|
||||
it('throws for duplicate registration', () => {
|
||||
const store = useExtensionStore()
|
||||
store.registerExtension({ name: 'dup' })
|
||||
expect(() => store.registerExtension({ name: 'dup' })).toThrow(
|
||||
"Extension named 'dup' already registered."
|
||||
)
|
||||
})
|
||||
|
||||
it('warns when registering a disabled extension but still installs it', () => {
|
||||
const store = useExtensionStore()
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
try {
|
||||
store.loadDisabledExtensionNames(['disabled.ext'])
|
||||
store.registerExtension({ name: 'disabled.ext' })
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
'Extension disabled.ext is disabled.'
|
||||
)
|
||||
expect(store.isExtensionInstalled('disabled.ext')).toBe(true)
|
||||
expect(store.isExtensionEnabled('disabled.ext')).toBe(false)
|
||||
} finally {
|
||||
warnSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('isExtensionInstalled', () => {
|
||||
it('returns false for uninstalled extension', () => {
|
||||
const store = useExtensionStore()
|
||||
expect(store.isExtensionInstalled('missing')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isExtensionEnabled / loadDisabledExtensionNames', () => {
|
||||
it('all extensions are enabled by default', () => {
|
||||
const store = useExtensionStore()
|
||||
store.registerExtension({ name: 'fresh' })
|
||||
expect(store.isExtensionEnabled('fresh')).toBe(true)
|
||||
})
|
||||
|
||||
it('disables extensions from provided list', () => {
|
||||
const store = useExtensionStore()
|
||||
store.loadDisabledExtensionNames(['off.ext'])
|
||||
store.registerExtension({ name: 'off.ext' })
|
||||
expect(store.isExtensionEnabled('off.ext')).toBe(false)
|
||||
})
|
||||
|
||||
it('always disables hardcoded extensions', () => {
|
||||
const store = useExtensionStore()
|
||||
store.loadDisabledExtensionNames([])
|
||||
store.registerExtension({ name: 'pysssss.Locking' })
|
||||
store.registerExtension({ name: 'regular.ext' })
|
||||
|
||||
expect(store.isExtensionEnabled('pysssss.Locking')).toBe(false)
|
||||
expect(store.isExtensionEnabled('pysssss.SnapToGrid')).toBe(false)
|
||||
expect(store.isExtensionEnabled('pysssss.FaviconStatus')).toBe(false)
|
||||
expect(store.isExtensionEnabled('KJNodes.browserstatus')).toBe(false)
|
||||
expect(store.isExtensionEnabled('regular.ext')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('enabledExtensions', () => {
|
||||
it('filters out disabled extensions', () => {
|
||||
const store = useExtensionStore()
|
||||
store.loadDisabledExtensionNames(['ext.off'])
|
||||
store.registerExtension({ name: 'ext.on' })
|
||||
store.registerExtension({ name: 'ext.off' })
|
||||
|
||||
const enabled = store.enabledExtensions
|
||||
expect(enabled).toHaveLength(1)
|
||||
expect(enabled[0].name).toBe('ext.on')
|
||||
})
|
||||
})
|
||||
|
||||
describe('isExtensionReadOnly', () => {
|
||||
it('returns true for always-disabled extensions', () => {
|
||||
const store = useExtensionStore()
|
||||
expect(store.isExtensionReadOnly('pysssss.Locking')).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false for normal extensions', () => {
|
||||
const store = useExtensionStore()
|
||||
expect(store.isExtensionReadOnly('some.custom.ext')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('inactiveDisabledExtensionNames', () => {
|
||||
it('returns disabled names not currently installed', () => {
|
||||
const store = useExtensionStore()
|
||||
store.loadDisabledExtensionNames(['ghost.ext', 'installed.ext'])
|
||||
store.registerExtension({ name: 'installed.ext' })
|
||||
|
||||
expect(store.inactiveDisabledExtensionNames).toContain('ghost.ext')
|
||||
expect(store.inactiveDisabledExtensionNames).not.toContain(
|
||||
'installed.ext'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('core extensions', () => {
|
||||
it('captures current extensions as core', () => {
|
||||
const store = useExtensionStore()
|
||||
store.registerExtension({ name: 'core.a' })
|
||||
store.registerExtension({ name: 'core.b' })
|
||||
store.captureCoreExtensions()
|
||||
|
||||
expect(store.isCoreExtension('core.a')).toBe(true)
|
||||
expect(store.isCoreExtension('core.b')).toBe(true)
|
||||
})
|
||||
|
||||
it('identifies third-party extensions registered after capture', () => {
|
||||
const store = useExtensionStore()
|
||||
store.registerExtension({ name: 'core.x' })
|
||||
store.captureCoreExtensions()
|
||||
|
||||
expect(store.hasThirdPartyExtensions).toBe(false)
|
||||
|
||||
store.registerExtension({ name: 'third.party' })
|
||||
expect(store.hasThirdPartyExtensions).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false for isCoreExtension before capture', () => {
|
||||
const store = useExtensionStore()
|
||||
store.registerExtension({ name: 'ext.pre' })
|
||||
expect(store.isCoreExtension('ext.pre')).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,76 +0,0 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { ComfyWidgets } from '@/scripts/widgets'
|
||||
import { useWidgetStore } from '@/stores/widgetStore'
|
||||
|
||||
vi.mock('@/scripts/widgets', () => ({
|
||||
ComfyWidgets: {
|
||||
INT: vi.fn(),
|
||||
FLOAT: vi.fn(),
|
||||
STRING: vi.fn(),
|
||||
BOOLEAN: vi.fn(),
|
||||
COMBO: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/schemas/nodeDefSchema', () => ({
|
||||
getInputSpecType: (spec: unknown[]) => spec[0]
|
||||
}))
|
||||
|
||||
describe('widgetStore', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
describe('widgets getter', () => {
|
||||
it('includes custom widgets after registration', () => {
|
||||
const store = useWidgetStore()
|
||||
const customFn = vi.fn()
|
||||
store.registerCustomWidgets({ CUSTOM_TYPE: customFn })
|
||||
expect(store.widgets.get('CUSTOM_TYPE')).toBe(customFn)
|
||||
})
|
||||
|
||||
it('core widgets take precedence over custom widgets with same key', () => {
|
||||
const store = useWidgetStore()
|
||||
const override = vi.fn()
|
||||
store.registerCustomWidgets({ INT: override })
|
||||
expect(store.widgets.get('INT')).toBe(ComfyWidgets.INT)
|
||||
})
|
||||
})
|
||||
|
||||
describe('inputIsWidget', () => {
|
||||
it('returns true for known widget type (v1 spec)', () => {
|
||||
const store = useWidgetStore()
|
||||
expect(store.inputIsWidget(['INT', {}] as const)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false for unknown type (v1 spec)', () => {
|
||||
const store = useWidgetStore()
|
||||
expect(store.inputIsWidget(['UNKNOWN_TYPE', {}] as const)).toBe(false)
|
||||
})
|
||||
|
||||
it('returns true for v2 spec with known type', () => {
|
||||
const store = useWidgetStore()
|
||||
expect(store.inputIsWidget({ type: 'STRING', name: 'test_input' })).toBe(
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
it('returns false for v2 spec with unknown type', () => {
|
||||
const store = useWidgetStore()
|
||||
expect(store.inputIsWidget({ type: 'LATENT', name: 'test_input' })).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
it('returns true for custom registered type', () => {
|
||||
const store = useWidgetStore()
|
||||
store.registerCustomWidgets({ MY_WIDGET: vi.fn() })
|
||||
expect(
|
||||
store.inputIsWidget({ type: 'MY_WIDGET', name: 'test_input' })
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user