Compare commits
3 Commits
codex/code
...
unify-webs
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9a986bcd2b | ||
|
|
ea7a923b6b | ||
|
|
108a63f2eb |
@@ -63,47 +63,3 @@ reviews:
|
||||
Pass if none of these patterns are found in the diff.
|
||||
|
||||
When warning, reference the specific ADR by number and link to `docs/adr/` for context. Frame findings as directional guidance since ADR 0003 and 0008 are in Proposed status.
|
||||
|
||||
path_instructions:
|
||||
- path: '**/*.test.ts'
|
||||
instructions: |
|
||||
Treat `.agents/checks/test-quality.md`, `docs/testing/README.md`, and `docs/guidance/vitest.md` as required review context for every changed Vitest test file.
|
||||
Flag missing behavioral coverage for changed behavior, change-detector tests, mock-heavy tests, snapshot abuse, fragile assertions, missing edge cases, unclear setup, and unrestored global mutations.
|
||||
Prefer colocated behavioral tests named after the source file.
|
||||
Build partial mocks with fromPartial<T>() from @total-typescript/shoehorn; flag `as unknown as` double assertions and fromAny().
|
||||
Mock only at seams (Pinia stores, settings, third-party libs); flag mocked type guards or sibling composables.
|
||||
Use a real createI18n instance rather than vi.mock('vue-i18n').
|
||||
Flag bare expect(fn).not.toThrow() as a sole assertion, assertions that echo stub return values, and .mock.results assertions.
|
||||
For rejected promises, thrown errors, and failed async calls, require assertions for post-error state cleanup and side-effect rollback, especially auth tokens, API keys, globals, listeners, timers, subscriptions, and caches that production mutates before awaiting.
|
||||
Tests for production changes must exercise the changed runtime/public entrypoint directly; flag helper-only coverage when the changed branch runs through a higher-level entrypoint.
|
||||
Use @testing-library/vue for component tests, not @vue/test-utils.
|
||||
For platform-owned types (Response, CustomEvent, DOM events), require real instances (new Response(), Response.json(), new CustomEvent()) instead of fromPartial or casts.
|
||||
When a fixture cast hides a too-wide production signature, suggest narrowing the production type instead of casting the fixture.
|
||||
Flag process-level listeners (process.on('unhandledRejection')) in tests; assert the rejected promise directly.
|
||||
Tests should import the module under test from its public entrypoint, not deep internal paths.
|
||||
- path: 'src/scripts/app*.test.ts'
|
||||
instructions: |
|
||||
For app tests that mock or spy on `api.queuePrompt`, require rejected-queue coverage whenever the code temporarily populates `api.authToken` or `api.apiKey`.
|
||||
The rejected-path test must populate both values, make `api.queuePrompt` reject, and assert both fields are cleared afterward; success-path cleanup alone is insufficient.
|
||||
If `api.queuePrompt` rejection is tested without asserting `api.authToken` and `api.apiKey` cleanup afterward, flag the missing assertion even when a success-path test already covers cleanup.
|
||||
- path: 'src/scripts/app.ts'
|
||||
instructions: |
|
||||
When app queuing code temporarily assigns `api.authToken` or `api.apiKey` before awaiting `api.queuePrompt`, require app tests for both resolved and rejected `api.queuePrompt` paths.
|
||||
The rejected-path test must prove both credential fields are cleared after `api.queuePrompt` rejects; a rejection assertion without cleanup checks is insufficient.
|
||||
- path: 'src/lib/litegraph/src/LGraph.ts'
|
||||
instructions: |
|
||||
When changes touch `LGraph.configure()`, graph deserialization, link loading, or nullable serialized links, require a direct regression test that constructs an `LGraph` and calls `configure()` with the changed serialized shape.
|
||||
Helper-only coverage is insufficient for these changes. For sparse legacy links, expect a v0.4 workflow with `links: [null, validLink]` to not throw and still create the valid link.
|
||||
- path: 'src/lib/litegraph/**/*.test.ts'
|
||||
instructions: |
|
||||
Treat `.agents/checks/test-quality.md`, `docs/testing/README.md`, and `docs/guidance/vitest.md` as required review context for every changed litegraph Vitest test file.
|
||||
Reuse shared factories in `src/utils/__tests__/litegraphTestUtils.ts` instead of hand-rolling litegraph mock builders.
|
||||
Flag mocked litegraph classes when a real instance or shared factory would exercise behavior directly.
|
||||
- path: '{browser_tests,apps/website/e2e}/**/*.spec.ts'
|
||||
instructions: |
|
||||
Treat `.agents/checks/test-quality.md` and `docs/testing/README.md` as required review context for every changed Playwright test file.
|
||||
Flag missing behavioral coverage, change-detector tests, mock-heavy tests, snapshot abuse, fragile assertions, missing edge cases, unclear setup, and test isolation problems.
|
||||
Every route.fulfill() body must be typed with generated types or schemas from packages/ingest-types, packages/registry-types, src/workbench/extensions/manager/types/generatedManagerTypes.ts, or src/schemas/; flag untyped inline JSON objects.
|
||||
Never use waitForTimeout; use Locator actions and auto-retrying assertions instead.
|
||||
Restrict page.evaluate() to reading internal state or fixture setup; flag any page.evaluate() that drives UI actions when a Playwright action method exists.
|
||||
New shared test helpers must be Playwright fixtures via base.extend(), not properties added to ComfyPage.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
name: Post Release Summary Comment
|
||||
description: Post or update a PR comment summarizing release links with diff, derived versions, and optional extras.
|
||||
description: Upsert a release-links section (diff, derived versions, optional extras) into the unified PR report comment.
|
||||
author: ComfyUI Frontend Team
|
||||
|
||||
inputs:
|
||||
@@ -49,7 +49,7 @@ runs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
MARKER='release-summary'
|
||||
SECTION_NAME='release'
|
||||
MESSAGE='Publish jobs finished successfully:'
|
||||
LINKS_VALUE=''
|
||||
|
||||
@@ -60,7 +60,7 @@ runs:
|
||||
'npm types|https://www.npmjs.com/package/@comfyorg/comfyui-frontend-types/v/{{version}}')
|
||||
;;
|
||||
apps/desktop-ui/package.json)
|
||||
MARKER='desktop-release-summary'
|
||||
SECTION_NAME='desktop-release'
|
||||
LINKS_VALUE='npm desktop UI|https://www.npmjs.com/package/@comfyorg/desktop-ui/v/{{version}}'
|
||||
;;
|
||||
esac
|
||||
@@ -71,7 +71,6 @@ runs:
|
||||
COMMENT_FILE=$(mktemp)
|
||||
|
||||
{
|
||||
echo "<!--$MARKER:$DIFF_PREFIX$NEW_VERSION-->"
|
||||
echo "$MESSAGE"
|
||||
echo ""
|
||||
echo "- $DIFF_LABEL: [\`$DIFF_PREFIX$PREV_VERSION...$DIFF_PREFIX$NEW_VERSION\`]($DIFF_URL)"
|
||||
@@ -99,21 +98,14 @@ runs:
|
||||
echo "COMMENT_BODY_END_MARKER"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
echo "prev_version=$PREV_VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "marker_search=<!--$MARKER:" >> "$GITHUB_OUTPUT"
|
||||
echo "section_name=$SECTION_NAME" >> "$GITHUB_OUTPUT"
|
||||
echo "new_version=$NEW_VERSION" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Find existing comment
|
||||
id: find
|
||||
uses: peter-evans/find-comment@b30e6a3c0ed37e7c023ccd3f1db5c6c0b0c23aad # v4.0.0
|
||||
- name: Upsert release section into unified report
|
||||
uses: ./.github/actions/upsert-comment-section
|
||||
with:
|
||||
issue-number: ${{ inputs.issue-number || github.event.pull_request.number }}
|
||||
comment-author: github-actions[bot]
|
||||
body-includes: ${{ steps.build.outputs.marker_search }}
|
||||
|
||||
- name: Post or update comment
|
||||
uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9 # v5.0.0
|
||||
with:
|
||||
issue-number: ${{ inputs.issue-number || github.event.pull_request.number }}
|
||||
comment-id: ${{ steps.find.outputs.comment-id }}
|
||||
body: ${{ steps.build.outputs.body }}
|
||||
edit-mode: replace
|
||||
pr-number: ${{ inputs.issue-number || github.event.pull_request.number }}
|
||||
section-name: ${{ steps.build.outputs.section_name }}
|
||||
section-content: ${{ steps.build.outputs.body }}
|
||||
comment-marker: '<!-- COMFYUI_FRONTEND_PR_REPORT -->'
|
||||
token: ${{ github.token }}
|
||||
|
||||
@@ -49,36 +49,75 @@ runs:
|
||||
|
||||
// Escape special regex characters in delimiter strings
|
||||
const escapeRegex = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
|
||||
const comments = await github.paginate(
|
||||
github.rest.issues.listComments,
|
||||
{ ...context.repo, issue_number: prNumber }
|
||||
)
|
||||
|
||||
const existing = comments.find(
|
||||
(c) =>
|
||||
c.user?.login === 'github-actions[bot]' &&
|
||||
c.body?.includes(commentMarker)
|
||||
)
|
||||
|
||||
if (!existing) {
|
||||
return github.rest.issues.createComment({
|
||||
...context.repo,
|
||||
issue_number: prNumber,
|
||||
body: `${commentMarker}\n${sectionBlock}`
|
||||
})
|
||||
}
|
||||
|
||||
const body = existing.body ?? ''
|
||||
const sectionRegex = new RegExp(
|
||||
`${escapeRegex(sectionStart)}[\\s\\S]*?${escapeRegex(sectionEnd)}`
|
||||
)
|
||||
const updated = sectionRegex.test(body)
|
||||
? body.replace(sectionRegex, sectionBlock)
|
||||
: body.trimEnd() + '\n\n' + sectionBlock
|
||||
|
||||
return github.rest.issues.updateComment({
|
||||
...context.repo,
|
||||
comment_id: existing.id,
|
||||
body: updated
|
||||
})
|
||||
// Every section-start marker in a body, used to detect whether a
|
||||
// concurrent writer's section was lost across our write.
|
||||
const sectionStartsIn = (body) =>
|
||||
body.match(/<!-- section:[a-z0-9-]+:start -->/g) ?? []
|
||||
|
||||
const findComment = async () => {
|
||||
const comments = await github.paginate(
|
||||
github.rest.issues.listComments,
|
||||
{ ...context.repo, issue_number: prNumber }
|
||||
)
|
||||
return comments.find(
|
||||
(c) =>
|
||||
c.user?.login === 'github-actions[bot]' &&
|
||||
c.body?.includes(commentMarker)
|
||||
)
|
||||
}
|
||||
|
||||
// Several workflows share one comment via read-modify-write, which the
|
||||
// REST API can't do atomically. Re-read immediately before each write
|
||||
// and verify our section plus every previously-present section
|
||||
// survived; on a detected clobber, re-read and retry so writers
|
||||
// converge instead of silently dropping each other's sections.
|
||||
const maxAttempts = 5
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||
const existing = await findComment()
|
||||
|
||||
if (!existing) {
|
||||
try {
|
||||
await github.rest.issues.createComment({
|
||||
...context.repo,
|
||||
issue_number: prNumber,
|
||||
body: `${commentMarker}\n${sectionBlock}`
|
||||
})
|
||||
return
|
||||
} catch (err) {
|
||||
if (attempt === maxAttempts) throw err
|
||||
continue // another writer likely created it first; retry updates
|
||||
}
|
||||
}
|
||||
|
||||
const body = existing.body ?? ''
|
||||
const expectedSections = sectionStartsIn(body)
|
||||
const updated = sectionRegex.test(body)
|
||||
? body.replace(sectionRegex, sectionBlock)
|
||||
: body.trimEnd() + '\n\n' + sectionBlock
|
||||
|
||||
await github.rest.issues.updateComment({
|
||||
...context.repo,
|
||||
comment_id: existing.id,
|
||||
body: updated
|
||||
})
|
||||
|
||||
const current = (await findComment())?.body ?? ''
|
||||
const survived =
|
||||
current.includes(sectionBlock) &&
|
||||
expectedSections.every((s) => current.includes(s))
|
||||
if (survived) return
|
||||
|
||||
if (attempt === maxAttempts) {
|
||||
core.warning(
|
||||
`upsert-comment-section: section "${sectionName}" may have been clobbered by a concurrent writer after ${maxAttempts} attempts`
|
||||
)
|
||||
return
|
||||
}
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(resolve, 250 * attempt + Math.floor(Math.random() * 250))
|
||||
)
|
||||
}
|
||||
|
||||
197
.github/workflows/backport-auto-merge.yaml
vendored
@@ -1,197 +0,0 @@
|
||||
---
|
||||
name: Backport Auto-Merge
|
||||
|
||||
# Completes the merge of backport PRs once they are approved and their required
|
||||
# checks pass.
|
||||
#
|
||||
# Background: pr-backport.yaml opens each backport PR (labelled `backport`) and
|
||||
# calls `gh pr merge --auto`, which relies on the repo-level "Allow auto-merge"
|
||||
# setting. That setting is off, so `--auto` is a silent no-op and backport PRs
|
||||
# sit unmerged until a human clicks merge. This workflow performs the merge
|
||||
# directly (a plain `gh pr merge --squash`, which does not depend on that
|
||||
# setting) once GitHub itself reports the PR as ready to merge.
|
||||
#
|
||||
# Safety: branch protection on core/** and cloud/** is the hard gate — it
|
||||
# unconditionally requires an approval + the required status checks and cannot
|
||||
# be bypassed, and GitHub's merge API re-enforces it at merge time. This
|
||||
# workflow can only ever complete a merge that already satisfies those rules;
|
||||
# the eligibility check below only avoids pointless merge attempts.
|
||||
#
|
||||
# The merge uses PR_GH_TOKEN (not the default GITHUB_TOKEN) on purpose: a merge
|
||||
# performed by the default token does not emit events that trigger other
|
||||
# workflows, which would silently starve cloud-backport-tag.yaml (it runs on the
|
||||
# backport PR's `pull_request: closed` event to create the release tag).
|
||||
|
||||
on:
|
||||
# Fires when someone approves — if the required checks are already green, the
|
||||
# PR merges immediately.
|
||||
pull_request_review:
|
||||
types: [submitted]
|
||||
# Primary catch for the "approved first, checks went green later" case, plus a
|
||||
# general backstop. A `check_suite`/`workflow_run` trigger would react faster to
|
||||
# checks completing, but GitHub suppresses `check_suite` events for its own
|
||||
# Actions suites (so it wouldn't fire for this repo's CI), and `workflow_run` is
|
||||
# a secrets-bearing "dangerous" trigger we don't want on a public repo for a
|
||||
# non-latency-critical task. Backports wait hours today, so a short sweep is a
|
||||
# large improvement and needs neither.
|
||||
schedule:
|
||||
- cron: '*/15 * * * *'
|
||||
|
||||
# Only constrains the default github.token (used for read-only PR lookups below).
|
||||
# It does NOT constrain PR_GH_TOKEN, whose authority is fixed by its own scopes.
|
||||
permissions:
|
||||
contents: read # read-only; required for gh api / gh pr list to resolve candidates
|
||||
pull-requests: read # read-only; required for gh pr view eligibility checks
|
||||
|
||||
# Serialize runs that act on the same PR (review events keyed by PR number; all
|
||||
# scheduled sweeps share one key). Cross-key overlaps are still possible but
|
||||
# harmless: the merge loop treats an already-merged PR as success (idempotent).
|
||||
concurrency:
|
||||
group: backport-auto-merge-${{ github.event.pull_request.number || 'sweep' }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
merge:
|
||||
name: Merge eligible backport PRs
|
||||
# Skip review events that can't possibly make a PR mergeable — non-approval
|
||||
# reviews, or reviews on non-backport PRs (most reviews in the repo) — before
|
||||
# spending any API call. Schedule sweeps always proceed. The per-PR
|
||||
# eligibility checks in the job still re-verify the label and decision from
|
||||
# live state.
|
||||
if: github.event_name != 'pull_request_review' || (github.event.review.state == 'approved' && contains(github.event.pull_request.labels.*.name, 'backport'))
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read # read-only PR/commit lookups via the default token
|
||||
pull-requests: read # read-only PR metadata via the default token
|
||||
steps:
|
||||
- name: Collect candidate backport PRs
|
||||
id: candidates
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
PR_FROM_REVIEW: ${{ github.event.pull_request.number }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
numbers=""
|
||||
case "$EVENT_NAME" in
|
||||
pull_request_review)
|
||||
numbers="$PR_FROM_REVIEW"
|
||||
;;
|
||||
schedule)
|
||||
# Sweep every open backport PR.
|
||||
numbers=$(gh pr list --repo "$GH_REPO" --state open --label backport \
|
||||
--limit 100 --json number --jq '.[].number')
|
||||
;;
|
||||
esac
|
||||
# De-duplicate and emit space-separated, digit-only tokens.
|
||||
numbers=$(echo "$numbers" | tr ' ' '\n' | grep -E '^[0-9]+$' | sort -u | tr '\n' ' ' || true)
|
||||
echo "numbers=${numbers}" >> "$GITHUB_OUTPUT"
|
||||
echo "Candidate PRs: '${numbers:-<none>}'"
|
||||
|
||||
- name: Merge eligible backport PRs
|
||||
if: steps.candidates.outputs.numbers != ''
|
||||
env:
|
||||
GH_REPO: ${{ github.repository }}
|
||||
# Read with the default token; merge with PR_GH_TOKEN so the merge emits
|
||||
# the events that downstream workflows (cloud-backport-tag.yaml) rely on.
|
||||
READ_TOKEN: ${{ github.token }}
|
||||
MERGE_TOKEN: ${{ secrets.PR_GH_TOKEN }}
|
||||
CANDIDATES: ${{ steps.candidates.outputs.numbers }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
is_merged() {
|
||||
[ "$(GH_TOKEN="$READ_TOKEN" gh pr view "$1" --repo "$GH_REPO" --json merged --jq '.merged' 2>/dev/null || echo false)" = "true" ]
|
||||
}
|
||||
|
||||
for pr in $CANDIDATES; do
|
||||
echo "::group::PR #${pr}"
|
||||
|
||||
info=$(GH_TOKEN="$READ_TOKEN" gh pr view "$pr" --repo "$GH_REPO" \
|
||||
--json number,state,isDraft,labels,baseRefName,reviewDecision,mergeStateStatus 2>/dev/null || echo '')
|
||||
if [ -z "$info" ]; then
|
||||
echo "Could not read PR #${pr} — skipping."; echo "::endgroup::"; continue
|
||||
fi
|
||||
|
||||
state=$(echo "$info" | jq -r '.state')
|
||||
is_draft=$(echo "$info" | jq -r '.isDraft')
|
||||
is_backport=$(echo "$info" | jq -r '[.labels[].name] | any(. == "backport")')
|
||||
base=$(echo "$info" | jq -r '.baseRefName')
|
||||
review=$(echo "$info" | jq -r '.reviewDecision')
|
||||
merge_state=$(echo "$info" | jq -r '.mergeStateStatus')
|
||||
|
||||
# Only ever act on open, non-draft, backport-labelled PRs targeting a
|
||||
# protected release branch.
|
||||
if [ "$state" != "OPEN" ] || [ "$is_draft" != "false" ] || [ "$is_backport" != "true" ]; then
|
||||
echo "Not an actionable backport PR (state=$state draft=$is_draft backport=$is_backport) — skipping."
|
||||
echo "::endgroup::"; continue
|
||||
fi
|
||||
case "$base" in
|
||||
cloud/*|core/*) : ;;
|
||||
*) echo "Base '$base' is not a release branch — skipping."; echo "::endgroup::"; continue ;;
|
||||
esac
|
||||
|
||||
# Ready = approved AND GitHub says it's mergeable with required checks green.
|
||||
# CLEAN = approved, all required checks green, mergeable, no conflict.
|
||||
# UNSTABLE = same, but a NON-required check is pending/failing — GitHub
|
||||
# still allows the merge, so we do too (matches what a human
|
||||
# clicking "Squash and merge" can do; required checks are the
|
||||
# only merge gate per the ruleset). Requiring CLEAN alone would
|
||||
# stick forever behind flaky/slow non-required checks.
|
||||
# Any other state (BLOCKED/DIRTY/BEHIND/UNKNOWN/...) => not ready; re-checked
|
||||
# by a later event or the next sweep.
|
||||
if [ "$review" != "APPROVED" ] || { [ "$merge_state" != "CLEAN" ] && [ "$merge_state" != "UNSTABLE" ]; }; then
|
||||
echo "Not yet ready (reviewDecision=$review mergeStateStatus=$merge_state) — will re-check later."
|
||||
echo "::endgroup::"; continue
|
||||
fi
|
||||
|
||||
echo "PR #${pr} is ready — attempting squash merge."
|
||||
attempt=0
|
||||
max=3
|
||||
merged=false
|
||||
while [ "$attempt" -lt "$max" ]; do
|
||||
attempt=$((attempt + 1))
|
||||
# A concurrent run (or a human) may have merged it already.
|
||||
if is_merged "$pr"; then merged=true; break; fi
|
||||
if out=$(GH_TOKEN="$MERGE_TOKEN" gh pr merge "$pr" --repo "$GH_REPO" --squash 2>&1); then
|
||||
merged=true; break
|
||||
fi
|
||||
echo "Merge attempt ${attempt}/${max} failed: ${out}"
|
||||
# No sleep after the final attempt.
|
||||
[ "$attempt" -lt "$max" ] && sleep $((attempt * 15))
|
||||
done
|
||||
|
||||
# Final reconciliation: a failed merge command may just mean a concurrent
|
||||
# run won the race — don't post a false failure if the PR is in fact merged.
|
||||
if [ "$merged" != "true" ] && is_merged "$pr"; then merged=true; fi
|
||||
|
||||
if [ "$merged" = "true" ]; then
|
||||
echo "PR #${pr} merged."
|
||||
else
|
||||
echo "::warning::PR #${pr} looked ready but did not merge after ${max} attempts."
|
||||
# Avoid spamming a persistently-stuck PR: only re-warn if the last
|
||||
# warning (identified by its marker) is more than an hour old.
|
||||
marker='<!-- backport-auto-merge:merge-failed -->'
|
||||
# `gh api --paginate` emits one JSON array per page; `--jq` would run
|
||||
# per page (missing the true latest across pages), so slurp all pages
|
||||
# into one array first and filter with a separate jq pass.
|
||||
last_warned=$(GH_TOKEN="$READ_TOKEN" gh api "repos/${GH_REPO}/issues/${pr}/comments" --paginate 2>/dev/null \
|
||||
| jq -s "[.[][] | select(.body | contains(\"${marker}\"))] | sort_by(.created_at) | last | .created_at // empty") || last_warned=''
|
||||
stale=true
|
||||
if [ -n "$last_warned" ]; then
|
||||
last_epoch=$(date -d "$last_warned" +%s 2>/dev/null || echo 0)
|
||||
now_epoch=$(date -u +%s)
|
||||
[ $((now_epoch - last_epoch)) -lt 3600 ] && stale=false
|
||||
fi
|
||||
if [ "$stale" = "true" ]; then
|
||||
body=$(printf '%s\n\n%s' \
|
||||
"This backport PR is approved and its required checks are green, but automatic merge failed after ${max} attempts. Please merge manually or investigate (possible branch-protection mismatch)." \
|
||||
"$marker")
|
||||
GH_TOKEN="$MERGE_TOKEN" gh pr comment "$pr" --repo "$GH_REPO" --body "$body" || true
|
||||
else
|
||||
echo "Already warned within the last hour — skipping duplicate comment."
|
||||
fi
|
||||
fi
|
||||
echo "::endgroup::"
|
||||
done
|
||||
4
.github/workflows/ci-website-e2e.yaml
vendored
@@ -193,7 +193,7 @@ jobs:
|
||||
with:
|
||||
pr-number: ${{ github.event.pull_request.number }}
|
||||
section-name: e2e
|
||||
comment-marker: '<!-- WEBSITE_CI_REPORT -->'
|
||||
comment-marker: '<!-- COMFYUI_FRONTEND_PR_REPORT -->'
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
section-content: |-
|
||||
## 🌐 Website E2E
|
||||
@@ -273,6 +273,6 @@ jobs:
|
||||
with:
|
||||
pr-number: ${{ github.event.pull_request.number }}
|
||||
section-name: e2e
|
||||
comment-marker: '<!-- WEBSITE_CI_REPORT -->'
|
||||
comment-marker: '<!-- COMFYUI_FRONTEND_PR_REPORT -->'
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
section-content: ${{ steps.content.outputs.section-content }}
|
||||
|
||||
6
.github/workflows/cla.yml
vendored
@@ -5,8 +5,6 @@ on:
|
||||
types: [created]
|
||||
pull_request_target:
|
||||
types: [opened, synchronize, closed]
|
||||
merge_group:
|
||||
types: [checks_requested]
|
||||
|
||||
permissions:
|
||||
actions: write
|
||||
@@ -18,10 +16,6 @@ jobs:
|
||||
cla-assistant:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: CLA already verified before merge queue
|
||||
if: github.event_name == 'merge_group'
|
||||
run: echo "CLA is checked on the pull request before it enters merge queue."
|
||||
|
||||
# The CLA action normally requires every commit author in a PR to sign.
|
||||
# We only want the PR author to sign, so we allowlist all other committers
|
||||
# by computing them from the PR's commits and excluding the PR author.
|
||||
|
||||
1
.github/workflows/pr-report.yaml
vendored
@@ -142,6 +142,7 @@ jobs:
|
||||
'<!-- COMFYUI_FRONTEND_PERF -->',
|
||||
'<!-- PLAYWRIGHT_TEST_STATUS -->',
|
||||
'<!-- STORYBOOK_BUILD_STATUS -->',
|
||||
'<!-- WEBSITE_CI_REPORT -->',
|
||||
];
|
||||
|
||||
const comments = await github.paginate(github.rest.issues.listComments, {
|
||||
|
||||
@@ -179,7 +179,7 @@ jobs:
|
||||
with:
|
||||
pr-number: ${{ github.event.number || github.event.issue.number }}
|
||||
section-name: screenshot-update
|
||||
comment-marker: '<!-- WEBSITE_CI_REPORT -->'
|
||||
comment-marker: '<!-- COMFYUI_FRONTEND_PR_REPORT -->'
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
section-content: |-
|
||||
## 📸 Screenshot Update
|
||||
@@ -240,6 +240,6 @@ jobs:
|
||||
with:
|
||||
pr-number: ${{ needs.update-screenshots.outputs.pr-number }}
|
||||
section-name: screenshot-update
|
||||
comment-marker: '<!-- WEBSITE_CI_REPORT -->'
|
||||
comment-marker: '<!-- COMFYUI_FRONTEND_PR_REPORT -->'
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
section-content: ${{ steps.content.outputs.section-content }}
|
||||
|
||||
@@ -58,7 +58,7 @@ jobs:
|
||||
with:
|
||||
pr-number: ${{ steps.pr-meta.outputs.number }}
|
||||
section-name: preview
|
||||
comment-marker: '<!-- WEBSITE_CI_REPORT -->'
|
||||
comment-marker: '<!-- COMFYUI_FRONTEND_PR_REPORT -->'
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
section-content: |-
|
||||
## 🔗 Website Preview
|
||||
|
||||
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 87 KiB After Width: | Height: | Size: 87 KiB |
|
Before Width: | Height: | Size: 87 KiB After Width: | Height: | Size: 87 KiB |
|
Before Width: | Height: | Size: 51 KiB After Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 92 KiB After Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 95 KiB After Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 6.5 KiB |
|
Before Width: | Height: | Size: 938 B After Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 56 KiB |
@@ -1,10 +0,0 @@
|
||||
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_1483_15836)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M196.373 184.704V136.491C196.373 132.437 197.909 129.387 201.451 127.36L298.368 71.552C311.573 63.936 327.296 60.3947 343.531 60.3947C404.416 60.3947 442.987 107.584 442.987 157.803C442.987 161.365 442.987 165.419 442.475 169.472L341.995 110.613C339.266 108.876 336.099 107.954 332.864 107.954C329.629 107.954 326.462 108.876 323.733 110.613L196.373 184.704ZM422.699 372.437V257.28C422.699 250.176 419.648 245.12 413.547 241.557L286.187 167.467L327.787 143.616C329.294 142.624 331.059 142.095 332.864 142.095C334.669 142.095 336.434 142.624 337.941 143.616L434.859 199.445C462.784 215.659 481.557 250.176 481.557 283.669C481.557 322.24 458.731 357.76 422.677 372.48L422.699 372.437ZM166.443 270.997L124.843 246.635C121.28 244.608 119.744 241.557 119.744 237.504V125.845C119.744 71.552 161.344 30.4427 217.685 30.4427C239.019 30.4427 258.795 37.5467 275.541 50.24L175.573 108.096C169.493 111.637 166.443 116.715 166.443 123.819V270.976V270.997ZM256 322.731L196.373 289.237V218.197L256 184.704L315.627 218.197V289.237L256 322.731ZM294.315 476.971C272.981 476.971 253.205 469.888 236.459 457.195L336.427 399.339C342.507 395.797 345.557 390.72 345.557 383.616V236.459L387.669 260.821C391.232 262.848 392.747 265.899 392.747 269.952V381.589C392.747 435.883 350.635 476.971 294.315 476.971ZM174.059 363.84L77.12 308.011C49.216 291.776 30.4427 257.28 30.4427 223.787C30.3769 204.756 35.9917 186.138 46.5684 170.317C57.1451 154.495 72.2025 142.19 89.8133 134.976V250.667C89.8133 257.771 92.864 262.848 98.944 266.411L225.813 339.989L184.213 363.84C182.707 364.835 180.941 365.365 179.136 365.365C177.331 365.365 175.565 364.835 174.059 363.84ZM168.469 447.04C111.125 447.04 69.0133 403.925 69.0133 350.635C69.0133 346.581 69.5253 342.528 70.016 338.475L169.984 396.288C176.085 399.851 182.165 399.851 188.245 396.288L315.605 322.731V370.944C315.605 374.997 314.112 378.048 310.549 380.075L213.632 435.883C200.427 443.499 184.704 447.04 168.469 447.04ZM294.315 507.413C323.553 507.416 351.895 497.319 374.547 478.831C397.198 460.343 412.768 434.598 418.624 405.952C475.456 391.232 512 337.92 512 283.648C512 248.128 496.789 213.632 469.376 188.757C471.915 178.091 473.429 167.445 473.429 156.8C473.429 84.2453 414.571 29.9307 346.581 29.9307C332.885 29.9307 319.701 31.9573 306.475 36.544C282.795 13.2354 250.933 0.118797 217.707 1.37049e-07C188.465 -0.00135846 160.121 10.0985 137.469 28.5908C114.817 47.0831 99.2486 72.8325 93.3973 101.483C36.544 116.203 0 169.493 0 223.787C0 259.328 15.2107 293.824 42.624 318.677C40.0853 329.344 38.5707 340.011 38.5707 350.656C38.5707 423.211 97.4293 477.504 165.419 477.504C179.115 477.504 192.299 475.477 205.525 470.912C229.208 494.23 261.08 507.347 294.315 507.456V507.413Z" fill="white"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_1483_15836">
|
||||
<rect width="512" height="512" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 3.0 KiB |
@@ -1,28 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { ChevronRight } from '@lucide/vue'
|
||||
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
const { hover = 'self', class: className } = defineProps<{
|
||||
hover?: 'self' | 'group'
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'flex size-10 items-center justify-center rounded-2xl bg-white/20 text-white backdrop-blur-sm transition-colors',
|
||||
hover === 'group'
|
||||
? 'group-hover:bg-primary-comfy-yellow group-hover:text-primary-comfy-ink'
|
||||
: 'hover:bg-primary-comfy-yellow hover:text-primary-comfy-ink',
|
||||
className
|
||||
)
|
||||
"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<ChevronRight class="size-5" :stroke-width="2" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,8 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import Button from '../ui/button/Button.vue'
|
||||
|
||||
const { title, description, cta, href, bg } = defineProps<{
|
||||
title: string
|
||||
description: string
|
||||
@@ -30,9 +28,11 @@ const { title, description, cta, href, bg } = defineProps<{
|
||||
<p class="text-sm text-white/70">
|
||||
{{ description }}
|
||||
</p>
|
||||
<Button as="span" variant="default" size="sm" class="mt-4">
|
||||
<span
|
||||
class="bg-primary-comfy-yellow text-primary-comfy-ink mt-4 inline-block rounded-xl px-4 py-2 text-xs font-bold tracking-wide"
|
||||
>
|
||||
{{ cta }}
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
</template>
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale } from '../../../i18n/translations'
|
||||
|
||||
import { externalLinks } from '../../../config/routes'
|
||||
import { t } from '../../../i18n/translations'
|
||||
import CardArrow from '../../common/CardArrow.vue'
|
||||
import GlassCard from '../../common/GlassCard.vue'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
@@ -29,7 +27,7 @@ const cards = [
|
||||
<template>
|
||||
<section class="max-w-9xl mx-auto px-4 pt-24 lg:px-20 lg:pt-40">
|
||||
<h2
|
||||
class="text-3.5xl/tight mx-auto max-w-3xl text-center font-light text-primary-comfy-canvas lg:text-5xl/tight"
|
||||
class="text-primary-comfy-canvas text-3.5xl/tight mx-auto max-w-3xl text-center font-light lg:text-5xl/tight"
|
||||
>
|
||||
{{ headingParts[0]
|
||||
}}<span class="text-white">{{
|
||||
@@ -39,11 +37,10 @@ const cards = [
|
||||
</h2>
|
||||
|
||||
<GlassCard class="mt-12 grid grid-cols-1 gap-6 lg:mt-20 lg:grid-cols-2">
|
||||
<a
|
||||
<div
|
||||
v-for="card in cards"
|
||||
:key="card.labelKey"
|
||||
:href="externalLinks.cloud"
|
||||
class="group rounded-4.5xl block overflow-hidden bg-primary-comfy-ink"
|
||||
class="bg-primary-comfy-ink rounded-4.5xl overflow-hidden"
|
||||
>
|
||||
<img
|
||||
:src="card.image"
|
||||
@@ -54,27 +51,23 @@ const cards = [
|
||||
/>
|
||||
|
||||
<div class="mt-8 p-6">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<p
|
||||
class="text-primary-comfy-yellow text-sm font-bold tracking-widest uppercase"
|
||||
>
|
||||
{{ t(card.labelKey, locale) }}
|
||||
</p>
|
||||
|
||||
<CardArrow hover="group" class="shrink-0" />
|
||||
</div>
|
||||
<p
|
||||
class="text-primary-comfy-yellow text-sm font-bold tracking-widest uppercase"
|
||||
>
|
||||
{{ t(card.labelKey, locale) }}
|
||||
</p>
|
||||
|
||||
<h3
|
||||
class="mt-8 text-3xl/tight font-light whitespace-pre-line text-primary-comfy-canvas"
|
||||
class="text-primary-comfy-canvas mt-8 text-3xl/tight font-light whitespace-pre-line"
|
||||
>
|
||||
{{ t(card.titleKey, locale) }}
|
||||
</h3>
|
||||
|
||||
<p class="mt-8 text-base/normal text-primary-comfy-canvas">
|
||||
<p class="text-primary-comfy-canvas mt-8 text-base/normal">
|
||||
{{ t(card.descriptionKey, locale) }}
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</GlassCard>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -17,18 +17,18 @@ const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
>
|
||||
<div class="max-w-2xl">
|
||||
<h2
|
||||
class="text-2xl/tight font-medium text-primary-comfy-ink lg:text-3xl/tight"
|
||||
class="text-primary-comfy-ink text-2xl/tight font-medium lg:text-3xl/tight"
|
||||
>
|
||||
{{ t('cloud.pricing.title', locale) }}
|
||||
</h2>
|
||||
|
||||
<p class="mt-4 text-base text-primary-comfy-ink">
|
||||
<p class="text-primary-comfy-ink mt-4 text-base">
|
||||
{{ t('cloud.pricing.description', locale) }}
|
||||
</p>
|
||||
|
||||
<p
|
||||
v-if="SHOW_FREE_TIER"
|
||||
class="mt-4 text-base font-bold text-primary-comfy-ink"
|
||||
class="text-primary-comfy-ink mt-4 text-base font-bold"
|
||||
>
|
||||
{{ t('cloud.pricing.tagline', locale) }}
|
||||
</p>
|
||||
@@ -36,7 +36,7 @@ const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
<a
|
||||
:href="getRoutes(locale).cloudPricing"
|
||||
class="text-primary-comfy-yellow shrink-0 rounded-2xl bg-primary-comfy-ink px-6 py-3 text-center text-sm font-semibold transition-opacity hover:opacity-90"
|
||||
class="bg-primary-comfy-ink text-primary-comfy-yellow shrink-0 rounded-2xl px-6 py-3 text-center text-sm font-semibold transition-opacity hover:opacity-90"
|
||||
>
|
||||
{{ t('cloud.pricing.cta', locale) }}
|
||||
</a>
|
||||
|
||||
@@ -6,7 +6,6 @@ import type { Locale } from '../../../i18n/translations'
|
||||
import { externalLinks } from '../../../config/routes'
|
||||
import { t } from '../../../i18n/translations'
|
||||
import BrandButton from '../../common/BrandButton.vue'
|
||||
import CardArrow from '../../common/CardArrow.vue'
|
||||
|
||||
type ModelCard = {
|
||||
titleKey:
|
||||
@@ -15,10 +14,11 @@ type ModelCard = {
|
||||
| 'cloud.aiModels.card.seedance20'
|
||||
| 'cloud.aiModels.card.qwenImageEdit'
|
||||
| 'cloud.aiModels.card.wan22TextToVideo'
|
||||
| 'cloud.aiModels.card.gptImage2'
|
||||
imageSrc: string
|
||||
badgeIcon: string
|
||||
badgeClass: string
|
||||
layoutClass: string
|
||||
objectPosition?: string
|
||||
}
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
@@ -32,45 +32,48 @@ const modelCards: ModelCard[] = [
|
||||
imageSrc:
|
||||
'https://media.comfy.org/website/cloud/ai-models/seedance-20.webm',
|
||||
badgeIcon: '/icons/ai-models/bytedance.svg',
|
||||
badgeClass: `${badgeBase} rounded-2xl`
|
||||
badgeClass: `${badgeBase} rounded-2xl`,
|
||||
layoutClass: 'lg:col-span-6 lg:aspect-[16/7]'
|
||||
},
|
||||
{
|
||||
titleKey: 'cloud.aiModels.card.nanoBananaPro',
|
||||
imageSrc:
|
||||
'https://media.comfy.org/website/cloud/ai-models/nano-banana-pro.webp',
|
||||
badgeIcon: '/icons/ai-models/gemini.svg',
|
||||
badgeClass: `${badgeBase} rounded-2xl`
|
||||
badgeClass: `${badgeBase} rounded-2xl`,
|
||||
layoutClass: 'lg:col-span-6 lg:aspect-[16/7]',
|
||||
objectPosition: 'center 20%'
|
||||
},
|
||||
{
|
||||
titleKey: 'cloud.aiModels.card.grokImagine',
|
||||
imageSrc: 'https://media.comfy.org/website/cloud/ai-models/grok-video.webm',
|
||||
badgeIcon: '/icons/ai-models/grok.svg',
|
||||
badgeClass: `${badgeBase} rounded-2xl`
|
||||
badgeClass: `${badgeBase} rounded-2xl`,
|
||||
layoutClass: 'lg:col-span-4 lg:aspect-[4/3]'
|
||||
},
|
||||
{
|
||||
titleKey: 'cloud.aiModels.card.qwenImageEdit',
|
||||
imageSrc:
|
||||
'https://media.comfy.org/website/cloud/ai-models/qwen-image-edit.webp',
|
||||
badgeIcon: '/icons/ai-models/qwen.svg',
|
||||
badgeClass: `${badgeBase} rounded-2xl`
|
||||
badgeClass: `${badgeBase} rounded-2xl`,
|
||||
layoutClass: 'lg:col-span-4 lg:aspect-[4/3]'
|
||||
},
|
||||
{
|
||||
titleKey: 'cloud.aiModels.card.wan22TextToVideo',
|
||||
imageSrc: 'https://media.comfy.org/website/cloud/ai-models/wan-22.webm',
|
||||
badgeIcon: '/icons/ai-models/wan.svg',
|
||||
badgeClass: `${badgeBase} rounded-2xl`
|
||||
},
|
||||
{
|
||||
titleKey: 'cloud.aiModels.card.gptImage2',
|
||||
imageSrc:
|
||||
'https://media.comfy.org/website/cloud/ai-models/gpt-image-2.webm',
|
||||
badgeIcon: '/icons/ai-models/openai.svg',
|
||||
badgeClass: `${badgeBase} rounded-2xl`
|
||||
badgeClass: `${badgeBase} rounded-2xl`,
|
||||
layoutClass: 'lg:col-span-4 lg:aspect-[4/3]'
|
||||
}
|
||||
]
|
||||
|
||||
const cardClass =
|
||||
'group relative h-72 cursor-pointer overflow-hidden rounded-3xl bg-black/40 lg:col-span-4 lg:aspect-square lg:h-auto'
|
||||
function getCardClass(layoutClass: string): string {
|
||||
return cn(
|
||||
layoutClass,
|
||||
'group relative h-72 cursor-pointer overflow-hidden rounded-4xl bg-black/40 lg:h-auto'
|
||||
)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -97,18 +100,23 @@ const cardClass =
|
||||
</p>
|
||||
|
||||
<div class="mt-16 w-full lg:mt-24">
|
||||
<div class="rounded-4xl bg-white/8 p-2 lg:p-1.5">
|
||||
<div class="rounded-4xl border border-white/12 p-2 lg:p-1.5">
|
||||
<div class="grid grid-cols-1 gap-2 lg:grid-cols-12">
|
||||
<a
|
||||
v-for="card in modelCards"
|
||||
:key="card.titleKey"
|
||||
:href="externalLinks.workflows"
|
||||
:class="cardClass"
|
||||
:class="getCardClass(card.layoutClass)"
|
||||
>
|
||||
<video
|
||||
v-if="card.imageSrc.endsWith('.webm')"
|
||||
:src="card.imageSrc"
|
||||
:aria-label="t(card.titleKey, locale)"
|
||||
:style="
|
||||
card.objectPosition
|
||||
? { objectPosition: card.objectPosition }
|
||||
: undefined
|
||||
"
|
||||
class="size-full object-cover transition-transform duration-300 group-hover:scale-105"
|
||||
autoplay
|
||||
loop
|
||||
@@ -126,6 +134,11 @@ const cardClass =
|
||||
v-else
|
||||
:src="card.imageSrc"
|
||||
:alt="t(card.titleKey, locale)"
|
||||
:style="
|
||||
card.objectPosition
|
||||
? { objectPosition: card.objectPosition }
|
||||
: undefined
|
||||
"
|
||||
class="size-full object-cover transition-transform duration-300 group-hover:scale-105"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
@@ -155,14 +168,10 @@ const cardClass =
|
||||
</div>
|
||||
|
||||
<p
|
||||
class="text-primary-warm-white absolute right-20 bottom-6 left-6 text-2xl/tight font-light whitespace-pre-line drop-shadow-[0_2px_8px_rgba(0,0,0,0.9)] lg:top-6 lg:right-auto lg:bottom-auto lg:text-3xl"
|
||||
class="text-primary-warm-white absolute inset-x-6 bottom-6 text-2xl/tight font-light whitespace-pre-line drop-shadow-[0_2px_8px_rgba(0,0,0,0.9)] lg:top-6 lg:right-auto lg:bottom-auto lg:text-3xl"
|
||||
>
|
||||
{{ t(card.titleKey, locale) }}
|
||||
</p>
|
||||
|
||||
<CardArrow
|
||||
class="absolute right-5 bottom-5 lg:right-6 lg:bottom-6"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -932,9 +932,9 @@ const translations = {
|
||||
'zh-CN': '所有模型。\n商业许可保证。'
|
||||
},
|
||||
'cloud.reason.2.description': {
|
||||
en: 'Run open-source models like Wan 2.2, Flux, LTX and Qwen alongside partner models like Nano Banana, Seedance, Seedream, Grok, Kling, Hunyuan 3D, GPT Image 2 and more. Every model on Comfy Cloud is cleared for commercial use. No license ambiguity. All through one credit balance.',
|
||||
en: 'Run open-source models like Wan 2.2, Flux, LTX and Qwen alongside partner models like Nano Banana, Seedance, Seedream, Grok, Kling, Hunyuan 3D and more. Every model on Comfy Cloud is cleared for commercial use. No license ambiguity. All through one credit balance.',
|
||||
'zh-CN':
|
||||
'运行 Wan 2.2、Flux、LTX 和 Qwen 等开源模型,以及 Nano Banana、Seedance、Seedream、Grok、Kling、Hunyuan 3D、GPT Image 2 等合作伙伴模型。Comfy Cloud 上的每个模型都已获得商业使用许可。无许可证歧义。通过统一的积分余额使用。'
|
||||
'运行 Wan 2.2、Flux、LTX 和 Qwen 等开源模型,以及 Nano Banana、Seedance、Seedream、Grok、Kling、Hunyuan 3D 等合作伙伴模型。Comfy Cloud 上的每个模型都已获得商业使用许可。无许可证歧义。通过统一的积分余额使用。'
|
||||
},
|
||||
'cloud.reason.2.badge.onlyOn': {
|
||||
en: 'ONLY ON',
|
||||
@@ -996,10 +996,6 @@ const translations = {
|
||||
en: 'Wan 2.2',
|
||||
'zh-CN': 'Wan 2.2'
|
||||
},
|
||||
'cloud.aiModels.card.gptImage2': {
|
||||
en: 'GPT Image 2',
|
||||
'zh-CN': 'GPT Image 2'
|
||||
},
|
||||
'cloud.aiModels.ctaDesktop': {
|
||||
en: 'EXPLORE WORKFLOWS WITH THE LATEST MODELS',
|
||||
'zh-CN': '探索最新模型工作流'
|
||||
|
||||
@@ -248,7 +248,7 @@
|
||||
@utility ppformula-text-center {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
top: 0.1em;
|
||||
top: 0.19em;
|
||||
}
|
||||
|
||||
/* Hide native play-button overlay iOS Safari shows when autoplay is blocked
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
describe('runWhenGlobalIdle', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('falls back to a timeout when idle callbacks are unavailable', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.stubGlobal('requestIdleCallback', undefined)
|
||||
vi.stubGlobal('cancelIdleCallback', undefined)
|
||||
const { runWhenGlobalIdle } = await import('./async')
|
||||
const runner = vi.fn()
|
||||
|
||||
const disposable = runWhenGlobalIdle(runner)
|
||||
await vi.runAllTimersAsync()
|
||||
|
||||
expect(runner).toHaveBeenCalledOnce()
|
||||
const deadline = runner.mock.calls[0][0]
|
||||
expect(deadline.didTimeout).toBe(true)
|
||||
expect(deadline.timeRemaining()).toBeGreaterThanOrEqual(0)
|
||||
|
||||
disposable.dispose()
|
||||
disposable.dispose()
|
||||
})
|
||||
|
||||
it('cancels fallback idle work before it runs', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.stubGlobal('requestIdleCallback', undefined)
|
||||
vi.stubGlobal('cancelIdleCallback', undefined)
|
||||
const { runWhenGlobalIdle } = await import('./async')
|
||||
const runner = vi.fn()
|
||||
|
||||
runWhenGlobalIdle(runner).dispose()
|
||||
await vi.runAllTimersAsync()
|
||||
|
||||
expect(runner).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses native idle callbacks when available', async () => {
|
||||
const requestIdleCallback = vi.fn(() => 42)
|
||||
const cancelIdleCallback = vi.fn()
|
||||
vi.stubGlobal('requestIdleCallback', requestIdleCallback)
|
||||
vi.stubGlobal('cancelIdleCallback', cancelIdleCallback)
|
||||
const { runWhenGlobalIdle } = await import('./async')
|
||||
const runner = vi.fn()
|
||||
|
||||
const disposable = runWhenGlobalIdle(runner, 250)
|
||||
|
||||
expect(requestIdleCallback).toHaveBeenCalledWith(runner, { timeout: 250 })
|
||||
|
||||
disposable.dispose()
|
||||
disposable.dispose()
|
||||
|
||||
expect(cancelIdleCallback).toHaveBeenCalledOnce()
|
||||
expect(cancelIdleCallback).toHaveBeenCalledWith(42)
|
||||
})
|
||||
|
||||
it('omits native idle timeout options when no timeout is supplied', async () => {
|
||||
const requestIdleCallback = vi.fn(() => 7)
|
||||
vi.stubGlobal('requestIdleCallback', requestIdleCallback)
|
||||
vi.stubGlobal('cancelIdleCallback', vi.fn())
|
||||
const { runWhenGlobalIdle } = await import('./async')
|
||||
const runner = vi.fn()
|
||||
|
||||
runWhenGlobalIdle(runner)
|
||||
|
||||
expect(requestIdleCallback).toHaveBeenCalledWith(runner, undefined)
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { fromAny, fromPartial } from '@total-typescript/shoehorn'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
@@ -122,22 +122,6 @@ describe('downloadUtil', () => {
|
||||
expect(createObjectURLSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('throws for an empty URL', () => {
|
||||
expect(() => downloadFile('')).toThrow(
|
||||
'Invalid URL provided for download'
|
||||
)
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
expect(createObjectURLSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('throws for a whitespace URL', () => {
|
||||
expect(() => downloadFile(' ')).toThrow(
|
||||
'Invalid URL provided for download'
|
||||
)
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
expect(createObjectURLSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should prefer custom filename over extracted filename', () => {
|
||||
const testUrl =
|
||||
'https://example.com/api/file?filename=extracted-image.jpg'
|
||||
@@ -355,7 +339,7 @@ describe('downloadUtil', () => {
|
||||
const testUrl = 'https://storage.googleapis.com/bucket/image.png'
|
||||
const blob = new Blob(['test'], { type: 'image/png' })
|
||||
const mockTab = { location: { href: '' }, closed: false, close: vi.fn() }
|
||||
windowOpenSpy.mockReturnValue(fromPartial<Window>(mockTab))
|
||||
windowOpenSpy.mockReturnValue(fromAny<Window, unknown>(mockTab))
|
||||
fetchMock.mockResolvedValue(
|
||||
fromPartial<Response>({
|
||||
ok: true,
|
||||
@@ -375,7 +359,7 @@ describe('downloadUtil', () => {
|
||||
mockIsCloud.value = true
|
||||
const blob = new Blob(['test'], { type: 'image/png' })
|
||||
const mockTab = { location: { href: '' }, closed: false, close: vi.fn() }
|
||||
windowOpenSpy.mockReturnValue(fromPartial<Window>(mockTab))
|
||||
windowOpenSpy.mockReturnValue(fromAny<Window, unknown>(mockTab))
|
||||
fetchMock.mockResolvedValue(
|
||||
fromPartial<Response>({
|
||||
ok: true,
|
||||
@@ -395,7 +379,7 @@ describe('downloadUtil', () => {
|
||||
const testUrl = 'https://storage.googleapis.com/bucket/missing.png'
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const mockTab = { location: { href: '' }, closed: false, close: vi.fn() }
|
||||
windowOpenSpy.mockReturnValue(fromPartial<Window>(mockTab))
|
||||
windowOpenSpy.mockReturnValue(fromAny<Window, unknown>(mockTab))
|
||||
fetchMock.mockResolvedValue(
|
||||
fromPartial<Response>({ ok: false, status: 404 })
|
||||
)
|
||||
@@ -411,7 +395,7 @@ describe('downloadUtil', () => {
|
||||
mockIsCloud.value = true
|
||||
const blob = new Blob(['test'], { type: 'image/png' })
|
||||
const mockTab = { location: { href: '' }, closed: true, close: vi.fn() }
|
||||
windowOpenSpy.mockReturnValue(fromPartial<Window>(mockTab))
|
||||
windowOpenSpy.mockReturnValue(fromAny<Window, unknown>(mockTab))
|
||||
fetchMock.mockResolvedValue(
|
||||
fromPartial<Response>({
|
||||
ok: true,
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
CREDITS_PER_USD,
|
||||
COMFY_CREDIT_RATE_CENTS,
|
||||
centsToCredits,
|
||||
clampUsd,
|
||||
creditsToCents,
|
||||
creditsToUsd,
|
||||
formatCredits,
|
||||
@@ -44,21 +43,4 @@ describe('comfyCredits helpers', () => {
|
||||
expect(formatCreditsFromUsd({ usd: 1, locale })).toBe('211.00')
|
||||
expect(formatUsd({ value: 4.2, locale })).toBe('4.20')
|
||||
})
|
||||
|
||||
test('formats with compatible fraction digit bounds', () => {
|
||||
expect(
|
||||
formatCredits({
|
||||
value: 12.345,
|
||||
locale: 'en-US',
|
||||
numberOptions: { minimumFractionDigits: 4, maximumFractionDigits: 2 }
|
||||
})
|
||||
).toBe('12.35')
|
||||
})
|
||||
|
||||
test('clamps USD purchase values into the supported range', () => {
|
||||
expect(clampUsd(Number.NaN)).toBe(0)
|
||||
expect(clampUsd(-5)).toBe(1)
|
||||
expect(clampUsd(42)).toBe(42)
|
||||
expect(clampUsd(5000)).toBe(1000)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -260,6 +260,7 @@ useExtensionService().registerExtension({
|
||||
if (!isLoad3dNode(selectedNode)) return
|
||||
|
||||
ComfyApp.copyToClipspace(selectedNode)
|
||||
// @ts-expect-error clipspace_return_node is an extension property added at runtime
|
||||
ComfyApp.clipspace_return_node = selectedNode
|
||||
|
||||
const props = { node: selectedNode }
|
||||
|
||||
@@ -2494,7 +2494,6 @@ export class LGraph
|
||||
// Deprecated - old schema version, links are arrays
|
||||
if (Array.isArray(data.links)) {
|
||||
for (const linkData of data.links) {
|
||||
if (!linkData) continue
|
||||
const link = LLink.createFromArray(linkData)
|
||||
this._links.set(link.id, link)
|
||||
}
|
||||
|
||||
@@ -133,7 +133,7 @@ export interface ISerialisedGraph extends BaseExportedGraph {
|
||||
last_node_id: SerializedNodeId
|
||||
last_link_id: number
|
||||
nodes: ISerialisedNode[]
|
||||
links: (SerialisedLLinkArray | null)[]
|
||||
links: SerialisedLLinkArray[]
|
||||
floatingLinks?: SerialisableLLink[]
|
||||
groups: ISerialisedGroup[]
|
||||
version: typeof LiteGraph.VERSION
|
||||
|
||||
@@ -30,90 +30,6 @@ describe('preservedQueryManager', () => {
|
||||
expect(sessionStorage.getItem('Comfy.PreservedQuery.template')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('merges newly captured keys into the payload when merge is set', () => {
|
||||
capturePreservedQuery(
|
||||
NAMESPACE,
|
||||
{ template: 'flux' },
|
||||
['template', 'source', 'mode'],
|
||||
{ merge: true }
|
||||
)
|
||||
|
||||
capturePreservedQuery(
|
||||
NAMESPACE,
|
||||
{ source: 'custom' },
|
||||
['template', 'source', 'mode'],
|
||||
{ merge: true }
|
||||
)
|
||||
|
||||
const merged = mergePreservedQueryIntoQuery(NAMESPACE)
|
||||
expect(merged).toEqual({ template: 'flux', source: 'custom' })
|
||||
})
|
||||
|
||||
it('replaces the whole payload on capture by default', () => {
|
||||
capturePreservedQuery(NAMESPACE, { template: 'flux', source: 'custom' }, [
|
||||
'template',
|
||||
'source',
|
||||
'mode'
|
||||
])
|
||||
|
||||
capturePreservedQuery(NAMESPACE, { template: 'sdxl' }, [
|
||||
'template',
|
||||
'source',
|
||||
'mode'
|
||||
])
|
||||
|
||||
expect(mergePreservedQueryIntoQuery(NAMESPACE)).toEqual({
|
||||
template: 'sdxl'
|
||||
})
|
||||
})
|
||||
|
||||
it('leaves the payload untouched when a default capture has no valid values', () => {
|
||||
capturePreservedQuery(NAMESPACE, { template: 'flux' }, ['template'])
|
||||
|
||||
capturePreservedQuery(NAMESPACE, { template: '' }, ['template'])
|
||||
|
||||
expect(getPreservedQueryParam(NAMESPACE, 'template')).toBe('flux')
|
||||
})
|
||||
|
||||
it('captures the first non-empty string element of an array-valued param', () => {
|
||||
capturePreservedQuery(NAMESPACE, { template: ['', 'flux', 'sdxl'] }, [
|
||||
'template'
|
||||
])
|
||||
|
||||
expect(getPreservedQueryParam(NAMESPACE, 'template')).toBe('flux')
|
||||
})
|
||||
|
||||
it('does not stash empty, null, or all-junk array values', () => {
|
||||
capturePreservedQuery(
|
||||
NAMESPACE,
|
||||
{ template: '', source: null, mode: ['', null] },
|
||||
['template', 'source', 'mode']
|
||||
)
|
||||
|
||||
expect(getPreservedQueryParam(NAMESPACE, 'template')).toBeUndefined()
|
||||
expect(getPreservedQueryParam(NAMESPACE, 'source')).toBeUndefined()
|
||||
expect(getPreservedQueryParam(NAMESPACE, 'mode')).toBeUndefined()
|
||||
expect(mergePreservedQueryIntoQuery(NAMESPACE)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('removes a preserved key on empty value when merge is set', () => {
|
||||
capturePreservedQuery(
|
||||
NAMESPACE,
|
||||
{ template: 'flux', source: 'custom' },
|
||||
['template', 'source'],
|
||||
{ merge: true }
|
||||
)
|
||||
|
||||
capturePreservedQuery(NAMESPACE, { template: '' }, ['template', 'source'], {
|
||||
merge: true
|
||||
})
|
||||
|
||||
expect(getPreservedQueryParam(NAMESPACE, 'template')).toBeUndefined()
|
||||
expect(mergePreservedQueryIntoQuery(NAMESPACE)).toEqual({
|
||||
source: 'custom'
|
||||
})
|
||||
})
|
||||
|
||||
it('reads a preserved query param by key', () => {
|
||||
capturePreservedQuery(NAMESPACE, { template: 'flux' }, ['template'])
|
||||
|
||||
@@ -162,16 +78,6 @@ describe('preservedQueryManager', () => {
|
||||
expect(merged).toBeUndefined()
|
||||
})
|
||||
|
||||
it('overwrites an array-valued live query key with the stashed string', () => {
|
||||
capturePreservedQuery(NAMESPACE, { template: 'flux' }, ['template'])
|
||||
|
||||
const merged = mergePreservedQueryIntoQuery(NAMESPACE, {
|
||||
template: ['existing', 'other']
|
||||
})
|
||||
|
||||
expect(merged).toEqual({ template: 'flux' })
|
||||
})
|
||||
|
||||
it('clears cached payload', () => {
|
||||
capturePreservedQuery(NAMESPACE, { template: 'flux' }, ['template'])
|
||||
|
||||
|
||||
@@ -4,12 +4,7 @@ const STORAGE_PREFIX = 'Comfy.PreservedQuery.'
|
||||
const preservedQueries = new Map<string, Record<string, string>>()
|
||||
|
||||
const readQueryParam = (value: unknown): string | undefined => {
|
||||
if (typeof value === 'string') return value
|
||||
if (!Array.isArray(value)) return undefined
|
||||
return value.find(
|
||||
(entry: unknown): entry is string =>
|
||||
typeof entry === 'string' && entry !== ''
|
||||
)
|
||||
return typeof value === 'string' ? value : undefined
|
||||
}
|
||||
|
||||
const getStorageKey = (namespace: string) => `${STORAGE_PREFIX}${namespace}`
|
||||
@@ -70,65 +65,25 @@ export const hydratePreservedQuery = (namespace: string) => {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* By default each capture replaces the namespace stash with the values present
|
||||
* in the given query. With `merge`, values are merged into the existing stash
|
||||
* and a key supplied with an empty value clears its stashed entry — for
|
||||
* namespaces where the stash, not the URL, is the surviving carrier.
|
||||
*/
|
||||
export const capturePreservedQuery = (
|
||||
namespace: string,
|
||||
query: LocationQuery,
|
||||
keys: string[],
|
||||
{ merge = false }: { merge?: boolean } = {}
|
||||
keys: string[]
|
||||
) => {
|
||||
if (!merge) {
|
||||
const payload: Record<string, string> = {}
|
||||
keys.forEach((key) => {
|
||||
const value = readQueryParam(query[key])
|
||||
if (value) {
|
||||
payload[key] = value
|
||||
}
|
||||
})
|
||||
if (Object.keys(payload).length === 0) {
|
||||
return
|
||||
}
|
||||
preservedQueries.set(namespace, payload)
|
||||
writeToStorage(namespace, payload)
|
||||
return
|
||||
}
|
||||
|
||||
hydratePreservedQuery(namespace)
|
||||
const payload: Record<string, string> = {
|
||||
...(preservedQueries.get(namespace) ?? {})
|
||||
}
|
||||
let changed = false
|
||||
const payload: Record<string, string> = {}
|
||||
|
||||
keys.forEach((key) => {
|
||||
if (!Object.hasOwn(query, key)) return
|
||||
|
||||
const value = readQueryParam(query[key])
|
||||
if (value) {
|
||||
payload[key] = value
|
||||
changed = true
|
||||
return
|
||||
}
|
||||
|
||||
if (key in payload) {
|
||||
delete payload[key]
|
||||
changed = true
|
||||
}
|
||||
})
|
||||
|
||||
if (!changed) {
|
||||
if (Object.keys(payload).length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
if (Object.keys(payload).length === 0) {
|
||||
preservedQueries.delete(namespace)
|
||||
} else {
|
||||
preservedQueries.set(namespace, payload)
|
||||
}
|
||||
preservedQueries.set(namespace, payload)
|
||||
writeToStorage(namespace, payload)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,196 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Router, RouterHistory } from 'vue-router'
|
||||
import { createMemoryHistory, createRouter } from 'vue-router'
|
||||
|
||||
import {
|
||||
clearPreservedQuery,
|
||||
getPreservedQueryParam
|
||||
} from '@/platform/navigation/preservedQueryManager'
|
||||
import { installPreservedQueryTracker } from '@/platform/navigation/preservedQueryTracker'
|
||||
|
||||
const STRIPPED_NAMESPACE = 'test_strip'
|
||||
const SECOND_STRIPPED_NAMESPACE = 'test_strip_b'
|
||||
const PLAIN_NAMESPACE = 'test_plain'
|
||||
|
||||
const strippedDefinition = {
|
||||
namespace: STRIPPED_NAMESPACE,
|
||||
keys: ['one_time_code'],
|
||||
stripAfterCapture: true
|
||||
}
|
||||
|
||||
const plainDefinition = {
|
||||
namespace: PLAIN_NAMESPACE,
|
||||
keys: ['plain_code', 'plain_source']
|
||||
}
|
||||
|
||||
function createTestRouter(
|
||||
history: RouterHistory = createMemoryHistory()
|
||||
): Router {
|
||||
return createRouter({
|
||||
history,
|
||||
routes: [{ path: '/:pathMatch(.*)*', component: { template: '<div />' } }]
|
||||
})
|
||||
}
|
||||
|
||||
describe('installPreservedQueryTracker', () => {
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear()
|
||||
clearPreservedQuery(STRIPPED_NAMESPACE)
|
||||
clearPreservedQuery(SECOND_STRIPPED_NAMESPACE)
|
||||
clearPreservedQuery(PLAIN_NAMESPACE)
|
||||
})
|
||||
|
||||
it('strips marked keys from the URL while preserving other query and hash', async () => {
|
||||
const router = createTestRouter()
|
||||
installPreservedQueryTracker(router, [strippedDefinition])
|
||||
|
||||
await router.push('/?one_time_code=otc_abc123&keep=a+b#frag')
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe('/?keep=a+b#frag')
|
||||
expect(getPreservedQueryParam(STRIPPED_NAMESPACE, 'one_time_code')).toBe(
|
||||
'otc_abc123'
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps params of non-strip namespaces in the URL and still captures them', async () => {
|
||||
const router = createTestRouter()
|
||||
installPreservedQueryTracker(router, [plainDefinition])
|
||||
|
||||
await router.push('/?plain_code=alpha&plain_source=beta')
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe(
|
||||
'/?plain_code=alpha&plain_source=beta'
|
||||
)
|
||||
expect(getPreservedQueryParam(PLAIN_NAMESPACE, 'plain_code')).toBe('alpha')
|
||||
expect(getPreservedQueryParam(PLAIN_NAMESPACE, 'plain_source')).toBe('beta')
|
||||
})
|
||||
|
||||
it('replaces a non-strip namespace stash on later captures', async () => {
|
||||
const router = createTestRouter()
|
||||
installPreservedQueryTracker(router, [plainDefinition])
|
||||
|
||||
await router.push('/?plain_code=alpha&plain_source=beta')
|
||||
await router.push('/?plain_code=gamma')
|
||||
|
||||
expect(getPreservedQueryParam(PLAIN_NAMESPACE, 'plain_code')).toBe('gamma')
|
||||
expect(
|
||||
getPreservedQueryParam(PLAIN_NAMESPACE, 'plain_source')
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
it('navigates exactly once when no strip-marked keys are present', async () => {
|
||||
const router = createTestRouter()
|
||||
installPreservedQueryTracker(router, [strippedDefinition])
|
||||
let completedNavigations = 0
|
||||
router.afterEach(() => {
|
||||
completedNavigations++
|
||||
})
|
||||
|
||||
await router.push('/?keep=1')
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe('/?keep=1')
|
||||
expect(completedNavigations).toBe(1)
|
||||
})
|
||||
|
||||
it('scrubs empty and null values from the URL without stashing them', async () => {
|
||||
const router = createTestRouter()
|
||||
installPreservedQueryTracker(router, [strippedDefinition])
|
||||
|
||||
await router.push('/?one_time_code=')
|
||||
expect(router.currentRoute.value.fullPath).toBe('/')
|
||||
expect(
|
||||
getPreservedQueryParam(STRIPPED_NAMESPACE, 'one_time_code')
|
||||
).toBeUndefined()
|
||||
|
||||
await router.push('/?one_time_code')
|
||||
expect(router.currentRoute.value.fullPath).toBe('/')
|
||||
expect(
|
||||
getPreservedQueryParam(STRIPPED_NAMESPACE, 'one_time_code')
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
it('clears a stale stripped value when the URL supplies an empty value', async () => {
|
||||
const router = createTestRouter()
|
||||
installPreservedQueryTracker(router, [strippedDefinition])
|
||||
|
||||
await router.push('/?one_time_code=otc_abc123')
|
||||
expect(getPreservedQueryParam(STRIPPED_NAMESPACE, 'one_time_code')).toBe(
|
||||
'otc_abc123'
|
||||
)
|
||||
|
||||
await router.push('/?one_time_code=')
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe('/')
|
||||
expect(
|
||||
getPreservedQueryParam(STRIPPED_NAMESPACE, 'one_time_code')
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
it('stashes the first value of a repeated param and cleans the URL', async () => {
|
||||
const router = createTestRouter()
|
||||
installPreservedQueryTracker(router, [strippedDefinition])
|
||||
|
||||
await router.push('/?one_time_code=otc_A&one_time_code=otc_B')
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe('/')
|
||||
expect(getPreservedQueryParam(STRIPPED_NAMESPACE, 'one_time_code')).toBe(
|
||||
'otc_A'
|
||||
)
|
||||
})
|
||||
|
||||
it('strips keys of multiple marked namespaces in a single redirect', async () => {
|
||||
const router = createTestRouter()
|
||||
let navigationAttempts = 0
|
||||
router.beforeEach((_to, _from, next) => {
|
||||
navigationAttempts++
|
||||
next()
|
||||
})
|
||||
installPreservedQueryTracker(router, [
|
||||
strippedDefinition,
|
||||
{
|
||||
namespace: SECOND_STRIPPED_NAMESPACE,
|
||||
keys: ['second_code'],
|
||||
stripAfterCapture: true
|
||||
}
|
||||
])
|
||||
|
||||
await router.push('/?one_time_code=otc_x&second_code=sc_y&keep=1')
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe('/?keep=1')
|
||||
expect(navigationAttempts).toBe(2)
|
||||
expect(getPreservedQueryParam(STRIPPED_NAMESPACE, 'one_time_code')).toBe(
|
||||
'otc_x'
|
||||
)
|
||||
expect(
|
||||
getPreservedQueryParam(SECOND_STRIPPED_NAMESPACE, 'second_code')
|
||||
).toBe('sc_y')
|
||||
})
|
||||
|
||||
it('keeps the prior history entry reachable after the strip redirect', async () => {
|
||||
const router = createTestRouter()
|
||||
installPreservedQueryTracker(router, [strippedDefinition])
|
||||
|
||||
await router.push('/start')
|
||||
await router.push('/?one_time_code=otc_abc123')
|
||||
expect(router.currentRoute.value.fullPath).toBe('/')
|
||||
|
||||
router.go(-1)
|
||||
await vi.waitFor(() =>
|
||||
expect(router.currentRoute.value.fullPath).toBe('/start')
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps replace navigation from adding a back target', async () => {
|
||||
const history = createMemoryHistory()
|
||||
const router = createTestRouter(history)
|
||||
installPreservedQueryTracker(router, [strippedDefinition])
|
||||
|
||||
await router.push('/start')
|
||||
await router.replace('/?one_time_code=otc_abc123')
|
||||
expect(router.currentRoute.value.fullPath).toBe('/')
|
||||
|
||||
router.go(-1)
|
||||
|
||||
expect(history.location).toBe('/')
|
||||
})
|
||||
})
|
||||
@@ -5,48 +5,25 @@ import {
|
||||
hydratePreservedQuery
|
||||
} from '@/platform/navigation/preservedQueryManager'
|
||||
|
||||
interface PreservedQueryDefinition {
|
||||
namespace: string
|
||||
keys: string[]
|
||||
/**
|
||||
* When set, keys present in the query are removed from the client-side URL
|
||||
* before navigation completes. Later guards, afterEach hooks, and views must
|
||||
* read a strip-marked key from the preserved-query stash instead of
|
||||
* route.query or fullPath. Because the stash is the only carrier after
|
||||
* stripping, captures for the namespace merge into the existing stash and an
|
||||
* explicitly empty value clears the stashed key; non-strip namespaces keep
|
||||
* replace-on-capture semantics.
|
||||
*/
|
||||
stripAfterCapture?: boolean
|
||||
}
|
||||
|
||||
export const installPreservedQueryTracker = (
|
||||
router: Router,
|
||||
definitions: PreservedQueryDefinition[]
|
||||
definitions: Array<{ namespace: string; keys: string[] }>
|
||||
) => {
|
||||
const trackedDefinitions = definitions.map((definition) => ({
|
||||
...definition
|
||||
}))
|
||||
|
||||
router.beforeEach((to, _from, next) => {
|
||||
const queryKeys = new Set(Object.keys(to.query))
|
||||
const keysToStrip = new Set<string>()
|
||||
|
||||
definitions.forEach(({ namespace, keys, stripAfterCapture }) => {
|
||||
trackedDefinitions.forEach(({ namespace, keys }) => {
|
||||
hydratePreservedQuery(namespace)
|
||||
const presentKeys = keys.filter((key) => queryKeys.has(key))
|
||||
if (presentKeys.length === 0) return
|
||||
capturePreservedQuery(namespace, to.query, keys, {
|
||||
merge: stripAfterCapture
|
||||
})
|
||||
if (stripAfterCapture) {
|
||||
presentKeys.forEach((key) => keysToStrip.add(key))
|
||||
const shouldCapture = keys.some((key) => queryKeys.has(key))
|
||||
if (shouldCapture) {
|
||||
capturePreservedQuery(namespace, to.query, keys)
|
||||
}
|
||||
})
|
||||
|
||||
if (keysToStrip.size === 0) {
|
||||
next()
|
||||
return
|
||||
}
|
||||
|
||||
const cleanedQuery = { ...to.query }
|
||||
keysToStrip.forEach((key) => delete cleanedQuery[key])
|
||||
next({ path: to.path, query: cleanedQuery, hash: to.hash })
|
||||
next()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -148,13 +148,13 @@ describe('useWorkflowAutoSave', () => {
|
||||
|
||||
const serviceInstance = vi.mocked(useWorkflowService).mock.results[0].value
|
||||
const graphChangedCallback = vi.mocked(api.addEventListener).mock
|
||||
.calls[0][1] as EventListener | undefined
|
||||
.calls[0][1]
|
||||
|
||||
graphChangedCallback?.({} as Event)
|
||||
graphChangedCallback?.({} as Parameters<typeof graphChangedCallback>[0])
|
||||
|
||||
vi.advanceTimersByTime(500)
|
||||
|
||||
graphChangedCallback?.({} as Event)
|
||||
graphChangedCallback?.({} as Parameters<typeof graphChangedCallback>[0])
|
||||
|
||||
vi.advanceTimersByTime(1999)
|
||||
expect(serviceInstance.saveWorkflow).not.toHaveBeenCalled()
|
||||
@@ -221,8 +221,8 @@ describe('useWorkflowAutoSave', () => {
|
||||
vi.advanceTimersByTime(1000)
|
||||
|
||||
const graphChangedCallback = vi.mocked(api.addEventListener).mock
|
||||
.calls[0][1] as EventListener | undefined
|
||||
graphChangedCallback?.({} as Event)
|
||||
.calls[0][1]
|
||||
graphChangedCallback?.({} as Parameters<typeof graphChangedCallback>[0])
|
||||
|
||||
resolveSave!()
|
||||
await Promise.resolve()
|
||||
@@ -269,8 +269,8 @@ describe('useWorkflowAutoSave', () => {
|
||||
mockAutoSaveDelay = -500
|
||||
|
||||
const graphChangedCallback = vi.mocked(api.addEventListener).mock
|
||||
.calls[0][1] as EventListener | undefined
|
||||
graphChangedCallback?.({} as Event)
|
||||
.calls[0][1]
|
||||
graphChangedCallback?.({} as Parameters<typeof graphChangedCallback>[0])
|
||||
|
||||
await vi.runAllTimersAsync()
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ const { isCreditsBadge } = usePriceBadge()
|
||||
const { t } = useI18n()
|
||||
|
||||
const creditsBadges = computed(() =>
|
||||
mapAllNodes(app.rootGraph, (node) => {
|
||||
mapAllNodes(app.graph, (node) => {
|
||||
if (node.isSubgraphNode()) return
|
||||
|
||||
const priceBadge = node.badges.find(isCreditsBadge)
|
||||
|
||||
@@ -530,6 +530,8 @@ export function useSlotLinkInteraction({
|
||||
|
||||
raf.flush()
|
||||
|
||||
raf.flush()
|
||||
|
||||
if (!state.source) {
|
||||
cleanupInteraction()
|
||||
app.canvas?.setDirty(true, true)
|
||||
@@ -577,18 +579,24 @@ export function useSlotLinkInteraction({
|
||||
const graph = app.canvas?.graph ?? null
|
||||
const context = { adapter, graph, session: dragContext }
|
||||
|
||||
const attemptSnapped = () => tryConnectToCandidate(snappedCandidate)
|
||||
|
||||
const domSlotCandidate = resolveSlotTargetCandidate(target, context)
|
||||
const attemptDomSlot = () => tryConnectToCandidate(domSlotCandidate)
|
||||
|
||||
const nodeSurfaceSlotCandidate = resolveNodeSurfaceSlotCandidate(
|
||||
target,
|
||||
context
|
||||
)
|
||||
const attemptNodeSurface = () =>
|
||||
tryConnectToCandidate(nodeSurfaceSlotCandidate)
|
||||
const attemptReroute = () => tryConnectViaRerouteAtPointer()
|
||||
|
||||
return (
|
||||
tryConnectToCandidate(snappedCandidate) ||
|
||||
tryConnectToCandidate(domSlotCandidate) ||
|
||||
tryConnectToCandidate(nodeSurfaceSlotCandidate) ||
|
||||
tryConnectViaRerouteAtPointer()
|
||||
)
|
||||
if (attemptSnapped()) return true
|
||||
if (attemptDomSlot()) return true
|
||||
if (attemptNodeSurface()) return true
|
||||
if (attemptReroute()) return true
|
||||
return false
|
||||
}
|
||||
|
||||
const onPointerDown = (event: PointerEvent) => {
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
getComboSpecComboOptions,
|
||||
getInputSpecType,
|
||||
isComboInputSpec,
|
||||
isComboInputSpecV1,
|
||||
isComboInputSpecV2,
|
||||
isFloatInputSpec,
|
||||
isIntInputSpec,
|
||||
isMediaUploadComboInput
|
||||
} from './nodeDefSchema'
|
||||
import type {
|
||||
ComboInputSpec,
|
||||
ComboInputSpecV2,
|
||||
InputSpec
|
||||
} from './nodeDefSchema'
|
||||
|
||||
describe('node definition schema helpers', () => {
|
||||
it('identifies input spec variants', () => {
|
||||
const intSpec: InputSpec = ['INT', {}]
|
||||
const floatSpec: InputSpec = ['FLOAT', {}]
|
||||
const comboV1: ComboInputSpec = [['a', 'b'], {}]
|
||||
const comboV2: ComboInputSpecV2 = ['COMBO', { options: ['a', 'b'] }]
|
||||
|
||||
expect(isIntInputSpec(intSpec)).toBe(true)
|
||||
expect(isFloatInputSpec(floatSpec)).toBe(true)
|
||||
expect(isComboInputSpecV1(comboV1)).toBe(true)
|
||||
expect(isComboInputSpecV2(comboV2)).toBe(true)
|
||||
expect(isComboInputSpec(comboV1)).toBe(true)
|
||||
expect(isComboInputSpec(comboV2)).toBe(true)
|
||||
expect(getInputSpecType(comboV1)).toBe('COMBO')
|
||||
expect(getInputSpecType(intSpec)).toBe('INT')
|
||||
})
|
||||
|
||||
it('reads combo options from legacy and v2 combo specs', () => {
|
||||
expect(getComboSpecComboOptions([['a', 1], {}])).toEqual(['a', 1])
|
||||
expect(
|
||||
getComboSpecComboOptions(['COMBO', { options: ['x', 'y'] }])
|
||||
).toEqual(['x', 'y'])
|
||||
expect(getComboSpecComboOptions(['COMBO', {}])).toEqual([])
|
||||
})
|
||||
|
||||
it('detects media upload combo inputs', () => {
|
||||
expect(isMediaUploadComboInput([['a'], { image_upload: true }])).toBe(true)
|
||||
expect(
|
||||
isMediaUploadComboInput(['COMBO', { animated_image_upload: true }])
|
||||
).toBe(true)
|
||||
expect(isMediaUploadComboInput(['COMBO', { video_upload: true }])).toBe(
|
||||
true
|
||||
)
|
||||
expect(isMediaUploadComboInput(['STRING', { image_upload: true }])).toBe(
|
||||
false
|
||||
)
|
||||
expect(isMediaUploadComboInput(['COMBO', undefined])).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,149 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
fetchWithUnifiedRemint,
|
||||
shouldRemintCloudRequest
|
||||
} from '@/platform/auth/unified/remintRetry'
|
||||
|
||||
const { mockAuthStore } = vi.hoisted(() => ({
|
||||
mockAuthStore: {
|
||||
isInitialized: true,
|
||||
getAuthHeader: vi.fn(),
|
||||
getAuthToken: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({ isCloud: true }))
|
||||
|
||||
vi.mock('@/stores/authStore', () => ({
|
||||
useAuthStore: vi.fn(() => mockAuthStore)
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/auth/unified/remintRetry', () => ({
|
||||
fetchWithUnifiedRemint: vi.fn(),
|
||||
shouldRemintCloudRequest: vi.fn()
|
||||
}))
|
||||
|
||||
class FakeWebSocket extends EventTarget {
|
||||
static instances: FakeWebSocket[] = []
|
||||
|
||||
binaryType = ''
|
||||
sent: string[] = []
|
||||
|
||||
constructor(readonly url: string) {
|
||||
super()
|
||||
FakeWebSocket.instances.push(this)
|
||||
}
|
||||
|
||||
send(data: string) {
|
||||
this.sent.push(data)
|
||||
}
|
||||
|
||||
close() {
|
||||
this.dispatchEvent(new Event('close'))
|
||||
}
|
||||
}
|
||||
|
||||
const { ComfyApi } = await import('./api')
|
||||
|
||||
describe('ComfyApi cloud mode', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
FakeWebSocket.instances = []
|
||||
window.name = ''
|
||||
sessionStorage.clear()
|
||||
mockAuthStore.isInitialized = true
|
||||
mockAuthStore.getAuthHeader.mockResolvedValue(null)
|
||||
mockAuthStore.getAuthToken.mockResolvedValue(null)
|
||||
vi.mocked(shouldRemintCloudRequest).mockResolvedValue(false)
|
||||
vi.mocked(fetchWithUnifiedRemint).mockResolvedValue(
|
||||
new Response(JSON.stringify({ ok: true }), {
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
})
|
||||
)
|
||||
vi.stubGlobal('WebSocket', FakeWebSocket)
|
||||
})
|
||||
|
||||
it('adds cloud auth headers and enables unified retry for authenticated requests', async () => {
|
||||
mockAuthStore.getAuthHeader.mockResolvedValue({
|
||||
Authorization: 'Bearer firebase-token'
|
||||
})
|
||||
vi.mocked(shouldRemintCloudRequest).mockResolvedValue(true)
|
||||
const api = new ComfyApi()
|
||||
api.user = 'cloud-user'
|
||||
|
||||
await api.fetchApi('/queue')
|
||||
|
||||
expect(api.api_base).toBe('')
|
||||
expect(fetchWithUnifiedRemint).toHaveBeenCalledWith(
|
||||
'/api/queue',
|
||||
expect.objectContaining({
|
||||
cache: 'no-cache',
|
||||
headers: {
|
||||
Authorization: 'Bearer firebase-token',
|
||||
'Comfy-User': 'cloud-user'
|
||||
}
|
||||
}),
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
it('continues cloud fetches when auth header lookup fails', async () => {
|
||||
mockAuthStore.getAuthHeader.mockRejectedValue(new Error('auth unavailable'))
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const api = new ComfyApi()
|
||||
|
||||
await api.fetchApi('/history', {
|
||||
headers: [['X-Test', '1']]
|
||||
})
|
||||
|
||||
const [, options, retryOn401] = vi.mocked(fetchWithUnifiedRemint).mock
|
||||
.calls[0]
|
||||
expect(options.headers).toEqual([
|
||||
['X-Test', '1'],
|
||||
['Comfy-User', '']
|
||||
])
|
||||
expect(retryOn401).toBe(false)
|
||||
expect(shouldRemintCloudRequest).not.toHaveBeenCalled()
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
'Failed to get auth header:',
|
||||
expect.any(Error)
|
||||
)
|
||||
})
|
||||
|
||||
it('adds the cloud auth token to websocket URLs', async () => {
|
||||
mockAuthStore.getAuthToken.mockResolvedValue('socket-token')
|
||||
window.name = 'client-1'
|
||||
const api = new ComfyApi()
|
||||
|
||||
api.init()
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(FakeWebSocket.instances).toHaveLength(1)
|
||||
})
|
||||
const socket = FakeWebSocket.instances[0]
|
||||
|
||||
expect(socket.url).toContain('clientId=client-1')
|
||||
expect(socket.url).toContain('token=socket-token')
|
||||
})
|
||||
|
||||
it('opens a cloud websocket without a token when token lookup fails', async () => {
|
||||
mockAuthStore.getAuthToken.mockRejectedValue(new Error('token unavailable'))
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const api = new ComfyApi()
|
||||
|
||||
api.init()
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(FakeWebSocket.instances).toHaveLength(1)
|
||||
})
|
||||
const socket = FakeWebSocket.instances[0]
|
||||
|
||||
expect(socket.url).not.toContain('token=')
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
'Could not get auth token for WebSocket connection:',
|
||||
expect.any(Error)
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,460 +0,0 @@
|
||||
import axios from 'axios'
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { JobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||
import type {
|
||||
ComfyApiWorkflow,
|
||||
ComfyWorkflowJSON
|
||||
} from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import type { PromptResponse } from '@/schemas/apiSchema'
|
||||
import { api as sharedApi, ComfyApi, PromptExecutionError } from '@/scripts/api'
|
||||
import type { NodeExecutionId } from '@/types/nodeIdentification'
|
||||
|
||||
const fetchJobs = vi.hoisted(() => ({
|
||||
fetchHistory: vi.fn(),
|
||||
fetchJobDetail: vi.fn(),
|
||||
fetchQueue: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('axios')
|
||||
vi.mock('@/platform/remote/comfyui/jobs/fetchJobs', () => fetchJobs)
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.resetAllMocks()
|
||||
})
|
||||
|
||||
function jsonResponse(data: unknown, init: ResponseInit = {}) {
|
||||
return new Response(JSON.stringify(data), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
...init
|
||||
})
|
||||
}
|
||||
|
||||
function promptData(): {
|
||||
output: ComfyApiWorkflow
|
||||
workflow: ComfyWorkflowJSON
|
||||
} {
|
||||
return {
|
||||
output: fromPartial<ComfyApiWorkflow>({
|
||||
1: {
|
||||
inputs: {},
|
||||
class_type: 'KSampler',
|
||||
_meta: { title: 'KSampler' }
|
||||
}
|
||||
}),
|
||||
workflow: fromPartial<ComfyWorkflowJSON>({
|
||||
version: 0.4,
|
||||
nodes: [],
|
||||
links: []
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function requestBody(fetchApi: ReturnType<typeof vi.spyOn>, call = 0) {
|
||||
const init = fetchApi.mock.calls[call][1]
|
||||
return JSON.parse(String(init?.body)) as Record<string, unknown>
|
||||
}
|
||||
|
||||
describe('PromptExecutionError', () => {
|
||||
it('formats string and node-specific prompt errors', () => {
|
||||
const response = fromPartial<PromptResponse>({
|
||||
error: 'invalid prompt',
|
||||
node_errors: {
|
||||
7: {
|
||||
class_type: 'KSampler',
|
||||
dependent_outputs: [],
|
||||
errors: [{ message: 'bad seed', details: 'seed must be numeric' }]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
expect(new PromptExecutionError(response, 400).toString()).toBe(
|
||||
'invalid prompt\nKSampler:\n - bad seed: seed must be numeric'
|
||||
)
|
||||
})
|
||||
|
||||
it('formats structured prompt errors without node errors', () => {
|
||||
const response = fromPartial<PromptResponse>({
|
||||
error: {
|
||||
type: 'prompt_outputs_failed_validation',
|
||||
message: 'Validation failed',
|
||||
details: 'missing node'
|
||||
}
|
||||
})
|
||||
|
||||
expect(new PromptExecutionError(response).toString()).toBe(
|
||||
'Validation failed: missing node'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('ComfyApi queuePrompt', () => {
|
||||
it('sends front queue requests with auth and execution options', async () => {
|
||||
const api = new ComfyApi()
|
||||
api.clientId = 'client-1'
|
||||
api.authToken = 'auth-token'
|
||||
api.apiKey = 'api-key'
|
||||
const fetchApi = vi
|
||||
.spyOn(api, 'fetchApi')
|
||||
.mockResolvedValue(jsonResponse({ prompt_id: 'queued' }))
|
||||
|
||||
await api.queuePrompt(-1, promptData(), {
|
||||
partialExecutionTargets: ['9:10' as NodeExecutionId],
|
||||
previewMethod: 'auto'
|
||||
})
|
||||
|
||||
expect(fetchApi).toHaveBeenCalledWith('/prompt', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: fetchApi.mock.calls[0][1]?.body
|
||||
})
|
||||
expect(typeof fetchApi.mock.calls[0][1]?.body).toBe('string')
|
||||
expect(requestBody(fetchApi)).toMatchObject({
|
||||
client_id: 'client-1',
|
||||
front: true,
|
||||
partial_execution_targets: ['9:10'],
|
||||
extra_data: {
|
||||
auth_token_comfy_org: 'auth-token',
|
||||
api_key_comfy_org: 'api-key',
|
||||
comfy_usage_source: 'comfyui-frontend',
|
||||
preview_method: 'auto'
|
||||
}
|
||||
})
|
||||
expect(requestBody(fetchApi)).not.toHaveProperty('number')
|
||||
})
|
||||
|
||||
it('omits default-only queue options and sets explicit queue numbers', async () => {
|
||||
const api = new ComfyApi()
|
||||
const fetchApi = vi
|
||||
.spyOn(api, 'fetchApi')
|
||||
.mockImplementation(() =>
|
||||
Promise.resolve(jsonResponse({ prompt_id: 'queued' }))
|
||||
)
|
||||
|
||||
await api.queuePrompt(0, promptData(), { previewMethod: 'default' })
|
||||
await api.queuePrompt(4, promptData())
|
||||
|
||||
expect(requestBody(fetchApi, 0)).toMatchObject({ client_id: '' })
|
||||
expect(requestBody(fetchApi, 0)).not.toHaveProperty('front')
|
||||
expect(requestBody(fetchApi, 0)).not.toHaveProperty('number')
|
||||
expect(
|
||||
requestBody(fetchApi, 0).extra_data as Record<string, unknown>
|
||||
).not.toHaveProperty('preview_method')
|
||||
expect(requestBody(fetchApi, 1)).toMatchObject({ number: 4 })
|
||||
})
|
||||
|
||||
it('throws parsed prompt errors from non-200 responses', async () => {
|
||||
const api = new ComfyApi()
|
||||
vi.spyOn(api, 'fetchApi').mockResolvedValue(
|
||||
jsonResponse(
|
||||
{
|
||||
error: {
|
||||
type: 'server_error',
|
||||
message: 'Server rejected prompt',
|
||||
details: 'bad output'
|
||||
}
|
||||
},
|
||||
{ status: 400, statusText: 'Bad Request' }
|
||||
)
|
||||
)
|
||||
|
||||
await expect(api.queuePrompt(0, promptData())).rejects.toThrow(
|
||||
'Prompt execution failed'
|
||||
)
|
||||
})
|
||||
|
||||
it('wraps non-json prompt errors with status details', async () => {
|
||||
const api = new ComfyApi()
|
||||
vi.spyOn(api, 'fetchApi').mockResolvedValue(
|
||||
new Response('backend exploded', {
|
||||
status: 500,
|
||||
statusText: 'Server Error'
|
||||
})
|
||||
)
|
||||
|
||||
await expect(api.queuePrompt(0, promptData())).rejects.toMatchObject({
|
||||
status: 500,
|
||||
response: {
|
||||
error: {
|
||||
message: '500 Server Error',
|
||||
details: 'backend exploded'
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('ComfyApi read helpers', () => {
|
||||
it('returns localized templates, default templates, and empty non-json responses', async () => {
|
||||
const api = new ComfyApi()
|
||||
vi.mocked(axios.get)
|
||||
.mockResolvedValueOnce({
|
||||
headers: { 'content-type': 'application/json' },
|
||||
data: [{ name: 'localized' }]
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
headers: { 'content-type': 'text/html' },
|
||||
data: '<html></html>'
|
||||
})
|
||||
|
||||
await expect(api.getCoreWorkflowTemplates('fr')).resolves.toEqual([
|
||||
{ name: 'localized' }
|
||||
])
|
||||
await expect(api.getCoreWorkflowTemplates()).resolves.toEqual([])
|
||||
expect(vi.mocked(axios.get).mock.calls[0][0]).toContain(
|
||||
'/templates/index.fr.json'
|
||||
)
|
||||
expect(vi.mocked(axios.get).mock.calls[1][0]).toContain(
|
||||
'/templates/index.json'
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back from missing localized templates to the default index', async () => {
|
||||
const api = new ComfyApi()
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
vi.mocked(axios.get)
|
||||
.mockRejectedValueOnce(new Error('missing locale'))
|
||||
.mockResolvedValueOnce({
|
||||
headers: { 'content-type': 'application/json' },
|
||||
data: [{ name: 'default' }]
|
||||
})
|
||||
|
||||
await expect(api.getCoreWorkflowTemplates('ja')).resolves.toEqual([
|
||||
{ name: 'default' }
|
||||
])
|
||||
expect(vi.mocked(axios.get).mock.calls[1][0]).toContain(
|
||||
'/templates/index.json'
|
||||
)
|
||||
})
|
||||
|
||||
it('returns empty model lists for 404s and filters internal folders', async () => {
|
||||
const api = new ComfyApi()
|
||||
const fetchApi = vi
|
||||
.spyOn(api, 'fetchApi')
|
||||
.mockResolvedValueOnce(jsonResponse([], { status: 404 }))
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse([
|
||||
{ name: 'checkpoints' },
|
||||
{ name: 'configs' },
|
||||
{ name: 'custom_nodes' }
|
||||
])
|
||||
)
|
||||
.mockResolvedValueOnce(jsonResponse([], { status: 404 }))
|
||||
.mockResolvedValueOnce(jsonResponse(['model.safetensors']))
|
||||
|
||||
await expect(api.getModelFolders()).resolves.toEqual([])
|
||||
await expect(api.getModelFolders()).resolves.toEqual([
|
||||
{ name: 'checkpoints' }
|
||||
])
|
||||
await expect(api.getModels('checkpoints')).resolves.toEqual([])
|
||||
await expect(api.getModels('checkpoints')).resolves.toEqual([
|
||||
'model.safetensors'
|
||||
])
|
||||
expect(fetchApi).toHaveBeenCalledTimes(4)
|
||||
})
|
||||
|
||||
it('handles model metadata text responses', async () => {
|
||||
const api = new ComfyApi()
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
vi.spyOn(api, 'fetchApi')
|
||||
.mockResolvedValueOnce(new Response(''))
|
||||
.mockResolvedValueOnce(new Response('{"format":"safetensors"}'))
|
||||
.mockResolvedValueOnce(
|
||||
new Response('not json', { status: 200, statusText: 'OK' })
|
||||
)
|
||||
|
||||
await expect(
|
||||
api.viewMetadata('checkpoints', 'a.safetensors')
|
||||
).resolves.toBe(null)
|
||||
await expect(
|
||||
api.viewMetadata('checkpoints', 'a.safetensors')
|
||||
).resolves.toEqual({ format: 'safetensors' })
|
||||
await expect(
|
||||
api.viewMetadata('checkpoints', 'a.safetensors')
|
||||
).resolves.toBe(null)
|
||||
})
|
||||
|
||||
it('gets fuse options only from json responses', async () => {
|
||||
const api = new ComfyApi()
|
||||
vi.mocked(axios.get)
|
||||
.mockResolvedValueOnce({
|
||||
headers: { 'content-type': 'application/json' },
|
||||
data: { keys: ['name'] }
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
headers: { 'content-type': 'text/plain' },
|
||||
data: 'nope'
|
||||
})
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
await expect(api.getFuseOptions()).resolves.toEqual({ keys: ['name'] })
|
||||
await expect(api.getFuseOptions()).resolves.toBeNull()
|
||||
vi.mocked(axios.get).mockRejectedValueOnce(new Error('missing'))
|
||||
await expect(api.getFuseOptions()).resolves.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('ComfyApi queue and data helpers', () => {
|
||||
it('routes item collection requests to queue or history', async () => {
|
||||
const api = new ComfyApi()
|
||||
const queue = vi.spyOn(api, 'getQueue').mockResolvedValue({
|
||||
Running: [],
|
||||
Pending: []
|
||||
})
|
||||
const historyItem = fromPartial<JobListItem>({
|
||||
id: 'history-1',
|
||||
status: 'completed',
|
||||
create_time: 1,
|
||||
priority: 0
|
||||
})
|
||||
const history = vi.spyOn(api, 'getHistory').mockResolvedValue([historyItem])
|
||||
|
||||
await expect(api.getItems('queue')).resolves.toEqual({
|
||||
Running: [],
|
||||
Pending: []
|
||||
})
|
||||
await expect(api.getItems('history')).resolves.toEqual([historyItem])
|
||||
expect(queue).toHaveBeenCalledOnce()
|
||||
expect(history).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('returns queue fallbacks unless errors are requested', async () => {
|
||||
const api = new ComfyApi()
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
fetchJobs.fetchQueue.mockRejectedValue(new Error('network'))
|
||||
|
||||
await expect(api.getQueue()).resolves.toEqual({ Running: [], Pending: [] })
|
||||
await expect(api.getQueue({ throwOnError: true })).rejects.toThrow(
|
||||
'network'
|
||||
)
|
||||
})
|
||||
|
||||
it('returns empty history when fetchHistory fails', async () => {
|
||||
const api = new ComfyApi()
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
fetchJobs.fetchHistory.mockRejectedValue(new Error('history down'))
|
||||
|
||||
await expect(api.getHistory()).resolves.toEqual([])
|
||||
})
|
||||
|
||||
it('posts item mutations with and without request bodies', async () => {
|
||||
const api = new ComfyApi()
|
||||
const fetchApi = vi.spyOn(api, 'fetchApi').mockResolvedValue(new Response())
|
||||
|
||||
await api.deleteItem('history', 'job-1')
|
||||
await api.clearItems('queue')
|
||||
await api.interrupt(null)
|
||||
await api.interrupt('running-1')
|
||||
|
||||
expect(fetchApi.mock.calls.map((call) => call[0])).toEqual([
|
||||
'/history',
|
||||
'/queue',
|
||||
'/interrupt',
|
||||
'/interrupt'
|
||||
])
|
||||
expect(fetchApi.mock.calls[0][1]?.body).toBe(
|
||||
JSON.stringify({ delete: ['job-1'] })
|
||||
)
|
||||
expect(fetchApi.mock.calls[1][1]?.body).toBe(
|
||||
JSON.stringify({ clear: true })
|
||||
)
|
||||
expect(fetchApi.mock.calls[2][1]?.body).toBeUndefined()
|
||||
expect(fetchApi.mock.calls[3][1]?.body).toBe(
|
||||
JSON.stringify({ prompt_id: 'running-1' })
|
||||
)
|
||||
})
|
||||
|
||||
it('throws unauthorized settings responses', async () => {
|
||||
const api = new ComfyApi()
|
||||
vi.spyOn(api, 'fetchApi').mockResolvedValue(
|
||||
new Response('', { status: 401, statusText: 'Unauthorized' })
|
||||
)
|
||||
|
||||
await expect(api.getSettings()).rejects.toThrow('Unauthorized')
|
||||
})
|
||||
|
||||
it('stores user data with default and raw-body options', async () => {
|
||||
const api = new ComfyApi()
|
||||
const fetchApi = vi
|
||||
.spyOn(api, 'fetchApi')
|
||||
.mockResolvedValue(new Response('', { status: 200 }))
|
||||
const raw = new Blob(['raw'])
|
||||
|
||||
await api.storeUserData('a/b.json', { ok: true })
|
||||
await api.storeUserData('raw.bin', raw, {
|
||||
overwrite: false,
|
||||
stringify: false,
|
||||
throwOnError: false,
|
||||
full_info: true
|
||||
})
|
||||
|
||||
expect(fetchApi.mock.calls[0][0]).toBe(
|
||||
'/userdata/a%2Fb.json?overwrite=true&full_info=false'
|
||||
)
|
||||
expect(fetchApi.mock.calls[0][1]?.body).toBe(JSON.stringify({ ok: true }))
|
||||
expect(fetchApi.mock.calls[1][0]).toBe(
|
||||
'/userdata/raw.bin?overwrite=false&full_info=true'
|
||||
)
|
||||
expect(fetchApi.mock.calls[1][1]?.body).toBe(raw)
|
||||
})
|
||||
|
||||
it('honors storeUserData throwOnError', async () => {
|
||||
const api = new ComfyApi()
|
||||
vi.spyOn(api, 'fetchApi')
|
||||
.mockResolvedValueOnce(
|
||||
new Response('', { status: 500, statusText: 'Server Error' })
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
new Response('', { status: 500, statusText: 'Server Error' })
|
||||
)
|
||||
|
||||
await expect(api.storeUserData('bad.json', {})).rejects.toThrow(
|
||||
"Error storing user data file 'bad.json': 500 Server Error"
|
||||
)
|
||||
await expect(
|
||||
api.storeUserData('bad.json', {}, { throwOnError: false })
|
||||
).resolves.toHaveProperty('status', 500)
|
||||
})
|
||||
|
||||
it('lists full user data info by status', async () => {
|
||||
const api = new ComfyApi()
|
||||
vi.spyOn(api, 'fetchApi')
|
||||
.mockResolvedValueOnce(jsonResponse([], { status: 404 }))
|
||||
.mockResolvedValueOnce(
|
||||
new Response('', { status: 500, statusText: 'Server Error' })
|
||||
)
|
||||
.mockResolvedValueOnce(jsonResponse([{ path: 'x' }]))
|
||||
|
||||
await expect(api.listUserDataFullInfo('models/')).resolves.toEqual([])
|
||||
await expect(api.listUserDataFullInfo('models/')).rejects.toThrow(
|
||||
"Error getting user data list 'models': 500 Server Error"
|
||||
)
|
||||
await expect(api.listUserDataFullInfo('models/')).resolves.toEqual([
|
||||
{ path: 'x' }
|
||||
])
|
||||
})
|
||||
|
||||
it('loads global subgraph records and deferred data', async () => {
|
||||
const fetchApi = vi
|
||||
.spyOn(sharedApi, 'fetchApi')
|
||||
.mockResolvedValueOnce(jsonResponse({}, { status: 500 }))
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
ready: { name: 'Ready', info: { node_pack: 'core' }, data: '{}' },
|
||||
lazy: { name: 'Lazy', info: { node_pack: 'core' } }
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce(jsonResponse({ data: '{"lazy":true}' }))
|
||||
|
||||
await expect(sharedApi.getGlobalSubgraphs()).resolves.toEqual({})
|
||||
const subgraphs = await sharedApi.getGlobalSubgraphs()
|
||||
|
||||
expect(subgraphs.ready.data).toBe('{}')
|
||||
expect(subgraphs.lazy.data).toBeInstanceOf(Promise)
|
||||
await expect(subgraphs.lazy.data).resolves.toBe('{"lazy":true}')
|
||||
expect(fetchApi).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
})
|
||||
@@ -1,827 +0,0 @@
|
||||
import axios from 'axios'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
api as singletonApi,
|
||||
ComfyApi,
|
||||
PromptExecutionError,
|
||||
UnauthorizedError
|
||||
} from '@/scripts/api'
|
||||
import type { ComfyApiWorkflow } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import type { NodeExecutionId } from '@/types/nodeIdentification'
|
||||
import { fetchWithUnifiedRemint } from '@/platform/auth/unified/remintRetry'
|
||||
|
||||
const { mockToastStore } = vi.hoisted(() => ({
|
||||
mockToastStore: {
|
||||
add: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/auth/unified/remintRetry', () => ({
|
||||
fetchWithUnifiedRemint: vi.fn(),
|
||||
shouldRemintCloudRequest: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/updates/common/toastStore', () => ({
|
||||
useToastStore: vi.fn(() => mockToastStore)
|
||||
}))
|
||||
|
||||
vi.mock('axios', () => ({
|
||||
default: {
|
||||
get: vi.fn(),
|
||||
patch: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
class FakeWebSocket extends EventTarget {
|
||||
static instances: FakeWebSocket[] = []
|
||||
|
||||
binaryType = ''
|
||||
sent: string[] = []
|
||||
|
||||
constructor(readonly url: string) {
|
||||
super()
|
||||
FakeWebSocket.instances.push(this)
|
||||
}
|
||||
|
||||
send(data: string) {
|
||||
this.sent.push(data)
|
||||
}
|
||||
|
||||
close() {
|
||||
this.dispatchEvent(new Event('close'))
|
||||
}
|
||||
}
|
||||
|
||||
function jsonResponse(data: unknown, init: ResponseInit = {}) {
|
||||
return new Response(JSON.stringify(data), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
...init
|
||||
})
|
||||
}
|
||||
|
||||
function createWorkflow() {
|
||||
return {
|
||||
last_node_id: 0,
|
||||
last_link_id: 0,
|
||||
nodes: [],
|
||||
links: [],
|
||||
groups: [],
|
||||
config: {},
|
||||
extra: {},
|
||||
version: 0.4
|
||||
}
|
||||
}
|
||||
|
||||
function binaryMessage(type: number, payload: Uint8Array) {
|
||||
const bytes = new Uint8Array(4 + payload.length)
|
||||
new DataView(bytes.buffer).setUint32(0, type)
|
||||
bytes.set(payload, 4)
|
||||
return bytes.buffer
|
||||
}
|
||||
|
||||
function uint32(value: number) {
|
||||
const bytes = new Uint8Array(4)
|
||||
new DataView(bytes.buffer).setUint32(0, value)
|
||||
return bytes
|
||||
}
|
||||
|
||||
function concatBytes(...chunks: Uint8Array[]) {
|
||||
const totalLength = chunks.reduce((sum, chunk) => sum + chunk.length, 0)
|
||||
const result = new Uint8Array(totalLength)
|
||||
let offset = 0
|
||||
for (const chunk of chunks) {
|
||||
result.set(chunk, offset)
|
||||
offset += chunk.length
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
describe('PromptExecutionError', () => {
|
||||
it('formats string, structured, and node-level errors', () => {
|
||||
expect(
|
||||
new PromptExecutionError({
|
||||
error: 'Queue rejected',
|
||||
node_errors: {}
|
||||
}).toString()
|
||||
).toBe('Queue rejected')
|
||||
|
||||
expect(
|
||||
new PromptExecutionError({
|
||||
error: {
|
||||
type: 'invalid_prompt',
|
||||
message: 'Invalid prompt',
|
||||
details: 'missing input'
|
||||
},
|
||||
node_errors: {
|
||||
1: {
|
||||
class_type: 'PreviewAny',
|
||||
dependent_outputs: ['1'],
|
||||
errors: [
|
||||
{
|
||||
type: 'required_input_missing',
|
||||
message: 'Required input',
|
||||
details: 'source'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}).toString()
|
||||
).toContain('Invalid prompt: missing input\nPreviewAny:')
|
||||
})
|
||||
})
|
||||
|
||||
describe('ComfyApi', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
FakeWebSocket.instances = []
|
||||
window.name = ''
|
||||
sessionStorage.clear()
|
||||
vi.stubGlobal('WebSocket', FakeWebSocket)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('builds API, internal, file, and fetch URLs with user headers', async () => {
|
||||
const api = new ComfyApi()
|
||||
api.user = 'reviewer'
|
||||
vi.mocked(fetchWithUnifiedRemint).mockResolvedValue(
|
||||
jsonResponse({ ok: true })
|
||||
)
|
||||
|
||||
await api.fetchApi('/queue', {
|
||||
headers: new Headers([['X-Test', '1']])
|
||||
})
|
||||
|
||||
expect(api.apiURL('/api/custom')).toBe(`${api.api_base}/api/custom`)
|
||||
expect(api.apiURL('/queue')).toBe(`${api.api_base}/api/queue`)
|
||||
expect(api.internalURL('/logs')).toBe(`${api.api_base}/internal/logs`)
|
||||
expect(api.fileURL('/view')).toBe(`${api.api_base}/view`)
|
||||
const [, options] = vi.mocked(fetchWithUnifiedRemint).mock.calls[0]
|
||||
expect(options.headers).toBeInstanceOf(Headers)
|
||||
expect((options.headers as Headers).get('Comfy-User')).toBe('reviewer')
|
||||
expect((options.headers as Headers).get('X-Test')).toBe('1')
|
||||
})
|
||||
|
||||
it('guards event listeners and still allows removing them', async () => {
|
||||
const api = new ComfyApi()
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
const listener = vi.fn()
|
||||
const throwingListener = vi.fn(() => {
|
||||
throw new Error('listener failed')
|
||||
})
|
||||
const asyncListener = vi.fn(() => Promise.reject(new Error('async failed')))
|
||||
const objectListener = { handleEvent: vi.fn() }
|
||||
|
||||
api.addEventListener('status', null)
|
||||
api.removeEventListener('status', null)
|
||||
api.addEventListener('status', listener)
|
||||
api.addEventListener('status', throwingListener)
|
||||
api.addEventListener('status', asyncListener)
|
||||
api.addEventListener('status', objectListener)
|
||||
|
||||
api.dispatchCustomEvent('status', { exec_info: { queue_remaining: 1 } })
|
||||
await Promise.resolve()
|
||||
|
||||
expect(listener).toHaveBeenCalled()
|
||||
expect(throwingListener).toHaveBeenCalled()
|
||||
expect(asyncListener).toHaveBeenCalled()
|
||||
expect(objectListener.handleEvent).toHaveBeenCalled()
|
||||
expect(warn).toHaveBeenCalledTimes(2)
|
||||
|
||||
api.removeEventListener('status', listener)
|
||||
api.dispatchCustomEvent('status', null)
|
||||
expect(listener).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('reuses guarded listener wrappers and ignores unknown removals', () => {
|
||||
const api = new ComfyApi()
|
||||
const listener = vi.fn()
|
||||
const neverRegistered = vi.fn()
|
||||
|
||||
api.addEventListener('status', listener)
|
||||
api.addEventListener('status', listener)
|
||||
api.removeEventListener('status', neverRegistered)
|
||||
api.dispatchCustomEvent('status', null)
|
||||
|
||||
expect(listener).toHaveBeenCalledTimes(1)
|
||||
expect(neverRegistered).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('supports guarded custom event listeners', () => {
|
||||
const api = new ComfyApi()
|
||||
const listener = vi.fn()
|
||||
|
||||
api.addCustomEventListener('custom-node-event', listener)
|
||||
;(api as EventTarget).dispatchEvent(
|
||||
new CustomEvent('custom-node-event', { detail: { ok: true } })
|
||||
)
|
||||
api.removeCustomEventListener('custom-node-event', listener)
|
||||
;(api as EventTarget).dispatchEvent(
|
||||
new CustomEvent('custom-node-event', { detail: { ok: false } })
|
||||
)
|
||||
|
||||
expect(listener).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('routes websocket JSON messages and custom registered messages', () => {
|
||||
window.name = 'existing-client'
|
||||
const api = new ComfyApi()
|
||||
const status = vi.fn()
|
||||
const executing = vi.fn()
|
||||
const featureFlags = vi.fn()
|
||||
const custom = vi.fn()
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
vi.spyOn(console, 'log').mockImplementation(() => undefined)
|
||||
|
||||
api.addEventListener('status', status)
|
||||
api.addEventListener('executing', executing)
|
||||
api.addEventListener('feature_flags', featureFlags)
|
||||
api.addCustomEventListener('custom-message', custom)
|
||||
api.init()
|
||||
const socket = FakeWebSocket.instances[0]
|
||||
socket.dispatchEvent(new Event('open'))
|
||||
|
||||
expect(socket.url).toContain('clientId=existing-client')
|
||||
expect(JSON.parse(socket.sent[0])).toMatchObject({
|
||||
type: 'feature_flags'
|
||||
})
|
||||
|
||||
socket.dispatchEvent(
|
||||
new MessageEvent('message', {
|
||||
data: JSON.stringify({
|
||||
type: 'status',
|
||||
data: {
|
||||
sid: 'fresh-client',
|
||||
status: { exec_info: { queue_remaining: 2 } }
|
||||
}
|
||||
})
|
||||
})
|
||||
)
|
||||
socket.dispatchEvent(
|
||||
new MessageEvent('message', {
|
||||
data: JSON.stringify({
|
||||
type: 'executing',
|
||||
data: { node: '12' }
|
||||
})
|
||||
})
|
||||
)
|
||||
socket.dispatchEvent(
|
||||
new MessageEvent('message', {
|
||||
data: JSON.stringify({
|
||||
type: 'feature_flags',
|
||||
data: { supports_progress_text_metadata: true }
|
||||
})
|
||||
})
|
||||
)
|
||||
socket.dispatchEvent(
|
||||
new MessageEvent('message', {
|
||||
data: JSON.stringify({
|
||||
type: 'custom-message',
|
||||
data: { from: 'extension' }
|
||||
})
|
||||
})
|
||||
)
|
||||
socket.dispatchEvent(
|
||||
new MessageEvent('message', {
|
||||
data: JSON.stringify({
|
||||
type: 'unknown-message',
|
||||
data: {}
|
||||
})
|
||||
})
|
||||
)
|
||||
socket.dispatchEvent(
|
||||
new MessageEvent('message', {
|
||||
data: JSON.stringify({
|
||||
type: 'unknown-message',
|
||||
data: {}
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
expect(api.clientId).toBe('fresh-client')
|
||||
expect(window.name).toBe('fresh-client')
|
||||
expect(sessionStorage.getItem('clientId')).toBe('fresh-client')
|
||||
expect(status).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
detail: { exec_info: { queue_remaining: 2 } }
|
||||
})
|
||||
)
|
||||
expect(executing).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ detail: '12' })
|
||||
)
|
||||
expect(featureFlags).toHaveBeenCalled()
|
||||
expect(api.serverSupportsFeature('supports_progress_text_metadata')).toBe(
|
||||
true
|
||||
)
|
||||
expect(custom).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ detail: { from: 'extension' } })
|
||||
)
|
||||
expect(api.reportedUnknownMessageTypes.has('unknown-message')).toBe(true)
|
||||
expect(warn).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('polls status when the initial websocket connection fails', async () => {
|
||||
vi.useFakeTimers()
|
||||
const api = new ComfyApi()
|
||||
const status = vi.fn()
|
||||
vi.spyOn(api, 'fetchApi')
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse({ exec_info: { queue_remaining: 4 } })
|
||||
)
|
||||
.mockRejectedValueOnce(new Error('poll failed'))
|
||||
api.addEventListener('status', status)
|
||||
|
||||
api.init()
|
||||
FakeWebSocket.instances[0].dispatchEvent(new Event('error'))
|
||||
await vi.advanceTimersByTimeAsync(1000)
|
||||
await vi.advanceTimersByTimeAsync(1000)
|
||||
|
||||
expect(status).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
detail: { exec_info: { queue_remaining: 4 } }
|
||||
})
|
||||
)
|
||||
expect(status).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ detail: null })
|
||||
)
|
||||
})
|
||||
|
||||
it('emits reconnect lifecycle events after an opened websocket closes', async () => {
|
||||
vi.useFakeTimers()
|
||||
const api = new ComfyApi()
|
||||
const status = vi.fn()
|
||||
const reconnecting = vi.fn()
|
||||
const reconnected = vi.fn()
|
||||
api.addEventListener('status', status)
|
||||
api.addEventListener('reconnecting', reconnecting)
|
||||
api.addEventListener('reconnected', reconnected)
|
||||
|
||||
api.init()
|
||||
const socket = FakeWebSocket.instances[0]
|
||||
socket.dispatchEvent(new Event('open'))
|
||||
socket.close()
|
||||
|
||||
expect(status).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ detail: null })
|
||||
)
|
||||
expect(reconnecting).toHaveBeenCalledOnce()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(300)
|
||||
const reconnectSocket = FakeWebSocket.instances[1]
|
||||
reconnectSocket.dispatchEvent(new Event('open'))
|
||||
|
||||
expect(reconnected).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('routes websocket variants without session ids and display-node fallbacks', () => {
|
||||
const api = new ComfyApi()
|
||||
const status = vi.fn()
|
||||
const executing = vi.fn()
|
||||
api.addEventListener('status', status)
|
||||
api.addEventListener('executing', executing)
|
||||
api.init()
|
||||
const socket = FakeWebSocket.instances[0]
|
||||
|
||||
socket.dispatchEvent(
|
||||
new MessageEvent('message', {
|
||||
data: JSON.stringify({
|
||||
type: 'status',
|
||||
data: { status: undefined }
|
||||
})
|
||||
})
|
||||
)
|
||||
socket.dispatchEvent(
|
||||
new MessageEvent('message', {
|
||||
data: JSON.stringify({
|
||||
type: 'executing',
|
||||
data: { node: 'real', display_node: 'display' }
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
expect(status).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ detail: null })
|
||||
)
|
||||
expect(executing).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ detail: 'display' })
|
||||
)
|
||||
})
|
||||
|
||||
it('routes binary preview and progress websocket messages', () => {
|
||||
const api = new ComfyApi()
|
||||
const preview = vi.fn()
|
||||
const previewWithMetadata = vi.fn()
|
||||
const progressText = vi.fn()
|
||||
const encoder = new TextEncoder()
|
||||
api.serverFeatureFlags.value = {
|
||||
supports_progress_text_metadata: true
|
||||
}
|
||||
api.addEventListener('b_preview', preview)
|
||||
api.addEventListener('b_preview_with_metadata', previewWithMetadata)
|
||||
api.addEventListener('progress_text', progressText)
|
||||
api.init()
|
||||
const socket = FakeWebSocket.instances[0]
|
||||
|
||||
socket.dispatchEvent(
|
||||
new MessageEvent('message', {
|
||||
data: binaryMessage(1, concatBytes(uint32(2), new Uint8Array([1, 2])))
|
||||
})
|
||||
)
|
||||
|
||||
const promptId = encoder.encode('prompt-1')
|
||||
const nodeId = encoder.encode('7')
|
||||
const progressPayload = concatBytes(
|
||||
uint32(promptId.length),
|
||||
promptId,
|
||||
uint32(nodeId.length),
|
||||
nodeId,
|
||||
encoder.encode('loading')
|
||||
)
|
||||
socket.dispatchEvent(
|
||||
new MessageEvent('message', {
|
||||
data: binaryMessage(3, progressPayload)
|
||||
})
|
||||
)
|
||||
|
||||
const metadata = encoder.encode(
|
||||
JSON.stringify({
|
||||
image_type: 'image/webp',
|
||||
node_id: '7',
|
||||
display_node_id: '7',
|
||||
parent_node_id: '4',
|
||||
real_node_id: '7',
|
||||
prompt_id: 'prompt-1'
|
||||
})
|
||||
)
|
||||
socket.dispatchEvent(
|
||||
new MessageEvent('message', {
|
||||
data: binaryMessage(
|
||||
4,
|
||||
concatBytes(uint32(metadata.length), metadata, new Uint8Array([9]))
|
||||
)
|
||||
})
|
||||
)
|
||||
|
||||
expect(preview).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
detail: expect.objectContaining({ type: 'image/png' })
|
||||
})
|
||||
)
|
||||
expect(progressText).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
detail: {
|
||||
nodeId: '7',
|
||||
text: 'loading',
|
||||
prompt_id: 'prompt-1'
|
||||
}
|
||||
})
|
||||
)
|
||||
expect(previewWithMetadata).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
detail: expect.objectContaining({
|
||||
nodeId: '7',
|
||||
parentNodeId: '4',
|
||||
jobId: 'prompt-1'
|
||||
})
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('routes binary jpeg/default previews and malformed binary messages defensively', () => {
|
||||
const api = new ComfyApi()
|
||||
const preview = vi.fn()
|
||||
const progressText = vi.fn()
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
const encoder = new TextEncoder()
|
||||
api.addEventListener('b_preview', preview)
|
||||
api.addEventListener('progress_text', progressText)
|
||||
api.init()
|
||||
const socket = FakeWebSocket.instances[0]
|
||||
|
||||
socket.dispatchEvent(
|
||||
new MessageEvent('message', {
|
||||
data: binaryMessage(1, concatBytes(uint32(1), new Uint8Array([1])))
|
||||
})
|
||||
)
|
||||
socket.dispatchEvent(
|
||||
new MessageEvent('message', {
|
||||
data: binaryMessage(1, concatBytes(uint32(99), new Uint8Array([2])))
|
||||
})
|
||||
)
|
||||
|
||||
const nodeId = encoder.encode('node')
|
||||
socket.dispatchEvent(
|
||||
new MessageEvent('message', {
|
||||
data: binaryMessage(
|
||||
3,
|
||||
concatBytes(uint32(nodeId.length), nodeId, encoder.encode('ready'))
|
||||
)
|
||||
})
|
||||
)
|
||||
socket.dispatchEvent(
|
||||
new MessageEvent('message', {
|
||||
data: binaryMessage(3, new Uint8Array([1]))
|
||||
})
|
||||
)
|
||||
socket.dispatchEvent(
|
||||
new MessageEvent('message', {
|
||||
data: binaryMessage(99, new Uint8Array())
|
||||
})
|
||||
)
|
||||
|
||||
expect(preview).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
detail: expect.objectContaining({ type: 'image/jpeg' })
|
||||
})
|
||||
)
|
||||
expect(progressText).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
detail: { nodeId: 'node', text: 'ready' }
|
||||
})
|
||||
)
|
||||
expect(warn).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('serializes prompt queue options and surfaces non-200 errors', async () => {
|
||||
const api = new ComfyApi()
|
||||
api.clientId = 'client-1'
|
||||
api.authToken = 'token-1'
|
||||
api.apiKey = 'key-1'
|
||||
const prompt: ComfyApiWorkflow = {
|
||||
1: {
|
||||
class_type: 'PreviewAny',
|
||||
inputs: {},
|
||||
_meta: { title: 'PreviewAny' }
|
||||
}
|
||||
}
|
||||
const fetchApi = vi
|
||||
.spyOn(api, 'fetchApi')
|
||||
.mockResolvedValueOnce(jsonResponse({ prompt_id: 'queued' }))
|
||||
.mockResolvedValueOnce(
|
||||
new Response('backend exploded', {
|
||||
status: 500,
|
||||
statusText: 'Server Error'
|
||||
})
|
||||
)
|
||||
|
||||
await expect(
|
||||
api.queuePrompt(
|
||||
-1,
|
||||
{ output: prompt, workflow: createWorkflow() },
|
||||
{
|
||||
partialExecutionTargets: ['7' as NodeExecutionId],
|
||||
previewMethod: 'latent2rgb'
|
||||
}
|
||||
)
|
||||
).resolves.toEqual({ prompt_id: 'queued' })
|
||||
|
||||
const body = JSON.parse(fetchApi.mock.calls[0][1]?.body as string)
|
||||
expect(body).toMatchObject({
|
||||
client_id: 'client-1',
|
||||
prompt,
|
||||
partial_execution_targets: ['7'],
|
||||
front: true,
|
||||
extra_data: {
|
||||
auth_token_comfy_org: 'token-1',
|
||||
api_key_comfy_org: 'key-1',
|
||||
comfy_usage_source: 'comfyui-frontend',
|
||||
preview_method: 'latent2rgb'
|
||||
}
|
||||
})
|
||||
expect(body.number).toBeUndefined()
|
||||
|
||||
await expect(
|
||||
api.queuePrompt(3, { output: prompt, workflow: createWorkflow() })
|
||||
).rejects.toMatchObject({ status: 500 })
|
||||
})
|
||||
|
||||
it('omits queue position and default preview method for normal queueing', async () => {
|
||||
const api = new ComfyApi()
|
||||
const prompt: ComfyApiWorkflow = {
|
||||
1: {
|
||||
class_type: 'PreviewAny',
|
||||
inputs: {},
|
||||
_meta: { title: 'PreviewAny' }
|
||||
}
|
||||
}
|
||||
const fetchApi = vi
|
||||
.spyOn(api, 'fetchApi')
|
||||
.mockResolvedValue(jsonResponse({ prompt_id: 'queued' }))
|
||||
|
||||
await api.queuePrompt(
|
||||
0,
|
||||
{ output: prompt, workflow: createWorkflow() },
|
||||
{
|
||||
previewMethod: 'default'
|
||||
}
|
||||
)
|
||||
|
||||
const body = JSON.parse(fetchApi.mock.calls[0][1]?.body as string)
|
||||
expect(body.front).toBeUndefined()
|
||||
expect(body.number).toBeUndefined()
|
||||
expect(body.extra_data.preview_method).toBeUndefined()
|
||||
})
|
||||
|
||||
it('handles shareable assets, settings, userdata, subgraphs, and memory APIs', async () => {
|
||||
const api = new ComfyApi()
|
||||
const prompt: ComfyApiWorkflow = {
|
||||
1: {
|
||||
class_type: 'PreviewAny',
|
||||
inputs: {},
|
||||
_meta: { title: 'PreviewAny' }
|
||||
}
|
||||
}
|
||||
vi.spyOn(api, 'fetchApi')
|
||||
.mockResolvedValueOnce(jsonResponse({ assets: [] }))
|
||||
.mockResolvedValueOnce(jsonResponse({}, { status: 500 }))
|
||||
.mockResolvedValueOnce(
|
||||
new Response('', { status: 401, statusText: 'Unauthorized' })
|
||||
)
|
||||
.mockResolvedValueOnce(jsonResponse({}, { status: 204 }))
|
||||
.mockResolvedValueOnce(jsonResponse([], { status: 404 }))
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse({}, { status: 500, statusText: 'Server Error' })
|
||||
)
|
||||
.mockResolvedValueOnce(jsonResponse({}, { status: 200 }))
|
||||
.mockResolvedValueOnce(jsonResponse({}, { status: 200 }))
|
||||
.mockResolvedValueOnce(jsonResponse({}, { status: 500 }))
|
||||
vi.spyOn(singletonApi, 'fetchApi')
|
||||
.mockResolvedValueOnce(jsonResponse({ data: 'subgraph-data' }))
|
||||
.mockResolvedValueOnce(jsonResponse({ missing: {} }))
|
||||
.mockResolvedValueOnce(jsonResponse({ one: { data: 'inline' } }))
|
||||
|
||||
await expect(
|
||||
api.getShareableAssets(prompt, { owned: false })
|
||||
).resolves.toEqual({ assets: [] })
|
||||
await expect(api.getShareableAssets(prompt)).rejects.toThrow(
|
||||
'Failed to fetch shareable assets'
|
||||
)
|
||||
await expect(api.getSettings()).rejects.toBeInstanceOf(UnauthorizedError)
|
||||
await expect(
|
||||
api.storeUserData('plain.txt', 'raw', {
|
||||
overwrite: false,
|
||||
stringify: false,
|
||||
throwOnError: false,
|
||||
full_info: true
|
||||
})
|
||||
).resolves.toHaveProperty('status', 204)
|
||||
await expect(api.listUserDataFullInfo('/missing/')).resolves.toEqual([])
|
||||
await expect(api.listUserDataFullInfo('/broken/')).rejects.toThrow(
|
||||
"Error getting user data list '/broken'"
|
||||
)
|
||||
await expect(api.getGlobalSubgraphData('one')).resolves.toBe(
|
||||
'subgraph-data'
|
||||
)
|
||||
await expect(api.getGlobalSubgraphData('missing')).rejects.toThrow(
|
||||
"Global subgraph 'missing' returned empty data"
|
||||
)
|
||||
await expect(api.getGlobalSubgraphs()).resolves.toEqual({
|
||||
one: { data: 'inline' }
|
||||
})
|
||||
await api.freeMemory({ freeExecutionCache: true })
|
||||
await api.freeMemory({ freeExecutionCache: false })
|
||||
await api.freeMemory({ freeExecutionCache: true })
|
||||
expect(mockToastStore.add).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
summary: 'Models and Execution Cache have been cleared.'
|
||||
})
|
||||
)
|
||||
expect(mockToastStore.add).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ summary: 'Models have been unloaded.' })
|
||||
)
|
||||
expect(mockToastStore.add).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
summary:
|
||||
'Unloading of models failed. Installed ComfyUI may be an outdated version.'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects non-success global subgraph data responses', async () => {
|
||||
const api = new ComfyApi()
|
||||
vi.spyOn(singletonApi, 'fetchApi').mockResolvedValueOnce(
|
||||
jsonResponse({}, { status: 404, statusText: 'Not Found' })
|
||||
)
|
||||
|
||||
await expect(api.getGlobalSubgraphData('missing')).rejects.toThrow(
|
||||
"Failed to fetch global subgraph 'missing': 404 Not Found"
|
||||
)
|
||||
})
|
||||
|
||||
it('handles successful settings and userdata helper request shapes', async () => {
|
||||
const api = new ComfyApi()
|
||||
const fetchApi = vi
|
||||
.spyOn(api, 'fetchApi')
|
||||
.mockResolvedValueOnce(jsonResponse({ theme: 'dark' }))
|
||||
.mockResolvedValueOnce(jsonResponse({}, { status: 500 }))
|
||||
.mockResolvedValueOnce(jsonResponse({}, { status: 200 }))
|
||||
.mockResolvedValueOnce(jsonResponse({}, { status: 200 }))
|
||||
|
||||
await expect(api.getSettings()).resolves.toEqual({ theme: 'dark' })
|
||||
await expect(api.storeUserData('bad.json', { a: 1 })).rejects.toThrow(
|
||||
"Error storing user data file 'bad.json'"
|
||||
)
|
||||
await api.moveUserData('old/path.json', 'new path.json')
|
||||
await api.deleteUserData('old/path.json')
|
||||
|
||||
expect(fetchApi.mock.calls[2]).toEqual([
|
||||
'/userdata/old%2Fpath.json/move/new%20path.json?overwrite=false',
|
||||
{ method: 'POST' }
|
||||
])
|
||||
expect(fetchApi.mock.calls[3]).toEqual([
|
||||
'/userdata/old%2Fpath.json',
|
||||
{ method: 'DELETE' }
|
||||
])
|
||||
})
|
||||
|
||||
it('handles global subgraph fallbacks and log endpoints', async () => {
|
||||
const api = new ComfyApi()
|
||||
vi.spyOn(singletonApi, 'fetchApi')
|
||||
.mockResolvedValueOnce(jsonResponse({}, { status: 500 }))
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
missing: {
|
||||
name: 'Missing data',
|
||||
info: { node_pack: 'core' }
|
||||
}
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce(jsonResponse({ data: 'lazy-data' }))
|
||||
vi.mocked(axios.get)
|
||||
.mockResolvedValueOnce({ data: 'log text', headers: {} })
|
||||
.mockResolvedValueOnce({ data: { logs: [] }, headers: {} })
|
||||
.mockRejectedValueOnce(new Error('no folders'))
|
||||
.mockResolvedValueOnce({
|
||||
data: { checkpoints: ['/models'] },
|
||||
headers: {}
|
||||
})
|
||||
vi.mocked(axios.patch).mockResolvedValue({ data: undefined })
|
||||
|
||||
await expect(api.getGlobalSubgraphs()).resolves.toEqual({})
|
||||
const subgraphs = await api.getGlobalSubgraphs()
|
||||
await expect(subgraphs.missing.data).resolves.toBe('lazy-data')
|
||||
await expect(api.getLogs()).resolves.toBe('log text')
|
||||
await expect(api.getRawLogs()).resolves.toEqual({ logs: [] })
|
||||
await api.subscribeLogs(true)
|
||||
await expect(api.getFolderPaths()).resolves.toEqual({})
|
||||
await expect(api.getFolderPaths()).resolves.toEqual({
|
||||
checkpoints: ['/models']
|
||||
})
|
||||
|
||||
expect(axios.patch).toHaveBeenCalledWith(
|
||||
api.internalURL('/logs/subscribe'),
|
||||
{ enabled: true, clientId: undefined }
|
||||
)
|
||||
})
|
||||
|
||||
it('loads localized template indexes and fuse options defensively', async () => {
|
||||
const api = new ComfyApi()
|
||||
vi.mocked(axios.get)
|
||||
.mockResolvedValueOnce({
|
||||
data: [{ name: 'template' }],
|
||||
headers: { 'content-type': 'application/json' }
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
data: '<html></html>',
|
||||
headers: { 'content-type': 'text/html' }
|
||||
})
|
||||
.mockRejectedValueOnce(new Error('missing locale'))
|
||||
.mockResolvedValueOnce({
|
||||
data: [{ name: 'fallback' }],
|
||||
headers: { 'content-type': 'application/json' }
|
||||
})
|
||||
.mockRejectedValueOnce(new Error('default missing'))
|
||||
.mockResolvedValueOnce({
|
||||
data: { keys: ['name'] },
|
||||
headers: { 'content-type': 'application/json' }
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
data: '<html></html>',
|
||||
headers: { 'content-type': 'text/html' }
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
data: { ignored: true },
|
||||
headers: {}
|
||||
})
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
|
||||
await expect(api.getCoreWorkflowTemplates('fr')).resolves.toEqual([
|
||||
{ name: 'template' }
|
||||
])
|
||||
await expect(api.getCoreWorkflowTemplates()).resolves.toEqual([])
|
||||
await expect(api.getCoreWorkflowTemplates('zh')).resolves.toEqual([
|
||||
{ name: 'fallback' }
|
||||
])
|
||||
await expect(api.getCoreWorkflowTemplates('en')).resolves.toEqual([])
|
||||
await expect(api.getFuseOptions()).resolves.toEqual({ keys: ['name'] })
|
||||
await expect(api.getFuseOptions()).resolves.toBeNull()
|
||||
await expect(api.getFuseOptions()).resolves.toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -277,13 +277,13 @@ function addHeaderEntry(headers: HeadersInit, key: string, value: string) {
|
||||
export interface ComfyApi extends EventTarget {
|
||||
addEventListener<TEvent extends keyof ApiEvents>(
|
||||
type: TEvent,
|
||||
callback: ((event: ApiEvents[TEvent]) => void) | EventListenerObject | null,
|
||||
callback: ((event: ApiEvents[TEvent]) => void) | null,
|
||||
options?: AddEventListenerOptions | boolean
|
||||
): void
|
||||
|
||||
removeEventListener<TEvent extends keyof ApiEvents>(
|
||||
type: TEvent,
|
||||
callback: ((event: ApiEvents[TEvent]) => void) | EventListenerObject | null,
|
||||
callback: ((event: ApiEvents[TEvent]) => void) | null,
|
||||
options?: EventListenerOptions | boolean
|
||||
): void
|
||||
}
|
||||
@@ -548,7 +548,7 @@ export class ComfyApi extends EventTarget {
|
||||
|
||||
override addEventListener<TEvent extends keyof ApiEvents>(
|
||||
type: TEvent,
|
||||
callback: ((event: ApiEvents[TEvent]) => void) | EventListenerObject | null,
|
||||
callback: ((event: ApiEvents[TEvent]) => void) | null,
|
||||
options?: AddEventListenerOptions | boolean
|
||||
) {
|
||||
// Type assertion: strictFunctionTypes. So long as we emit events in a type-safe fashion, this is safe.
|
||||
@@ -562,7 +562,7 @@ export class ComfyApi extends EventTarget {
|
||||
|
||||
override removeEventListener<TEvent extends keyof ApiEvents>(
|
||||
type: TEvent,
|
||||
callback: ((event: ApiEvents[TEvent]) => void) | EventListenerObject | null,
|
||||
callback: ((event: ApiEvents[TEvent]) => void) | null,
|
||||
options?: EventListenerOptions | boolean
|
||||
): void {
|
||||
super.removeEventListener(
|
||||
|
||||
@@ -188,17 +188,6 @@ describe('ComfyApp', () => {
|
||||
)
|
||||
})
|
||||
|
||||
describe('graph (deprecated getter)', () => {
|
||||
it('returns undefined until the root graph is initialized', () => {
|
||||
expect(app.graph).toBeUndefined()
|
||||
|
||||
const graph = new LGraph()
|
||||
Reflect.set(app, 'rootGraphInternal', graph)
|
||||
|
||||
expect(app.graph).toBe(graph)
|
||||
})
|
||||
})
|
||||
|
||||
describe('queuePrompt', () => {
|
||||
it('shows the error overlay for successful prompt responses with node errors', async () => {
|
||||
const graph = new LGraph()
|
||||
|
||||
@@ -244,7 +244,7 @@ export class ComfyApp {
|
||||
static clipspace_invalidate_handler: (() => void) | null = null
|
||||
static open_maskeditor: (() => void) | null = null
|
||||
static maskeditor_is_opended: (() => void) | null = null
|
||||
static clipspace_return_node: LGraphNode | null = null
|
||||
static clipspace_return_node = null
|
||||
|
||||
vueAppReady: boolean
|
||||
api: ComfyApi
|
||||
@@ -257,8 +257,8 @@ export class ComfyApp {
|
||||
|
||||
// TODO: Migrate internal usage to the
|
||||
/** @deprecated Use {@link rootGraph} instead */
|
||||
get graph(): LGraph | undefined {
|
||||
return this.rootGraphInternal
|
||||
get graph() {
|
||||
return this.rootGraphInternal!
|
||||
}
|
||||
|
||||
get rootGraph(): LGraph {
|
||||
@@ -1167,7 +1167,7 @@ export class ComfyApp {
|
||||
}
|
||||
|
||||
async loadGraphData(
|
||||
graphData?: ComfyWorkflowJSON | unknown[],
|
||||
graphData?: ComfyWorkflowJSON,
|
||||
clean: boolean = true,
|
||||
restore_view: boolean = true,
|
||||
workflow: string | null | ComfyWorkflow = null,
|
||||
|
||||
@@ -1,17 +1,6 @@
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import type { PartialDeep } from '@total-typescript/shoehorn'
|
||||
import type { MockInstance } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type {
|
||||
CanvasPointerEvent,
|
||||
Subgraph
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import { LGraphCanvas, LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import type { ISerialisedGraph } from '@/lib/litegraph/src/types/serialisation'
|
||||
import type { ExecutedWsMessage } from '@/schemas/apiSchema'
|
||||
|
||||
const mockAssert = vi.hoisted(() => vi.fn())
|
||||
|
||||
@@ -25,7 +14,7 @@ const mockNodeOutputStore = vi.hoisted(() => ({
|
||||
}))
|
||||
|
||||
const mockSubgraphNavigationStore = vi.hoisted(() => ({
|
||||
exportState: vi.fn((): string[] => []),
|
||||
exportState: vi.fn(() => []),
|
||||
restoreState: vi.fn()
|
||||
}))
|
||||
|
||||
@@ -34,24 +23,10 @@ const mockWorkflowStore = vi.hoisted(() => ({
|
||||
getWorkflowByPath: vi.fn()
|
||||
}))
|
||||
|
||||
const mockExecutionStore = vi.hoisted(() => ({
|
||||
queuedJobs: {} as Record<string, { workflow: { changeTracker: unknown } }>
|
||||
}))
|
||||
|
||||
const mockMaskEditorIsOpened = vi.hoisted(() => vi.fn(() => false))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
constructor: {
|
||||
maskeditor_is_opended: mockMaskEditorIsOpened
|
||||
},
|
||||
graph: {},
|
||||
ui: {
|
||||
autoQueueEnabled: false,
|
||||
autoQueueMode: 'instant'
|
||||
},
|
||||
rootGraph: {
|
||||
subgraphs: new Map(),
|
||||
serialize: vi.fn(() => ({
|
||||
nodes: [],
|
||||
links: [],
|
||||
@@ -64,10 +39,8 @@ vi.mock('@/scripts/app', () => ({
|
||||
}))
|
||||
},
|
||||
canvas: {
|
||||
ds: { scale: 1, offset: [0, 0] },
|
||||
setGraph: vi.fn()
|
||||
},
|
||||
loadGraphData: vi.fn()
|
||||
ds: { scale: 1, offset: [0, 0] }
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
@@ -92,10 +65,6 @@ vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
|
||||
useWorkflowStore: vi.fn(() => mockWorkflowStore)
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/executionStore', () => ({
|
||||
useExecutionStore: vi.fn(() => mockExecutionStore)
|
||||
}))
|
||||
|
||||
import { app } from '@/scripts/app'
|
||||
import { api } from '@/scripts/api'
|
||||
import { ChangeTracker } from '@/scripts/changeTracker'
|
||||
@@ -114,7 +83,7 @@ function createState(nodeCount = 0): ComfyWorkflowJSON {
|
||||
outputs: [],
|
||||
properties: {}
|
||||
}))
|
||||
return Object.assign(fromPartial<ComfyWorkflowJSON>({}), {
|
||||
return {
|
||||
nodes,
|
||||
links: [],
|
||||
groups: [],
|
||||
@@ -123,51 +92,19 @@ function createState(nodeCount = 0): ComfyWorkflowJSON {
|
||||
version: 0.4,
|
||||
last_node_id: nodeIdCounter,
|
||||
last_link_id: 0
|
||||
})
|
||||
} as unknown as ComfyWorkflowJSON
|
||||
}
|
||||
|
||||
function createTracker(initialState?: ComfyWorkflowJSON): ChangeTracker {
|
||||
const state = initialState ?? createState()
|
||||
const workflow = fromPartial<ComfyWorkflow>({ path: '/test/workflow.json' })
|
||||
const workflow = { path: '/test/workflow.json' } as never
|
||||
const tracker = new ChangeTracker(workflow, state)
|
||||
mockWorkflowStore.activeWorkflow = { changeTracker: tracker }
|
||||
return tracker
|
||||
}
|
||||
|
||||
function mockCanvasState(state: ComfyWorkflowJSON) {
|
||||
vi.mocked(app.rootGraph.serialize).mockReturnValue(state as ISerialisedGraph)
|
||||
}
|
||||
|
||||
type ListenerMap = Record<string, EventListener[]>
|
||||
|
||||
function storeListener(
|
||||
listeners: ListenerMap,
|
||||
type: string,
|
||||
listener: EventListenerOrEventListenerObject
|
||||
) {
|
||||
if (typeof listener === 'function') {
|
||||
listeners[type] ??= []
|
||||
listeners[type].push(listener)
|
||||
}
|
||||
}
|
||||
|
||||
function dispatchStored(listeners: ListenerMap, type: string, event: Event) {
|
||||
for (const listener of listeners[type] ?? []) {
|
||||
listener(event)
|
||||
}
|
||||
}
|
||||
|
||||
async function flushAsyncFrame() {
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
}
|
||||
|
||||
function getApiListener(name: string) {
|
||||
const call = vi
|
||||
.mocked(api.addEventListener)
|
||||
.mock.calls.find(([eventName]) => eventName === name)
|
||||
expect(call).toBeDefined()
|
||||
return call?.[1] as (event: CustomEvent<ExecutedWsMessage>) => void
|
||||
vi.mocked(app.rootGraph.serialize).mockReturnValue(state as never)
|
||||
}
|
||||
|
||||
describe('ChangeTracker', () => {
|
||||
@@ -175,83 +112,8 @@ describe('ChangeTracker', () => {
|
||||
vi.clearAllMocks()
|
||||
nodeIdCounter = 0
|
||||
ChangeTracker.isLoadingGraph = false
|
||||
Reflect.set(ChangeTracker, '_checkStateWarned', false)
|
||||
mockWorkflowStore.activeWorkflow = null
|
||||
mockWorkflowStore.getWorkflowByPath.mockReturnValue(null)
|
||||
mockExecutionStore.queuedJobs = {}
|
||||
mockMaskEditorIsOpened.mockReturnValue(false)
|
||||
app.ui.autoQueueEnabled = false
|
||||
app.ui.autoQueueMode = 'instant'
|
||||
vi.mocked(app.canvas.setGraph).mockClear()
|
||||
vi.mocked(app.loadGraphData).mockResolvedValue(undefined)
|
||||
app.rootGraph.subgraphs.clear()
|
||||
app.canvas.ds.scale = 1
|
||||
app.canvas.ds.offset = [0, 0]
|
||||
})
|
||||
|
||||
describe('reset', () => {
|
||||
it('updates initialState from activeState or an explicit state', () => {
|
||||
const tracker = createTracker(createState(1))
|
||||
const changed = createState(2)
|
||||
|
||||
tracker.activeState = changed
|
||||
tracker.reset()
|
||||
|
||||
expect(tracker.initialState).toEqual(changed)
|
||||
expect(tracker.initialState).not.toBe(changed)
|
||||
|
||||
const explicit = createState(3)
|
||||
tracker.reset(explicit)
|
||||
|
||||
expect(tracker.activeState).toEqual(explicit)
|
||||
expect(tracker.activeState).not.toBe(explicit)
|
||||
expect(tracker.initialState).toEqual(explicit)
|
||||
})
|
||||
|
||||
it('does not reset while restoring state', () => {
|
||||
const tracker = createTracker(createState(1))
|
||||
const original = tracker.initialState
|
||||
tracker._restoringState = true
|
||||
|
||||
tracker.reset(createState(2))
|
||||
|
||||
expect(tracker.initialState).toBe(original)
|
||||
})
|
||||
})
|
||||
|
||||
describe('restore', () => {
|
||||
it('restores viewport, outputs, and root graph navigation', () => {
|
||||
const tracker = createTracker()
|
||||
app.canvas.ds.scale = 2
|
||||
app.canvas.ds.offset = [10, 20]
|
||||
mockNodeOutputStore.snapshotOutputs.mockReturnValue({ 1: { images: [] } })
|
||||
mockSubgraphNavigationStore.exportState.mockReturnValue([])
|
||||
|
||||
tracker.store()
|
||||
app.canvas.ds.scale = 1
|
||||
app.canvas.ds.offset = [0, 0]
|
||||
tracker.restore()
|
||||
|
||||
expect(app.canvas.ds.scale).toBe(2)
|
||||
expect(app.canvas.ds.offset).toEqual([10, 20])
|
||||
expect(mockNodeOutputStore.restoreOutputs).toHaveBeenCalledWith({
|
||||
1: { images: [] }
|
||||
})
|
||||
expect(mockSubgraphNavigationStore.restoreState).toHaveBeenCalledWith([])
|
||||
expect(app.canvas.setGraph).toHaveBeenCalledWith(app.rootGraph)
|
||||
})
|
||||
|
||||
it('restores saved subgraph navigation when the subgraph exists', () => {
|
||||
const tracker = createTracker()
|
||||
const subgraph = fromPartial<Subgraph>({ id: 'subgraph-1' })
|
||||
app.rootGraph.subgraphs.set('subgraph-1', subgraph)
|
||||
mockSubgraphNavigationStore.exportState.mockReturnValue(['subgraph-1'])
|
||||
|
||||
tracker.store()
|
||||
tracker.restore()
|
||||
|
||||
expect(app.canvas.setGraph).toHaveBeenCalledWith(subgraph)
|
||||
})
|
||||
})
|
||||
|
||||
describe('captureCanvasState', () => {
|
||||
@@ -260,7 +122,7 @@ describe('ChangeTracker', () => {
|
||||
const tracker = createTracker()
|
||||
const original = tracker.activeState
|
||||
|
||||
const spy = vi.spyOn(app, 'graph', 'get').mockReturnValue(undefined)
|
||||
const spy = vi.spyOn(app, 'graph', 'get').mockReturnValue(null as never)
|
||||
tracker.captureCanvasState()
|
||||
spy.mockRestore()
|
||||
|
||||
@@ -307,32 +169,9 @@ describe('ChangeTracker', () => {
|
||||
expect.stringContaining('captureCanvasState')
|
||||
)
|
||||
})
|
||||
|
||||
it('reports inactive tracker calls only once for the same workflow', () => {
|
||||
const tracker = createTracker()
|
||||
tracker.workflow.path = '/test/dedupe-workflow.json'
|
||||
mockWorkflowStore.activeWorkflow = { changeTracker: {} }
|
||||
|
||||
tracker.captureCanvasState()
|
||||
tracker.captureCanvasState()
|
||||
|
||||
expect(mockAssert).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
describe('state capture', () => {
|
||||
it('sets the active state without pushing undo when none exists yet', () => {
|
||||
const tracker = createTracker(createState(1))
|
||||
const changed = createState(2)
|
||||
Reflect.set(tracker, 'activeState', undefined)
|
||||
mockCanvasState(changed)
|
||||
|
||||
tracker.captureCanvasState()
|
||||
|
||||
expect(tracker.activeState).toEqual(changed)
|
||||
expect(tracker.undoQueue).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('pushes to undoQueue, updates activeState, and calls updateModified', () => {
|
||||
const initial = createState(1)
|
||||
const tracker = createTracker(initial)
|
||||
@@ -399,19 +238,6 @@ describe('ChangeTracker', () => {
|
||||
|
||||
expect(tracker.undoQueue).toHaveLength(ChangeTracker.MAX_HISTORY)
|
||||
})
|
||||
|
||||
it('does not capture until the outer change transaction finishes', () => {
|
||||
const tracker = createTracker(createState(1))
|
||||
tracker.beforeChange()
|
||||
tracker.beforeChange()
|
||||
mockCanvasState(createState(2))
|
||||
|
||||
tracker.afterChange()
|
||||
expect(app.rootGraph.serialize).not.toHaveBeenCalled()
|
||||
|
||||
tracker.afterChange()
|
||||
expect(app.rootGraph.serialize).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -476,105 +302,6 @@ describe('ChangeTracker', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateModified', () => {
|
||||
it('updates workflow modified state when the store can find it', () => {
|
||||
const state = createState(1)
|
||||
const tracker = createTracker(state)
|
||||
const workflow = { isModified: true }
|
||||
mockWorkflowStore.getWorkflowByPath.mockReturnValue(workflow)
|
||||
|
||||
tracker.updateModified()
|
||||
expect(workflow.isModified).toBe(false)
|
||||
|
||||
tracker.activeState = createState(2)
|
||||
tracker.updateModified()
|
||||
expect(workflow.isModified).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('undo and redo', () => {
|
||||
it('restores previous state and moves the current state to the target queue', async () => {
|
||||
const initial = createState(1)
|
||||
const changed = createState(2)
|
||||
const tracker = createTracker(changed)
|
||||
tracker.undoQueue.push(initial)
|
||||
|
||||
await tracker.undo()
|
||||
|
||||
expect(app.loadGraphData).toHaveBeenCalledWith(
|
||||
initial,
|
||||
false,
|
||||
false,
|
||||
tracker.workflow,
|
||||
{
|
||||
checkForRerouteMigration: false,
|
||||
silentAssetErrors: true
|
||||
}
|
||||
)
|
||||
expect(tracker.activeState).toBe(initial)
|
||||
expect(tracker.redoQueue).toEqual([changed])
|
||||
expect(tracker._restoringState).toBe(false)
|
||||
})
|
||||
|
||||
it('clears restoring state when loading fails', async () => {
|
||||
const tracker = createTracker(createState(2))
|
||||
tracker.undoQueue.push(createState(1))
|
||||
vi.mocked(app.loadGraphData).mockRejectedValueOnce(
|
||||
new Error('load failed')
|
||||
)
|
||||
|
||||
await expect(tracker.undo()).rejects.toThrow('load failed')
|
||||
|
||||
expect(tracker._restoringState).toBe(false)
|
||||
})
|
||||
|
||||
it('does nothing when no previous state exists', async () => {
|
||||
const tracker = createTracker(createState(1))
|
||||
|
||||
await tracker.undo()
|
||||
|
||||
expect(app.loadGraphData).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('handles keyboard undo and redo shortcuts', async () => {
|
||||
const tracker = createTracker()
|
||||
const undo = vi.spyOn(tracker, 'undo').mockResolvedValue()
|
||||
const redo = vi.spyOn(tracker, 'redo').mockResolvedValue()
|
||||
|
||||
await expect(
|
||||
tracker.undoRedo(
|
||||
new KeyboardEvent('keydown', { key: 'z', ctrlKey: true })
|
||||
)
|
||||
).resolves.toBe(true)
|
||||
await expect(
|
||||
tracker.undoRedo(
|
||||
new KeyboardEvent('keydown', {
|
||||
key: 'z',
|
||||
ctrlKey: true,
|
||||
shiftKey: true
|
||||
})
|
||||
)
|
||||
).resolves.toBe(true)
|
||||
await expect(
|
||||
tracker.undoRedo(
|
||||
new KeyboardEvent('keydown', { key: 'y', metaKey: true })
|
||||
)
|
||||
).resolves.toBe(true)
|
||||
await expect(
|
||||
tracker.undoRedo(
|
||||
new KeyboardEvent('keydown', {
|
||||
key: 'z',
|
||||
ctrlKey: true,
|
||||
altKey: true
|
||||
})
|
||||
)
|
||||
).resolves.toBeUndefined()
|
||||
|
||||
expect(undo).toHaveBeenCalledOnce()
|
||||
expect(redo).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('checkState (deprecated)', () => {
|
||||
it('delegates to captureCanvasState', () => {
|
||||
const tracker = createTracker(createState(1))
|
||||
@@ -585,378 +312,5 @@ describe('ChangeTracker', () => {
|
||||
|
||||
expect(tracker.activeState).toEqual(changed)
|
||||
})
|
||||
|
||||
it('warns only once before delegating', () => {
|
||||
const tracker = createTracker(createState(1))
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
|
||||
tracker.checkState()
|
||||
tracker.checkState()
|
||||
|
||||
expect(warn).toHaveBeenCalledOnce()
|
||||
warn.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('bindInput', () => {
|
||||
it('returns false for missing canvas or body elements', () => {
|
||||
expect(ChangeTracker.bindInput(null)).toBe(false)
|
||||
expect(ChangeTracker.bindInput(document.createElement('canvas'))).toBe(
|
||||
false
|
||||
)
|
||||
expect(ChangeTracker.bindInput(document.body)).toBe(false)
|
||||
})
|
||||
|
||||
it('captures state once when an input-like element changes', () => {
|
||||
const tracker = createTracker()
|
||||
const capture = vi.spyOn(tracker, 'captureCanvasState')
|
||||
const input = document.createElement('input')
|
||||
|
||||
expect(ChangeTracker.bindInput(input)).toBe(true)
|
||||
input.dispatchEvent(new Event('change'))
|
||||
input.dispatchEvent(new Event('change'))
|
||||
|
||||
expect(capture).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('binds textarea-like elements that expose an input handler slot', () => {
|
||||
const tracker = createTracker()
|
||||
const capture = vi.spyOn(tracker, 'captureCanvasState')
|
||||
const element = document.createElement('div') as HTMLElement & {
|
||||
oninput: unknown
|
||||
}
|
||||
element.oninput = null
|
||||
|
||||
expect(ChangeTracker.bindInput(element)).toBe(true)
|
||||
element.dispatchEvent(new Event('change'))
|
||||
|
||||
expect(capture).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
describe('init', () => {
|
||||
let windowListeners: ListenerMap
|
||||
let documentListeners: ListenerMap
|
||||
let windowAddSpy: MockInstance
|
||||
let documentAddSpy: MockInstance
|
||||
let rafSpy: MockInstance
|
||||
let originalProcessMouseUp: typeof LGraphCanvas.prototype.processMouseUp
|
||||
let originalPrompt: typeof LGraphCanvas.prototype.prompt
|
||||
let originalClose: typeof LiteGraph.ContextMenu.prototype.close
|
||||
|
||||
beforeEach(() => {
|
||||
windowListeners = {}
|
||||
documentListeners = {}
|
||||
originalProcessMouseUp = LGraphCanvas.prototype.processMouseUp
|
||||
originalPrompt = LGraphCanvas.prototype.prompt
|
||||
originalClose = LiteGraph.ContextMenu.prototype.close
|
||||
windowAddSpy = vi
|
||||
.spyOn(window, 'addEventListener')
|
||||
.mockImplementation((type, listener) => {
|
||||
storeListener(windowListeners, type, listener)
|
||||
})
|
||||
documentAddSpy = vi
|
||||
.spyOn(document, 'addEventListener')
|
||||
.mockImplementation((type, listener) => {
|
||||
storeListener(documentListeners, type, listener)
|
||||
})
|
||||
rafSpy = vi
|
||||
.spyOn(window, 'requestAnimationFrame')
|
||||
.mockImplementation((callback) => {
|
||||
callback(0)
|
||||
return 1
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
LGraphCanvas.prototype.processMouseUp = originalProcessMouseUp
|
||||
LGraphCanvas.prototype.prompt = originalPrompt
|
||||
LiteGraph.ContextMenu.prototype.close = originalClose
|
||||
windowAddSpy.mockRestore()
|
||||
documentAddSpy.mockRestore()
|
||||
rafSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('captures changes from registered browser, graph, and API events', async () => {
|
||||
const processMouseUp = vi.fn(() => true)
|
||||
const prompt = vi.fn()
|
||||
const close = vi.fn(() => true)
|
||||
|
||||
LGraphCanvas.prototype.processMouseUp = processMouseUp
|
||||
LGraphCanvas.prototype.prompt = prompt
|
||||
LiteGraph.ContextMenu.prototype.close = close
|
||||
|
||||
ChangeTracker.init()
|
||||
const tracker = createTracker()
|
||||
const capture = vi.spyOn(tracker, 'captureCanvasState')
|
||||
|
||||
dispatchStored(windowListeners, 'mouseup', new MouseEvent('mouseup'))
|
||||
getApiListener('promptQueued')(
|
||||
new CustomEvent('promptQueued', {
|
||||
detail: fromPartial<ExecutedWsMessage>({})
|
||||
})
|
||||
)
|
||||
getApiListener('graphCleared')(
|
||||
new CustomEvent('graphCleared', {
|
||||
detail: fromPartial<ExecutedWsMessage>({})
|
||||
})
|
||||
)
|
||||
dispatchStored(
|
||||
documentListeners,
|
||||
'litegraph:canvas',
|
||||
new CustomEvent('litegraph:canvas', {
|
||||
detail: { subType: 'before-change' }
|
||||
})
|
||||
)
|
||||
dispatchStored(
|
||||
documentListeners,
|
||||
'litegraph:canvas',
|
||||
new CustomEvent('litegraph:canvas', {
|
||||
detail: { subType: 'after-change' }
|
||||
})
|
||||
)
|
||||
|
||||
expect(capture).toHaveBeenCalledTimes(4)
|
||||
|
||||
dispatchStored(
|
||||
windowListeners,
|
||||
'keydown',
|
||||
new KeyboardEvent('keydown', { key: 'Control' })
|
||||
)
|
||||
await flushAsyncFrame()
|
||||
dispatchStored(windowListeners, 'keyup', new KeyboardEvent('keyup'))
|
||||
|
||||
expect(capture).toHaveBeenCalledTimes(5)
|
||||
|
||||
const undoRedo = vi.spyOn(tracker, 'undoRedo').mockResolvedValue(true)
|
||||
dispatchStored(
|
||||
windowListeners,
|
||||
'keydown',
|
||||
new KeyboardEvent('keydown', { key: 'z', ctrlKey: true })
|
||||
)
|
||||
await flushAsyncFrame()
|
||||
|
||||
expect(undoRedo).toHaveBeenCalledOnce()
|
||||
expect(capture).toHaveBeenCalledTimes(5)
|
||||
|
||||
undoRedo.mockResolvedValue(undefined)
|
||||
dispatchStored(
|
||||
windowListeners,
|
||||
'keydown',
|
||||
new KeyboardEvent('keydown', { key: 'a' })
|
||||
)
|
||||
await flushAsyncFrame()
|
||||
|
||||
expect(capture).toHaveBeenCalledTimes(6)
|
||||
|
||||
const input = document.createElement('input')
|
||||
document.body.append(input)
|
||||
input.focus()
|
||||
dispatchStored(
|
||||
windowListeners,
|
||||
'keydown',
|
||||
new KeyboardEvent('keydown', { key: 'b' })
|
||||
)
|
||||
await flushAsyncFrame()
|
||||
input.remove()
|
||||
|
||||
expect(capture).toHaveBeenCalledTimes(6)
|
||||
|
||||
mockMaskEditorIsOpened.mockReturnValue(true)
|
||||
dispatchStored(
|
||||
windowListeners,
|
||||
'keydown',
|
||||
new KeyboardEvent('keydown', { key: 'c' })
|
||||
)
|
||||
await flushAsyncFrame()
|
||||
|
||||
expect(capture).toHaveBeenCalledTimes(6)
|
||||
|
||||
const canvas = {} as LGraphCanvas
|
||||
LGraphCanvas.prototype.processMouseUp.call(
|
||||
canvas,
|
||||
new MouseEvent('mouseup') as CanvasPointerEvent
|
||||
)
|
||||
|
||||
expect(processMouseUp).toHaveBeenCalledOnce()
|
||||
expect(capture).toHaveBeenCalledTimes(7)
|
||||
|
||||
const promptCallback = vi.fn()
|
||||
LGraphCanvas.prototype.prompt.call(
|
||||
canvas,
|
||||
'title',
|
||||
'value',
|
||||
promptCallback,
|
||||
new MouseEvent('mouseup') as CanvasPointerEvent
|
||||
)
|
||||
const extendedCallback = prompt.mock.calls[0]?.[2] as
|
||||
| ((value: string) => void)
|
||||
| undefined
|
||||
extendedCallback?.('updated')
|
||||
|
||||
expect(promptCallback).toHaveBeenCalledWith('updated')
|
||||
expect(capture).toHaveBeenCalledTimes(8)
|
||||
|
||||
LiteGraph.ContextMenu.prototype.close.call(
|
||||
{} as InstanceType<typeof LiteGraph.ContextMenu>,
|
||||
new MouseEvent('mouseup')
|
||||
)
|
||||
|
||||
expect(close).toHaveBeenCalledOnce()
|
||||
expect(capture).toHaveBeenCalledTimes(9)
|
||||
})
|
||||
|
||||
it('ignores repeat keydowns and missing active trackers', async () => {
|
||||
ChangeTracker.init()
|
||||
const tracker = createTracker()
|
||||
const capture = vi.spyOn(tracker, 'captureCanvasState')
|
||||
|
||||
dispatchStored(
|
||||
windowListeners,
|
||||
'keydown',
|
||||
new KeyboardEvent('keydown', { key: 'x', repeat: true })
|
||||
)
|
||||
await flushAsyncFrame()
|
||||
expect(capture).not.toHaveBeenCalled()
|
||||
|
||||
mockWorkflowStore.activeWorkflow = null
|
||||
dispatchStored(
|
||||
windowListeners,
|
||||
'keydown',
|
||||
new KeyboardEvent('keydown', { key: 'x' })
|
||||
)
|
||||
await flushAsyncFrame()
|
||||
expect(capture).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('stores executed outputs for the workflow that owns the prompt', () => {
|
||||
ChangeTracker.init()
|
||||
const tracker = createTracker()
|
||||
const executed = getApiListener('executed')
|
||||
mockExecutionStore.queuedJobs = {
|
||||
promptA: { workflow: { changeTracker: tracker } }
|
||||
}
|
||||
|
||||
executed(
|
||||
new CustomEvent('executed', {
|
||||
detail: fromPartial<ExecutedWsMessage>({
|
||||
prompt_id: 'promptA',
|
||||
node: '1',
|
||||
output: { images: ['first'] }
|
||||
} as PartialDeep<ExecutedWsMessage>)
|
||||
})
|
||||
)
|
||||
executed(
|
||||
new CustomEvent('executed', {
|
||||
detail: fromPartial<ExecutedWsMessage>({
|
||||
prompt_id: 'promptA',
|
||||
node: '1',
|
||||
merge: true,
|
||||
output: { images: ['second'], text: ['caption'] }
|
||||
} as PartialDeep<ExecutedWsMessage>)
|
||||
})
|
||||
)
|
||||
executed(
|
||||
new CustomEvent('executed', {
|
||||
detail: fromPartial<ExecutedWsMessage>({
|
||||
prompt_id: 'missing',
|
||||
node: '2',
|
||||
output: { images: ['ignored'] }
|
||||
} as PartialDeep<ExecutedWsMessage>)
|
||||
})
|
||||
)
|
||||
|
||||
expect(tracker.nodeOutputs).toEqual({
|
||||
1: { images: ['first', 'second'], text: ['caption'] }
|
||||
})
|
||||
})
|
||||
|
||||
it('replaces non-array executed outputs during merge updates', () => {
|
||||
ChangeTracker.init()
|
||||
const tracker = createTracker()
|
||||
const executed = getApiListener('executed')
|
||||
mockExecutionStore.queuedJobs = {
|
||||
promptA: { workflow: { changeTracker: tracker } }
|
||||
}
|
||||
|
||||
executed(
|
||||
new CustomEvent('executed', {
|
||||
detail: fromPartial<ExecutedWsMessage>({
|
||||
prompt_id: 'promptA',
|
||||
node: '1',
|
||||
output: { value: 'old' }
|
||||
} as PartialDeep<ExecutedWsMessage>)
|
||||
})
|
||||
)
|
||||
executed(
|
||||
new CustomEvent('executed', {
|
||||
detail: fromPartial<ExecutedWsMessage>({
|
||||
prompt_id: 'promptA',
|
||||
node: '1',
|
||||
merge: true,
|
||||
output: { value: 'new' }
|
||||
} as PartialDeep<ExecutedWsMessage>)
|
||||
})
|
||||
)
|
||||
|
||||
expect(tracker.nodeOutputs).toEqual({
|
||||
1: { value: 'new' }
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('graphEqual', () => {
|
||||
it('compares workflow nodes as an unordered set and ignores extra.ds', () => {
|
||||
const first = createState(2)
|
||||
const second = fromPartial<ComfyWorkflowJSON>({
|
||||
...createState(),
|
||||
nodes: [...first.nodes].reverse(),
|
||||
links: first.links,
|
||||
groups: first.groups,
|
||||
extra: { ds: { scale: 2 } }
|
||||
} as PartialDeep<ComfyWorkflowJSON>)
|
||||
|
||||
expect(ChangeTracker.graphEqual(first, first)).toBe(true)
|
||||
expect(ChangeTracker.graphEqual(first, second)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false for non-object values and meaningful graph differences', () => {
|
||||
const first = createState(1)
|
||||
const differentNodes = createState(2)
|
||||
const differentLinks = fromPartial<ComfyWorkflowJSON>({
|
||||
...first,
|
||||
links: [[1, 1, 0, 2, 0, 'MODEL']]
|
||||
} as PartialDeep<ComfyWorkflowJSON>)
|
||||
|
||||
expect(ChangeTracker.graphEqual(first, null)).toBe(false)
|
||||
expect(ChangeTracker.graphEqual(first, differentNodes)).toBe(false)
|
||||
expect(ChangeTracker.graphEqual(first, differentLinks)).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for extra properties other than viewport state', () => {
|
||||
const first = createState()
|
||||
const second = fromPartial<ComfyWorkflowJSON>({
|
||||
...first,
|
||||
extra: { custom: true }
|
||||
} as PartialDeep<ComfyWorkflowJSON>)
|
||||
|
||||
expect(ChangeTracker.graphEqual(first, second)).toBe(false)
|
||||
})
|
||||
|
||||
it.each([
|
||||
'floatingLinks',
|
||||
'reroutes',
|
||||
'groups',
|
||||
'definitions',
|
||||
'subgraphs'
|
||||
] as const)('returns false when %s differs', (key) => {
|
||||
const first = createState()
|
||||
const second = fromPartial<ComfyWorkflowJSON>({
|
||||
...first,
|
||||
[key]: [{ id: 1 }]
|
||||
} as PartialDeep<ComfyWorkflowJSON>)
|
||||
|
||||
expect(ChangeTracker.graphEqual(first, second)).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -439,7 +439,7 @@ export class ChangeTracker {
|
||||
return false
|
||||
}
|
||||
|
||||
static graphEqual(a: ComfyWorkflowJSON | null, b: ComfyWorkflowJSON | null) {
|
||||
static graphEqual(a: ComfyWorkflowJSON, b: ComfyWorkflowJSON) {
|
||||
if (a === b) return true
|
||||
|
||||
if (typeof a == 'object' && a && typeof b == 'object' && b) {
|
||||
|
||||
@@ -1,20 +1,11 @@
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { describe, expect, test, vi } from 'vitest'
|
||||
|
||||
import type { LGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { ComponentWidgetImpl, DOMWidgetImpl } from '@/scripts/domWidget'
|
||||
import { addWidget, isComponentWidget, isDOMWidget } from '@/scripts/domWidget'
|
||||
|
||||
const { registerWidget, unregisterWidget } = vi.hoisted(() => ({
|
||||
registerWidget: vi.fn(),
|
||||
unregisterWidget: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/domWidgetStore', () => ({
|
||||
useDomWidgetStore: () => ({
|
||||
registerWidget,
|
||||
unregisterWidget
|
||||
unregisterWidget: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
@@ -105,266 +96,3 @@ describe('BaseDOMWidgetImpl.isVisible', () => {
|
||||
expect(widget.isVisible()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('DOMWidgetImpl', () => {
|
||||
test('identifies DOM and component widgets', () => {
|
||||
const node = new LGraphNode('test-node')
|
||||
const domWidget = new DOMWidgetImpl({
|
||||
node,
|
||||
name: 'dom',
|
||||
type: 'text',
|
||||
element: document.createElement('textarea'),
|
||||
options: {}
|
||||
})
|
||||
const componentWidget = new ComponentWidgetImpl({
|
||||
node,
|
||||
name: 'component',
|
||||
component: { template: '<div />' },
|
||||
inputSpec: { name: 'component', type: 'STRING' },
|
||||
options: {}
|
||||
})
|
||||
|
||||
expect(isDOMWidget(domWidget)).toBe(true)
|
||||
expect(isDOMWidget(componentWidget)).toBe(false)
|
||||
expect(isComponentWidget(componentWidget)).toBe(true)
|
||||
expect(isComponentWidget(domWidget)).toBe(false)
|
||||
})
|
||||
|
||||
test('uses option-backed values, callbacks, and margins over defaults', () => {
|
||||
const node = new LGraphNode('test-node')
|
||||
let value = 'initial'
|
||||
const setValue = vi.fn((next: string) => {
|
||||
value = next
|
||||
})
|
||||
const callback = vi.fn()
|
||||
const widget = new DOMWidgetImpl({
|
||||
node,
|
||||
name: 'text',
|
||||
type: 'text',
|
||||
element: document.createElement('textarea'),
|
||||
options: {
|
||||
getValue: () => value,
|
||||
setValue,
|
||||
margin: 4
|
||||
}
|
||||
})
|
||||
widget.callback = callback
|
||||
const defaultWidget = new DOMWidgetImpl({
|
||||
node,
|
||||
name: 'text-default',
|
||||
type: 'text',
|
||||
element: document.createElement('textarea'),
|
||||
options: {}
|
||||
})
|
||||
|
||||
widget.value = 'next'
|
||||
|
||||
expect(widget.value).toBe('next')
|
||||
expect(widget.margin).toBe(4)
|
||||
expect(setValue).toHaveBeenCalledWith('next')
|
||||
expect(callback).toHaveBeenCalledWith('next')
|
||||
expect(defaultWidget.value).toBe('')
|
||||
expect(defaultWidget.margin).toBe(10)
|
||||
})
|
||||
|
||||
test('draws zoom placeholders and delegates visible draws', () => {
|
||||
const node = new LGraphNode('test-node')
|
||||
vi.spyOn(node, 'isWidgetVisible').mockReturnValue(true)
|
||||
const onDraw = vi.fn()
|
||||
const widget = new DOMWidgetImpl({
|
||||
node,
|
||||
name: 'text',
|
||||
type: 'text',
|
||||
element: document.createElement('textarea'),
|
||||
options: {
|
||||
hideOnZoom: true,
|
||||
margin: 5,
|
||||
onDraw
|
||||
}
|
||||
})
|
||||
const ctx = fromPartial<CanvasRenderingContext2D>({
|
||||
beginPath: vi.fn(),
|
||||
fill: vi.fn(),
|
||||
fillStyle: '#000',
|
||||
rect: vi.fn()
|
||||
})
|
||||
|
||||
widget.draw(ctx, node, 100, 10, 40, true)
|
||||
|
||||
expect(ctx.rect).toHaveBeenCalledWith(5, 15, 90, 30)
|
||||
expect(ctx.fill).toHaveBeenCalledOnce()
|
||||
expect(ctx.fillStyle).toBe('#000')
|
||||
expect(onDraw).toHaveBeenCalledWith(widget)
|
||||
})
|
||||
|
||||
test('skips placeholder drawing when hidden', () => {
|
||||
const node = new LGraphNode('test-node')
|
||||
vi.spyOn(node, 'isWidgetVisible').mockReturnValue(false)
|
||||
const onDraw = vi.fn()
|
||||
const widget = new DOMWidgetImpl({
|
||||
node,
|
||||
name: 'text',
|
||||
type: 'text',
|
||||
element: document.createElement('textarea'),
|
||||
options: {
|
||||
hideOnZoom: true,
|
||||
onDraw
|
||||
}
|
||||
})
|
||||
const ctx = fromPartial<CanvasRenderingContext2D>({
|
||||
beginPath: vi.fn(),
|
||||
fill: vi.fn(),
|
||||
fillStyle: '#000',
|
||||
rect: vi.fn()
|
||||
})
|
||||
|
||||
widget.draw(ctx, node, 100, 10, 40, true)
|
||||
|
||||
expect(ctx.rect).not.toHaveBeenCalled()
|
||||
expect(onDraw).toHaveBeenCalledWith(widget)
|
||||
})
|
||||
|
||||
test('computes hidden, option, percent, and fallback layout sizes', () => {
|
||||
const node = new LGraphNode('test-node')
|
||||
node.size = [100, 200]
|
||||
const hiddenWidget = new DOMWidgetImpl({
|
||||
node,
|
||||
name: 'hidden',
|
||||
type: 'hidden',
|
||||
element: document.createElement('textarea'),
|
||||
options: {}
|
||||
})
|
||||
const optionWidget = new DOMWidgetImpl({
|
||||
node,
|
||||
name: 'option',
|
||||
type: 'text',
|
||||
element: document.createElement('textarea'),
|
||||
options: {
|
||||
getMinHeight: () => 11,
|
||||
getMaxHeight: () => 88,
|
||||
getHeight: () => 44
|
||||
}
|
||||
})
|
||||
const percentWidget = new DOMWidgetImpl({
|
||||
node,
|
||||
name: 'percent',
|
||||
type: 'text',
|
||||
element: document.createElement('textarea'),
|
||||
options: {
|
||||
getMinHeight: () => 10,
|
||||
getMaxHeight: () => 60,
|
||||
getHeight: () => '25%'
|
||||
}
|
||||
})
|
||||
const fallbackWidget = new DOMWidgetImpl({
|
||||
node,
|
||||
name: 'fallback',
|
||||
type: 'text',
|
||||
element: document.createElement('textarea'),
|
||||
options: {
|
||||
getHeight: () => 40
|
||||
}
|
||||
})
|
||||
|
||||
expect(hiddenWidget.computeLayoutSize(node)).toEqual({
|
||||
minHeight: 0,
|
||||
maxHeight: 0,
|
||||
minWidth: 0
|
||||
})
|
||||
expect(optionWidget.computeLayoutSize(node)).toEqual({
|
||||
minHeight: 11,
|
||||
maxHeight: 88,
|
||||
minWidth: 0
|
||||
})
|
||||
expect(percentWidget.computeLayoutSize(node)).toEqual({
|
||||
minHeight: 10,
|
||||
maxHeight: 60,
|
||||
minWidth: 0
|
||||
})
|
||||
expect(fallbackWidget.computeLayoutSize(node)).toEqual({
|
||||
minHeight: 40,
|
||||
maxHeight: undefined,
|
||||
minWidth: 0
|
||||
})
|
||||
})
|
||||
|
||||
test('registers widgets immediately and through node lifecycle callbacks', () => {
|
||||
registerWidget.mockClear()
|
||||
unregisterWidget.mockClear()
|
||||
const node = new LGraphNode('test-node')
|
||||
node.graph = {} as LGraph
|
||||
const beforeResize = vi.fn()
|
||||
const afterResize = vi.fn()
|
||||
const widget = new DOMWidgetImpl({
|
||||
node,
|
||||
name: 'text',
|
||||
type: 'text',
|
||||
element: document.createElement('textarea'),
|
||||
options: {
|
||||
beforeResize,
|
||||
afterResize
|
||||
}
|
||||
})
|
||||
vi.spyOn(node, 'addCustomWidget')
|
||||
|
||||
addWidget(node, widget)
|
||||
node.onAdded?.(node.graph)
|
||||
node.onResize?.([0, 0])
|
||||
node.onRemoved?.()
|
||||
|
||||
expect(node.addCustomWidget).toHaveBeenCalledWith(widget)
|
||||
expect(registerWidget).toHaveBeenCalledWith(widget)
|
||||
expect(registerWidget).toHaveBeenCalledTimes(2)
|
||||
expect(beforeResize).toHaveBeenCalledWith(node)
|
||||
expect(afterResize).toHaveBeenCalledWith(node)
|
||||
expect(unregisterWidget).toHaveBeenCalledWith(widget.id)
|
||||
})
|
||||
|
||||
test('computes component layout and serializes raw values', () => {
|
||||
const node = new LGraphNode('test-node')
|
||||
const value = { nested: true }
|
||||
const widget = new ComponentWidgetImpl({
|
||||
node,
|
||||
name: 'component',
|
||||
component: { template: '<div />' },
|
||||
inputSpec: { name: 'component', type: 'STRING' },
|
||||
options: {
|
||||
getValue: () => value,
|
||||
getMinHeight: () => 12,
|
||||
getMaxHeight: () => 48
|
||||
}
|
||||
})
|
||||
|
||||
expect(widget.computeLayoutSize()).toEqual({
|
||||
minHeight: 12,
|
||||
maxHeight: 48,
|
||||
minWidth: 0
|
||||
})
|
||||
expect(widget.serializeValue()).toEqual({ nested: true })
|
||||
})
|
||||
|
||||
test('adds DOM widgets through LGraphNode prototype helper', () => {
|
||||
const node = new LGraphNode('test-node')
|
||||
const element = document.createElement('textarea')
|
||||
let value = 'initial'
|
||||
const setValue = vi.fn((next: string) => {
|
||||
value = next
|
||||
})
|
||||
vi.spyOn(node, 'addCustomWidget')
|
||||
|
||||
const widget = node.addDOMWidget('text', 'textarea', element, {
|
||||
getValue: () => value,
|
||||
setValue
|
||||
})
|
||||
const callback = vi.fn()
|
||||
widget.callback = callback
|
||||
widget.value = 'next'
|
||||
|
||||
expect(node.addCustomWidget).toHaveBeenCalledWith(widget)
|
||||
expect(widget.element).toBe(element)
|
||||
expect(widget.options.hideOnZoom).toBe(true)
|
||||
expect(widget.value).toBe('next')
|
||||
expect(setValue).toHaveBeenCalledWith('next')
|
||||
expect(callback).toHaveBeenCalledWith('next')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
interface TestWidgetOptions {
|
||||
name: string
|
||||
type: string
|
||||
default?: IBaseWidget['value']
|
||||
multiline?: boolean
|
||||
}
|
||||
|
||||
function createWidgetFactory() {
|
||||
return (node: LGraphNode, options: TestWidgetOptions): IBaseWidget => {
|
||||
const widget = fromPartial<IBaseWidget>({
|
||||
name: options.name,
|
||||
type: options.type,
|
||||
value: options.default,
|
||||
options: { multiline: options.multiline }
|
||||
})
|
||||
node.widgets = [...(node.widgets ?? []), widget]
|
||||
return widget
|
||||
}
|
||||
}
|
||||
|
||||
vi.mock(
|
||||
'@/renderer/extensions/vueNodes/widgets/composables/useStringWidget',
|
||||
() => ({
|
||||
useStringWidget: () => createWidgetFactory()
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock(
|
||||
'@/renderer/extensions/vueNodes/widgets/composables/useFloatWidget',
|
||||
() => ({
|
||||
useFloatWidget: () => createWidgetFactory()
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock(
|
||||
'@/renderer/extensions/vueNodes/widgets/composables/useBooleanWidget',
|
||||
() => ({
|
||||
useBooleanWidget: () => createWidgetFactory()
|
||||
})
|
||||
)
|
||||
|
||||
import './errorNodeWidgets'
|
||||
|
||||
describe('errorNodeWidgets', () => {
|
||||
it('restores widgets from serialized values on error nodes', () => {
|
||||
const node = new LGraphNode('BrokenNode')
|
||||
const longText = 'serialized value with more than twenty chars'
|
||||
node.has_errors = true
|
||||
|
||||
node.onConfigure?.(
|
||||
fromPartial({
|
||||
widgets_values: ['short text', longText, 12, true, { nested: 'value' }]
|
||||
})
|
||||
)
|
||||
|
||||
expect(node.widgets).toHaveLength(5)
|
||||
expect(node.widgets?.map((widget) => widget.name)).toEqual([
|
||||
'UNKNOWN',
|
||||
'UNKNOWN_1',
|
||||
'UNKNOWN_2',
|
||||
'UNKNOWN_3',
|
||||
'UNKNOWN_4'
|
||||
])
|
||||
expect(node.widgets?.map((widget) => widget.label)).toEqual([
|
||||
'UNKNOWN',
|
||||
'UNKNOWN',
|
||||
'UNKNOWN',
|
||||
'UNKNOWN',
|
||||
'UNKNOWN'
|
||||
])
|
||||
expect(node.widgets?.map((widget) => widget.value)).toEqual([
|
||||
'short text',
|
||||
longText,
|
||||
12,
|
||||
true,
|
||||
'{"nested":"value"}'
|
||||
])
|
||||
expect(node.serialize_widgets).toBe(true)
|
||||
})
|
||||
|
||||
it('leaves normal nodes unchanged', () => {
|
||||
const node = new LGraphNode('HealthyNode')
|
||||
|
||||
node.onConfigure?.(
|
||||
fromPartial({
|
||||
widgets_values: ['ignored']
|
||||
})
|
||||
)
|
||||
|
||||
expect(node.widgets).toBeUndefined()
|
||||
expect(node.serialize_widgets).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -7,8 +7,7 @@ import {
|
||||
EXPECTED_PROMPT_NAN_COERCED,
|
||||
EXPECTED_WORKFLOW,
|
||||
mockFileReaderAbort,
|
||||
mockFileReaderError,
|
||||
mockFileReaderResult
|
||||
mockFileReaderError
|
||||
} from './__fixtures__/helpers'
|
||||
import { getFromAvifFile } from './avif'
|
||||
|
||||
@@ -84,11 +83,6 @@ describe('AVIF metadata', () => {
|
||||
mockFileReaderAbort('readAsArrayBuffer')
|
||||
expect(await getFromAvifFile(file)).toEqual({})
|
||||
})
|
||||
|
||||
it('resolves empty when the FileReader load has no result', async () => {
|
||||
mockFileReaderResult('readAsArrayBuffer', null)
|
||||
expect(await getFromAvifFile(file)).toEqual({})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -160,35 +154,6 @@ const buildInfeBox = (
|
||||
return buf
|
||||
}
|
||||
|
||||
const buildVersionedInfeBox = (
|
||||
itemId: number,
|
||||
itemType: string,
|
||||
version = 2
|
||||
): Uint8Array => {
|
||||
const itemIdSize = version === 2 ? 2 : version >= 3 ? 4 : 0
|
||||
const bodySize = 4 + itemIdSize + (version >= 2 ? 2 + 4 + 1 + 1 : 0)
|
||||
const totalSize = 8 + bodySize
|
||||
const buf = new Uint8Array(totalSize)
|
||||
const dv = new DataView(buf.buffer)
|
||||
setU32BE(dv, 0, totalSize)
|
||||
buf.set(new TextEncoder().encode('infe'), 4)
|
||||
buf[8] = version
|
||||
if (version >= 2) {
|
||||
let p = 12
|
||||
if (version === 2) {
|
||||
setU16BE(dv, p, itemId)
|
||||
p += 2
|
||||
} else {
|
||||
setU32BE(dv, p, itemId)
|
||||
p += 4
|
||||
}
|
||||
setU16BE(dv, p, 0)
|
||||
p += 2
|
||||
buf.set(new TextEncoder().encode(itemType.padEnd(4).slice(0, 4)), p)
|
||||
}
|
||||
return buf
|
||||
}
|
||||
|
||||
const buildIinfBox = (infeBoxes: Uint8Array[]): Uint8Array => {
|
||||
const bodySize = 4 + 2 + infeBoxes.reduce((s, b) => s + b.length, 0)
|
||||
const totalSize = 8 + bodySize
|
||||
@@ -205,31 +170,6 @@ const buildIinfBox = (infeBoxes: Uint8Array[]): Uint8Array => {
|
||||
return buf
|
||||
}
|
||||
|
||||
const buildVersionedIinfBox = (
|
||||
infeBoxes: Uint8Array[],
|
||||
version = 0
|
||||
): Uint8Array => {
|
||||
const countSize = version === 0 ? 2 : 4
|
||||
const bodySize = 4 + countSize + infeBoxes.reduce((s, b) => s + b.length, 0)
|
||||
const totalSize = 8 + bodySize
|
||||
const buf = new Uint8Array(totalSize)
|
||||
const dv = new DataView(buf.buffer)
|
||||
setU32BE(dv, 0, totalSize)
|
||||
buf.set(new TextEncoder().encode('iinf'), 4)
|
||||
buf[8] = version
|
||||
if (version === 0) {
|
||||
setU16BE(dv, 12, infeBoxes.length)
|
||||
} else {
|
||||
setU32BE(dv, 12, infeBoxes.length)
|
||||
}
|
||||
let off = 8 + 4 + countSize
|
||||
for (const ib of infeBoxes) {
|
||||
buf.set(ib, off)
|
||||
off += ib.length
|
||||
}
|
||||
return buf
|
||||
}
|
||||
|
||||
const buildIlocBox = (
|
||||
items: { itemId: number; extentOffset: number; extentLength: number }[]
|
||||
): Uint8Array => {
|
||||
@@ -259,95 +199,6 @@ const buildIlocBox = (
|
||||
return buf
|
||||
}
|
||||
|
||||
interface IlocItem {
|
||||
itemId: number
|
||||
extentOffset: number
|
||||
extentLength: number
|
||||
extents?: Array<{ extentOffset: number; extentLength: number }>
|
||||
}
|
||||
|
||||
const buildIlocBoxWithOptions = (
|
||||
items: IlocItem[],
|
||||
{
|
||||
version = 0,
|
||||
baseOffsetSize = 0,
|
||||
indexSize = 0
|
||||
}: { version?: number; baseOffsetSize?: number; indexSize?: number } = {}
|
||||
): Uint8Array => {
|
||||
const itemCountSize = version < 2 ? 2 : 4
|
||||
const itemIdSize = version < 2 ? 2 : 4
|
||||
const constructionMethodSize = version === 1 || version === 2 ? 2 : 0
|
||||
const itemSizes = items.map((item) => {
|
||||
const extents = item.extents ?? [
|
||||
{
|
||||
extentOffset: item.extentOffset,
|
||||
extentLength: item.extentLength
|
||||
}
|
||||
]
|
||||
return (
|
||||
itemIdSize +
|
||||
constructionMethodSize +
|
||||
2 +
|
||||
baseOffsetSize +
|
||||
2 +
|
||||
extents.length * (indexSize + 4 + 4)
|
||||
)
|
||||
})
|
||||
const bodySize =
|
||||
4 + 1 + 1 + itemCountSize + itemSizes.reduce((sum, size) => sum + size, 0)
|
||||
const totalSize = 8 + bodySize
|
||||
const buf = new Uint8Array(totalSize)
|
||||
const dv = new DataView(buf.buffer)
|
||||
setU32BE(dv, 0, totalSize)
|
||||
buf.set(new TextEncoder().encode('iloc'), 4)
|
||||
buf[8] = version
|
||||
buf[12] = 0x44
|
||||
buf[13] = (baseOffsetSize << 4) | indexSize
|
||||
let p = 14
|
||||
if (version < 2) {
|
||||
setU16BE(dv, p, items.length)
|
||||
p += 2
|
||||
} else {
|
||||
setU32BE(dv, p, items.length)
|
||||
p += 4
|
||||
}
|
||||
for (const it of items) {
|
||||
if (version < 2) {
|
||||
setU16BE(dv, p, it.itemId)
|
||||
p += 2
|
||||
} else {
|
||||
setU32BE(dv, p, it.itemId)
|
||||
p += 4
|
||||
}
|
||||
if (version === 1 || version === 2) {
|
||||
setU16BE(dv, p, 0)
|
||||
p += 2
|
||||
}
|
||||
setU16BE(dv, p, 0)
|
||||
p += 2
|
||||
if (baseOffsetSize > 0) {
|
||||
setU32BE(dv, p, 0)
|
||||
p += baseOffsetSize
|
||||
}
|
||||
const extents = it.extents ?? [
|
||||
{
|
||||
extentOffset: it.extentOffset,
|
||||
extentLength: it.extentLength
|
||||
}
|
||||
]
|
||||
setU16BE(dv, p, extents.length)
|
||||
p += 2
|
||||
for (const extent of extents) {
|
||||
p += indexSize
|
||||
setU32BE(dv, p, extent.extentOffset)
|
||||
p += 4
|
||||
setU32BE(dv, p, extent.extentLength)
|
||||
p += 4
|
||||
}
|
||||
}
|
||||
return buf
|
||||
}
|
||||
|
||||
const buildMetaBox = (boxes: Uint8Array[]): Uint8Array => {
|
||||
const bodySize = 4 + boxes.reduce((s, b) => s + b.length, 0)
|
||||
const totalSize = 8 + bodySize
|
||||
@@ -380,13 +231,7 @@ interface BuildAvifOpts {
|
||||
ftypBrand?: string
|
||||
omitMeta?: boolean
|
||||
omitIloc?: boolean
|
||||
iinfVersion?: number
|
||||
infeVersion?: number
|
||||
ilocVersion?: number
|
||||
ilocBaseOffsetSize?: number
|
||||
ilocIndexSize?: number
|
||||
ilocExtents?: Array<{ extentOffset: number; extentLength: number }>
|
||||
rawExifData?: Uint8Array
|
||||
}
|
||||
|
||||
const buildAvifFile = (opts: BuildAvifOpts = {}): ArrayBuffer => {
|
||||
@@ -432,79 +277,6 @@ const buildAvifFile = (opts: BuildAvifOpts = {}): ArrayBuffer => {
|
||||
return buf.slice().buffer as ArrayBuffer
|
||||
}
|
||||
|
||||
const buildAvifFileWithOptions = (opts: BuildAvifOpts = {}): ArrayBuffer => {
|
||||
const {
|
||||
exifEntries = [],
|
||||
endian = 'II',
|
||||
itemType = 'Exif',
|
||||
ftypBrand = 'avif',
|
||||
omitMeta = false,
|
||||
omitIloc = false,
|
||||
iinfVersion = 0,
|
||||
infeVersion = 2,
|
||||
ilocVersion = 0,
|
||||
ilocBaseOffsetSize = 0,
|
||||
ilocIndexSize = 0,
|
||||
ilocExtents,
|
||||
rawExifData
|
||||
} = opts
|
||||
|
||||
const ftyp = buildFtypBox(ftypBrand)
|
||||
if (omitMeta) {
|
||||
return ftyp.slice().buffer as ArrayBuffer
|
||||
}
|
||||
|
||||
const exifData = rawExifData ?? buildExifBlob(exifEntries, endian)
|
||||
const infe = buildVersionedInfeBox(1, itemType, infeVersion)
|
||||
const iinf = buildVersionedIinfBox([infe], iinfVersion)
|
||||
|
||||
const realIloc = buildIlocBoxWithOptions(
|
||||
[
|
||||
{
|
||||
itemId: 1,
|
||||
extentOffset: 0,
|
||||
extentLength: exifData.length,
|
||||
extents: ilocExtents
|
||||
}
|
||||
],
|
||||
{
|
||||
version: ilocVersion,
|
||||
baseOffsetSize: ilocBaseOffsetSize,
|
||||
indexSize: ilocIndexSize
|
||||
}
|
||||
)
|
||||
const metaSize = 8 + 4 + iinf.length + (omitIloc ? 0 : realIloc.length)
|
||||
const exifOffset = ftyp.length + metaSize
|
||||
|
||||
const finalIloc = buildIlocBoxWithOptions(
|
||||
[
|
||||
{
|
||||
itemId: 1,
|
||||
extentOffset: exifOffset,
|
||||
extentLength: exifData.length,
|
||||
extents: ilocExtents
|
||||
}
|
||||
],
|
||||
{
|
||||
version: ilocVersion,
|
||||
baseOffsetSize: ilocBaseOffsetSize,
|
||||
indexSize: ilocIndexSize
|
||||
}
|
||||
)
|
||||
const finalInner = omitIloc ? [iinf] : [iinf, finalIloc]
|
||||
const meta = buildMetaBox(finalInner)
|
||||
|
||||
const total = ftyp.length + meta.length + exifData.length
|
||||
const buf = new Uint8Array(total)
|
||||
let p = 0
|
||||
buf.set(ftyp, p)
|
||||
p += ftyp.length
|
||||
buf.set(meta, p)
|
||||
p += meta.length
|
||||
buf.set(exifData, p)
|
||||
return buf.slice().buffer as ArrayBuffer
|
||||
}
|
||||
|
||||
const fileFromBuffer = (buffer: ArrayBuffer, name = 'test.avif'): File =>
|
||||
new File([buffer], name, { type: 'image/avif' })
|
||||
|
||||
@@ -547,52 +319,6 @@ describe('getFromAvifFile', () => {
|
||||
expect(result.workflow).toBe(JSON.stringify(JSON.parse(workflow)))
|
||||
})
|
||||
|
||||
it('extracts EXIF metadata from versioned item info and location boxes', async () => {
|
||||
const workflow = '{"versioned":true}'
|
||||
const file = fileFromBuffer(
|
||||
buildAvifFileWithOptions({
|
||||
exifEntries: [`workflow:${workflow}`],
|
||||
iinfVersion: 1,
|
||||
infeVersion: 3,
|
||||
ilocVersion: 2,
|
||||
ilocBaseOffsetSize: 4,
|
||||
ilocIndexSize: 4
|
||||
})
|
||||
)
|
||||
|
||||
const result = await getFromAvifFile(file)
|
||||
|
||||
expect(result.workflow).toBe(JSON.stringify(JSON.parse(workflow)))
|
||||
})
|
||||
|
||||
it('returns {} when the Exif item has no extents', async () => {
|
||||
const file = fileFromBuffer(
|
||||
buildAvifFileWithOptions({
|
||||
exifEntries: ['workflow:{}'],
|
||||
ilocExtents: []
|
||||
})
|
||||
)
|
||||
|
||||
const result = await getFromAvifFile(file)
|
||||
|
||||
expect(result).toEqual({})
|
||||
})
|
||||
|
||||
it('returns {} when the Exif payload has no TIFF header', async () => {
|
||||
const file = fileFromBuffer(
|
||||
buildAvifFileWithOptions({
|
||||
rawExifData: new TextEncoder().encode('not tiff data')
|
||||
})
|
||||
)
|
||||
|
||||
const result = await getFromAvifFile(file)
|
||||
|
||||
expect(result).toEqual({})
|
||||
expect(console.log).toHaveBeenCalledWith(
|
||||
'Warning: TIFF header not found in EXIF data.'
|
||||
)
|
||||
})
|
||||
|
||||
it('returns {} when AVIF major brand is not "avif"', async () => {
|
||||
const file = fileFromBuffer(
|
||||
buildAvifFile({ exifEntries: ['workflow:{}'], ftypBrand: 'heic' })
|
||||
|
||||
@@ -7,8 +7,7 @@ import {
|
||||
EXPECTED_PROMPT_NAN_COERCED,
|
||||
EXPECTED_WORKFLOW,
|
||||
mockFileReaderAbort,
|
||||
mockFileReaderError,
|
||||
mockFileReaderResult
|
||||
mockFileReaderError
|
||||
} from './__fixtures__/helpers'
|
||||
import { getFromWebmFile } from './ebml'
|
||||
|
||||
@@ -17,56 +16,6 @@ const nanFixturePath = path.resolve(
|
||||
__dirname,
|
||||
'__fixtures__/with_nan_metadata.webm'
|
||||
)
|
||||
const WEBM_SIGNATURE = new Uint8Array([0x1a, 0x45, 0xdf, 0xa3])
|
||||
const SIMPLE_TAG = new Uint8Array([0x67, 0xc8])
|
||||
const TAG_NAME = new Uint8Array([0x45, 0xa3])
|
||||
const TAG_VALUE = new Uint8Array([0x44, 0x87])
|
||||
const encoder = new TextEncoder()
|
||||
|
||||
function concatBytes(...chunks: Uint8Array[]) {
|
||||
const totalLength = chunks.reduce((sum, chunk) => sum + chunk.length, 0)
|
||||
const result = new Uint8Array(totalLength)
|
||||
let offset = 0
|
||||
|
||||
for (const chunk of chunks) {
|
||||
result.set(chunk, offset)
|
||||
offset += chunk.length
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
function bytes(...values: number[]) {
|
||||
return new Uint8Array(values)
|
||||
}
|
||||
|
||||
function vint(value: number) {
|
||||
return bytes(0x80 | value)
|
||||
}
|
||||
|
||||
function element(id: Uint8Array, value: string) {
|
||||
const encoded = encoder.encode(value)
|
||||
return concatBytes(id, vint(encoded.length), encoded)
|
||||
}
|
||||
|
||||
function simpleTag(name: string, value: string, useTwoByteSize = false) {
|
||||
const payload = concatBytes(
|
||||
element(TAG_NAME, name),
|
||||
element(TAG_VALUE, value)
|
||||
)
|
||||
const size = useTwoByteSize
|
||||
? bytes(0x40, payload.length)
|
||||
: vint(payload.length)
|
||||
return concatBytes(SIMPLE_TAG, size, payload)
|
||||
}
|
||||
|
||||
async function readWebm(bytes: Uint8Array) {
|
||||
return getFromWebmFile(
|
||||
new File([bytes as Uint8Array<ArrayBuffer>], 'test.webm', {
|
||||
type: 'video/webm'
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
describe('WebM/EBML metadata', () => {
|
||||
it('extracts workflow and prompt from EBML SimpleTag elements', async () => {
|
||||
@@ -97,89 +46,6 @@ describe('WebM/EBML metadata', () => {
|
||||
expect(result).toEqual({})
|
||||
})
|
||||
|
||||
it('extracts plain string tags and trims null-terminated values', async () => {
|
||||
const result = await readWebm(
|
||||
concatBytes(
|
||||
WEBM_SIGNATURE,
|
||||
simpleTag('\0Comment', ' hello \0ignored', true)
|
||||
)
|
||||
)
|
||||
|
||||
expect(result.comment).toBe('hello')
|
||||
})
|
||||
|
||||
it('ignores prompt tags whose value is not complete JSON', async () => {
|
||||
const result = await readWebm(
|
||||
concatBytes(WEBM_SIGNATURE, simpleTag('PROMPT', '{not-json'))
|
||||
)
|
||||
|
||||
expect(result.prompt).toBeUndefined()
|
||||
})
|
||||
|
||||
it('ignores prompt tags whose value has no JSON object', async () => {
|
||||
const result = await readWebm(
|
||||
concatBytes(WEBM_SIGNATURE, simpleTag('PROMPT', 'not-json'))
|
||||
)
|
||||
|
||||
expect(result.prompt).toBeUndefined()
|
||||
})
|
||||
|
||||
it('parses the first complete JSON object from a prompt tag', async () => {
|
||||
const result = await readWebm(
|
||||
concatBytes(
|
||||
WEBM_SIGNATURE,
|
||||
simpleTag('PROMPT', 'prefix {"outer":{"inner":1}} trailing')
|
||||
)
|
||||
)
|
||||
|
||||
expect(result.prompt).toEqual({ outer: { inner: 1 } })
|
||||
})
|
||||
|
||||
it('ignores tags whose name has no readable text', async () => {
|
||||
const payload = concatBytes(
|
||||
concatBytes(TAG_NAME, vint(2), bytes(0, 1)),
|
||||
element(TAG_VALUE, 'value')
|
||||
)
|
||||
|
||||
const result = await readWebm(
|
||||
concatBytes(WEBM_SIGNATURE, SIMPLE_TAG, vint(payload.length), payload)
|
||||
)
|
||||
|
||||
expect(result).toEqual({})
|
||||
})
|
||||
|
||||
it('ignores tag elements with zero-sized names', async () => {
|
||||
const payload = concatBytes(TAG_NAME, vint(0), element(TAG_VALUE, 'value'))
|
||||
|
||||
const result = await readWebm(
|
||||
concatBytes(WEBM_SIGNATURE, SIMPLE_TAG, vint(payload.length), payload)
|
||||
)
|
||||
|
||||
expect(result).toEqual({})
|
||||
})
|
||||
|
||||
it('ignores malformed SimpleTag encodings', async () => {
|
||||
const nameOnly = element(TAG_NAME, 'comment')
|
||||
|
||||
await expect(
|
||||
readWebm(concatBytes(WEBM_SIGNATURE, SIMPLE_TAG))
|
||||
).resolves.toEqual({})
|
||||
await expect(
|
||||
readWebm(concatBytes(WEBM_SIGNATURE, SIMPLE_TAG, bytes(0x00)))
|
||||
).resolves.toEqual({})
|
||||
await expect(
|
||||
readWebm(concatBytes(WEBM_SIGNATURE, SIMPLE_TAG, bytes(0x7f, 0xff)))
|
||||
).resolves.toEqual({})
|
||||
await expect(
|
||||
readWebm(concatBytes(WEBM_SIGNATURE, SIMPLE_TAG, vint(10), TAG_NAME))
|
||||
).resolves.toEqual({})
|
||||
await expect(
|
||||
readWebm(
|
||||
concatBytes(WEBM_SIGNATURE, SIMPLE_TAG, vint(nameOnly.length), nameOnly)
|
||||
)
|
||||
).resolves.toEqual({})
|
||||
})
|
||||
|
||||
describe('FileReader failure modes', () => {
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
@@ -194,10 +60,5 @@ describe('WebM/EBML metadata', () => {
|
||||
mockFileReaderAbort('readAsArrayBuffer')
|
||||
expect(await getFromWebmFile(file)).toEqual({})
|
||||
})
|
||||
|
||||
it('resolves empty when the FileReader load has no result', async () => {
|
||||
mockFileReaderResult('readAsArrayBuffer', null)
|
||||
expect(await getFromWebmFile(file)).toEqual({})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,8 +5,7 @@ import { ASCII, GltfSizeBytes } from '@/types/metadataTypes'
|
||||
import {
|
||||
EXPECTED_PROMPT_NAN_COERCED,
|
||||
mockFileReaderAbort,
|
||||
mockFileReaderError,
|
||||
mockFileReaderResult
|
||||
mockFileReaderError
|
||||
} from './__fixtures__/helpers'
|
||||
import { getGltfBinaryMetadata } from './gltf'
|
||||
|
||||
@@ -25,18 +24,6 @@ describe('GLTF binary metadata parser', () => {
|
||||
return chunkHeader
|
||||
}
|
||||
|
||||
const createChunkHeaderWithOptions = (
|
||||
jsonData: ArrayBuffer,
|
||||
chunkType = ASCII.JSON,
|
||||
chunkLength = jsonData.byteLength
|
||||
) => {
|
||||
const chunkHeader = new ArrayBuffer(GltfSizeBytes.CHUNK_HEADER)
|
||||
const chunkView = new DataView(chunkHeader)
|
||||
chunkView.setUint32(0, chunkLength, true)
|
||||
chunkView.setUint32(4, chunkType, true)
|
||||
return chunkHeader
|
||||
}
|
||||
|
||||
const setVersionHeader = (headerView: DataView, version: number) => {
|
||||
headerView.setUint32(4, version, true)
|
||||
}
|
||||
@@ -83,42 +70,6 @@ describe('GLTF binary metadata parser', () => {
|
||||
return new File([fileContent], 'test.glb', { type: 'model/gltf-binary' })
|
||||
}
|
||||
|
||||
interface MockGltfFileOptions {
|
||||
chunkLength?: number
|
||||
chunkType?: number
|
||||
magicNumber?: number
|
||||
}
|
||||
|
||||
function createMockGltfFileFromTextWithOptions(
|
||||
jsonText: string,
|
||||
{
|
||||
chunkLength,
|
||||
chunkType = ASCII.JSON,
|
||||
magicNumber = ASCII.GLTF
|
||||
}: MockGltfFileOptions = {}
|
||||
): File {
|
||||
const jsonData = new TextEncoder().encode(jsonText)
|
||||
const { header, headerView } = createGLTFFileStructure()
|
||||
|
||||
setHeaders(headerView, jsonData.buffer)
|
||||
setTypeHeader(headerView, magicNumber)
|
||||
|
||||
const chunkHeader = createChunkHeaderWithOptions(
|
||||
jsonData.buffer,
|
||||
chunkType,
|
||||
chunkLength
|
||||
)
|
||||
|
||||
const fileContent = new Uint8Array(
|
||||
header.byteLength + chunkHeader.byteLength + jsonData.byteLength
|
||||
)
|
||||
fileContent.set(new Uint8Array(header), 0)
|
||||
fileContent.set(new Uint8Array(chunkHeader), header.byteLength)
|
||||
fileContent.set(jsonData, header.byteLength + chunkHeader.byteLength)
|
||||
|
||||
return new File([fileContent], 'test.glb', { type: 'model/gltf-binary' })
|
||||
}
|
||||
|
||||
it('should extract workflow metadata from GLTF binary file', async () => {
|
||||
const testWorkflow = {
|
||||
nodes: [
|
||||
@@ -228,80 +179,6 @@ describe('GLTF binary metadata parser', () => {
|
||||
expect(metadata).toEqual({})
|
||||
})
|
||||
|
||||
it('returns empty when the GLB magic number is invalid', async () => {
|
||||
const mockFile = createMockGltfFileFromTextWithOptions('{}', {
|
||||
magicNumber: 0
|
||||
})
|
||||
|
||||
const metadata = await getGltfBinaryMetadata(mockFile)
|
||||
|
||||
expect(metadata).toEqual({})
|
||||
})
|
||||
|
||||
it('returns empty when the GLB is missing the first chunk header', async () => {
|
||||
const { header, headerView } = createGLTFFileStructure()
|
||||
setTypeHeader(headerView, ASCII.GLTF)
|
||||
setVersionHeader(headerView, 2)
|
||||
setTotalLengthHeader(headerView, GltfSizeBytes.HEADER)
|
||||
const mockFile = new File([header], 'header-only.glb')
|
||||
|
||||
const metadata = await getGltfBinaryMetadata(mockFile)
|
||||
|
||||
expect(metadata).toEqual({})
|
||||
})
|
||||
|
||||
it('returns empty when the first chunk is not JSON', async () => {
|
||||
const mockFile = createMockGltfFileFromTextWithOptions('{}', {
|
||||
chunkType: 0
|
||||
})
|
||||
|
||||
const metadata = await getGltfBinaryMetadata(mockFile)
|
||||
|
||||
expect(metadata).toEqual({})
|
||||
})
|
||||
|
||||
it('returns empty when the declared JSON chunk exceeds the buffer', async () => {
|
||||
const mockFile = createMockGltfFileFromTextWithOptions('{}', {
|
||||
chunkLength: 1024
|
||||
})
|
||||
|
||||
const metadata = await getGltfBinaryMetadata(mockFile)
|
||||
|
||||
expect(metadata).toEqual({})
|
||||
})
|
||||
|
||||
it('returns empty when the JSON chunk cannot be parsed', async () => {
|
||||
const mockFile = createMockGltfFileFromText('{not json')
|
||||
|
||||
const metadata = await getGltfBinaryMetadata(mockFile)
|
||||
|
||||
expect(metadata).toEqual({})
|
||||
})
|
||||
|
||||
it('returns empty when asset extras are missing', async () => {
|
||||
const mockFile = createMockGltfFile({ asset: { version: '2.0' } })
|
||||
|
||||
const metadata = await getGltfBinaryMetadata(mockFile)
|
||||
|
||||
expect(metadata).toEqual({})
|
||||
})
|
||||
|
||||
it('skips string metadata values that are not valid JSON', async () => {
|
||||
const mockFile = createMockGltfFile({
|
||||
asset: {
|
||||
version: '2.0',
|
||||
extras: {
|
||||
prompt: '{not json',
|
||||
workflow: '{not json'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const metadata = await getGltfBinaryMetadata(mockFile)
|
||||
|
||||
expect(metadata).toEqual({})
|
||||
})
|
||||
|
||||
describe('FileReader failure modes', () => {
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
@@ -316,15 +193,5 @@ describe('GLTF binary metadata parser', () => {
|
||||
mockFileReaderAbort('readAsArrayBuffer')
|
||||
expect(await getGltfBinaryMetadata(file)).toEqual({})
|
||||
})
|
||||
|
||||
it('resolves empty when the FileReader load result is missing', async () => {
|
||||
mockFileReaderResult('readAsArrayBuffer', null)
|
||||
expect(await getGltfBinaryMetadata(file)).toEqual({})
|
||||
})
|
||||
|
||||
it('resolves empty when the FileReader result is not a buffer', async () => {
|
||||
mockFileReaderResult('readAsArrayBuffer', 'not a buffer')
|
||||
expect(await getGltfBinaryMetadata(file)).toEqual({})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,8 +7,7 @@ import {
|
||||
EXPECTED_PROMPT_NAN_COERCED,
|
||||
EXPECTED_WORKFLOW,
|
||||
mockFileReaderAbort,
|
||||
mockFileReaderError,
|
||||
mockFileReaderResult
|
||||
mockFileReaderError
|
||||
} from './__fixtures__/helpers'
|
||||
import { getFromIsobmffFile } from './isobmff'
|
||||
|
||||
@@ -17,82 +16,6 @@ const nanFixturePath = path.resolve(
|
||||
__dirname,
|
||||
'__fixtures__/with_nan_metadata.mp4'
|
||||
)
|
||||
const encoder = new TextEncoder()
|
||||
|
||||
function uint32(value: number) {
|
||||
return new Uint8Array([
|
||||
(value >>> 24) & 0xff,
|
||||
(value >>> 16) & 0xff,
|
||||
(value >>> 8) & 0xff,
|
||||
value & 0xff
|
||||
])
|
||||
}
|
||||
|
||||
function concatBytes(...chunks: Uint8Array[]) {
|
||||
const totalLength = chunks.reduce((sum, chunk) => sum + chunk.length, 0)
|
||||
const result = new Uint8Array(totalLength)
|
||||
let offset = 0
|
||||
|
||||
for (const chunk of chunks) {
|
||||
result.set(chunk, offset)
|
||||
offset += chunk.length
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
function box(type: string, payload = new Uint8Array(), size?: number) {
|
||||
return concatBytes(
|
||||
uint32(size ?? 8 + payload.length),
|
||||
encoder.encode(type),
|
||||
payload
|
||||
)
|
||||
}
|
||||
|
||||
function keyEntry(name: string) {
|
||||
const encoded = encoder.encode(name)
|
||||
return concatBytes(
|
||||
uint32(8 + encoded.length),
|
||||
encoder.encode('mdta'),
|
||||
encoded
|
||||
)
|
||||
}
|
||||
|
||||
function keysBox(names: string[]) {
|
||||
return box(
|
||||
'keys',
|
||||
concatBytes(uint32(0), uint32(names.length), ...names.map(keyEntry))
|
||||
)
|
||||
}
|
||||
|
||||
function dataBox(value: string | Uint8Array) {
|
||||
const payload = typeof value === 'string' ? encoder.encode(value) : value
|
||||
return box('data', concatBytes(uint32(0), uint32(0), payload))
|
||||
}
|
||||
|
||||
function ilstItem(index: number, payload: Uint8Array) {
|
||||
return concatBytes(uint32(8 + payload.length), uint32(index), payload)
|
||||
}
|
||||
|
||||
function ilstBox(...items: Uint8Array[]) {
|
||||
return box('ilst', concatBytes(...items))
|
||||
}
|
||||
|
||||
function metaBox(...children: Uint8Array[]) {
|
||||
return box('meta', concatBytes(uint32(0), ...children))
|
||||
}
|
||||
|
||||
function udtaWithMeta(...children: Uint8Array[]) {
|
||||
return box('udta', metaBox(...children))
|
||||
}
|
||||
|
||||
async function readMp4(bytes: Uint8Array) {
|
||||
return getFromIsobmffFile(
|
||||
new File([bytes as Uint8Array<ArrayBuffer>], 'test.mp4', {
|
||||
type: 'video/mp4'
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
describe('ISOBMFF (MP4) metadata', () => {
|
||||
it('extracts workflow and prompt from QuickTime keys/ilst boxes', async () => {
|
||||
@@ -125,102 +48,6 @@ describe('ISOBMFF (MP4) metadata', () => {
|
||||
expect(result).toEqual({})
|
||||
})
|
||||
|
||||
it('extracts metadata from udta nested inside moov', async () => {
|
||||
const bytes = box(
|
||||
'moov',
|
||||
udtaWithMeta(
|
||||
keysBox(['WORKFLOW']),
|
||||
ilstBox(ilstItem(1, dataBox('xxxx{"nodes":[]}')))
|
||||
)
|
||||
)
|
||||
|
||||
const result = await readMp4(bytes)
|
||||
|
||||
expect(result.workflow).toEqual({ nodes: [] })
|
||||
})
|
||||
|
||||
it('returns empty when a top-level box declares an impossible size', async () => {
|
||||
const result = await readMp4(box('free', new Uint8Array([1, 2]), 100))
|
||||
|
||||
expect(result).toEqual({})
|
||||
})
|
||||
|
||||
it('returns empty when the keys box cannot provide entries', async () => {
|
||||
const tooShortKeys = box('keys', uint32(0))
|
||||
const missingKeyEntry = box(
|
||||
'keys',
|
||||
concatBytes(uint32(0), uint32(1), uint32(8))
|
||||
)
|
||||
const malformedKey = box(
|
||||
'keys',
|
||||
concatBytes(uint32(0), uint32(1), uint32(7), encoder.encode('bad!'))
|
||||
)
|
||||
const oversizedKey = box(
|
||||
'keys',
|
||||
concatBytes(uint32(0), uint32(1), uint32(100), encoder.encode('bad!'))
|
||||
)
|
||||
|
||||
await expect(readMp4(udtaWithMeta(tooShortKeys))).resolves.toEqual({})
|
||||
await expect(readMp4(udtaWithMeta(missingKeyEntry))).resolves.toEqual({})
|
||||
await expect(readMp4(udtaWithMeta(malformedKey))).resolves.toEqual({})
|
||||
await expect(readMp4(udtaWithMeta(oversizedKey))).resolves.toEqual({})
|
||||
})
|
||||
|
||||
it('ignores item entries whose key is unknown or unsupported', async () => {
|
||||
const unknownIndex = udtaWithMeta(
|
||||
keysBox(['PROMPT']),
|
||||
ilstBox(ilstItem(2, dataBox('{"1":{}}')))
|
||||
)
|
||||
const unsupportedKey = udtaWithMeta(
|
||||
keysBox(['DESCRIPTION']),
|
||||
ilstBox(ilstItem(1, dataBox('{"ignored":true}')))
|
||||
)
|
||||
|
||||
await expect(readMp4(unknownIndex)).resolves.toEqual({})
|
||||
await expect(readMp4(unsupportedKey)).resolves.toEqual({})
|
||||
})
|
||||
|
||||
it('ignores metadata items without readable JSON data', async () => {
|
||||
const shortDataBox = box('data')
|
||||
const noJson = dataBox('not-json')
|
||||
const invalidJson = dataBox('prefix {not-json')
|
||||
const noDataBox = box('free', new Uint8Array([1]))
|
||||
const invalidItems = ilstBox(
|
||||
concatBytes(uint32(8), uint32(1)),
|
||||
concatBytes(uint32(100), uint32(1), invalidJson)
|
||||
)
|
||||
|
||||
await expect(
|
||||
readMp4(
|
||||
udtaWithMeta(keysBox(['PROMPT']), ilstBox(ilstItem(1, shortDataBox)))
|
||||
)
|
||||
).resolves.toEqual({})
|
||||
await expect(
|
||||
readMp4(udtaWithMeta(keysBox(['PROMPT']), ilstBox(ilstItem(1, noJson))))
|
||||
).resolves.toEqual({})
|
||||
await expect(
|
||||
readMp4(
|
||||
udtaWithMeta(keysBox(['PROMPT']), ilstBox(ilstItem(1, invalidJson)))
|
||||
)
|
||||
).resolves.toEqual({})
|
||||
await expect(
|
||||
readMp4(
|
||||
udtaWithMeta(keysBox(['PROMPT']), ilstBox(ilstItem(1, noDataBox)))
|
||||
)
|
||||
).resolves.toEqual({})
|
||||
await expect(
|
||||
readMp4(udtaWithMeta(keysBox(['PROMPT']), invalidItems))
|
||||
).resolves.toEqual({})
|
||||
})
|
||||
|
||||
it('returns empty when required metadata boxes are absent', async () => {
|
||||
await expect(readMp4(box('udta', box('free')))).resolves.toEqual({})
|
||||
await expect(readMp4(udtaWithMeta(ilstBox()))).resolves.toEqual({})
|
||||
await expect(readMp4(udtaWithMeta(keysBox(['PROMPT'])))).resolves.toEqual(
|
||||
{}
|
||||
)
|
||||
})
|
||||
|
||||
describe('FileReader failure modes', () => {
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
@@ -236,10 +63,5 @@ describe('ISOBMFF (MP4) metadata', () => {
|
||||
mockFileReaderAbort('readAsArrayBuffer')
|
||||
expect(await getFromIsobmffFile(file)).toEqual({})
|
||||
})
|
||||
|
||||
it('resolves empty when the FileReader load has no result', async () => {
|
||||
mockFileReaderResult('readAsArrayBuffer', null)
|
||||
expect(await getFromIsobmffFile(file)).toEqual({})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { getFromWebmFile } from '@/scripts/metadata/ebml'
|
||||
import { getGltfBinaryMetadata } from '@/scripts/metadata/gltf'
|
||||
import { getFromIsobmffFile } from '@/scripts/metadata/isobmff'
|
||||
import { getDataFromJSON } from '@/scripts/metadata/json'
|
||||
import { getMp3Metadata } from '@/scripts/metadata/mp3'
|
||||
import { getOggMetadata } from '@/scripts/metadata/ogg'
|
||||
import { getWorkflowDataFromFile } from '@/scripts/metadata/parser'
|
||||
import { getSvgMetadata } from '@/scripts/metadata/svg'
|
||||
import {
|
||||
getAvifMetadata,
|
||||
getFlacMetadata,
|
||||
getLatentMetadata,
|
||||
getPngMetadata,
|
||||
getWebpMetadata
|
||||
} from '@/scripts/pnginfo'
|
||||
|
||||
vi.mock('@/scripts/metadata/ebml', () => ({ getFromWebmFile: vi.fn() }))
|
||||
vi.mock('@/scripts/metadata/gltf', () => ({ getGltfBinaryMetadata: vi.fn() }))
|
||||
vi.mock('@/scripts/metadata/isobmff', () => ({ getFromIsobmffFile: vi.fn() }))
|
||||
vi.mock('@/scripts/metadata/json', () => ({ getDataFromJSON: vi.fn() }))
|
||||
vi.mock('@/scripts/metadata/mp3', () => ({ getMp3Metadata: vi.fn() }))
|
||||
vi.mock('@/scripts/metadata/ogg', () => ({ getOggMetadata: vi.fn() }))
|
||||
vi.mock('@/scripts/metadata/svg', () => ({ getSvgMetadata: vi.fn() }))
|
||||
vi.mock('@/scripts/pnginfo', () => ({
|
||||
getAvifMetadata: vi.fn(),
|
||||
getFlacMetadata: vi.fn(),
|
||||
getLatentMetadata: vi.fn(),
|
||||
getPngMetadata: vi.fn(),
|
||||
getWebpMetadata: vi.fn()
|
||||
}))
|
||||
|
||||
function file(type: string, name = 'file') {
|
||||
return new File(['data'], name, { type })
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('getWorkflowDataFromFile', () => {
|
||||
it('routes png/avif/mp3/ogg/webm to their parsers and returns the result', async () => {
|
||||
vi.mocked(getPngMetadata).mockResolvedValue({ a: '1' })
|
||||
expect(await getWorkflowDataFromFile(file('image/png'))).toEqual({ a: '1' })
|
||||
expect(getPngMetadata).toHaveBeenCalled()
|
||||
|
||||
await getWorkflowDataFromFile(file('image/avif'))
|
||||
expect(getAvifMetadata).toHaveBeenCalled()
|
||||
|
||||
await getWorkflowDataFromFile(file('audio/mpeg'))
|
||||
expect(getMp3Metadata).toHaveBeenCalled()
|
||||
|
||||
await getWorkflowDataFromFile(file('audio/ogg'))
|
||||
expect(getOggMetadata).toHaveBeenCalled()
|
||||
|
||||
await getWorkflowDataFromFile(file('video/webm'))
|
||||
expect(getFromWebmFile).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('extracts workflow/prompt from webp, preferring lowercase keys', async () => {
|
||||
vi.mocked(getWebpMetadata).mockResolvedValue({
|
||||
workflow: 'wf',
|
||||
prompt: 'pr'
|
||||
})
|
||||
expect(await getWorkflowDataFromFile(file('image/webp'))).toEqual({
|
||||
workflow: 'wf',
|
||||
prompt: 'pr'
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to capitalized webp keys when lowercase are absent', async () => {
|
||||
vi.mocked(getWebpMetadata).mockResolvedValue({
|
||||
Workflow: 'WF',
|
||||
Prompt: 'PR'
|
||||
})
|
||||
expect(await getWorkflowDataFromFile(file('image/webp'))).toEqual({
|
||||
workflow: 'WF',
|
||||
prompt: 'PR'
|
||||
})
|
||||
})
|
||||
|
||||
it('handles both flac mime types and extracts workflow/prompt', async () => {
|
||||
vi.mocked(getFlacMetadata).mockResolvedValue({ workflow: 'w' })
|
||||
expect(await getWorkflowDataFromFile(file('audio/flac'))).toEqual({
|
||||
workflow: 'w',
|
||||
prompt: undefined
|
||||
})
|
||||
expect(await getWorkflowDataFromFile(file('audio/x-flac'))).toEqual({
|
||||
workflow: 'w',
|
||||
prompt: undefined
|
||||
})
|
||||
})
|
||||
|
||||
it('routes isobmff by mime type and by file extension', async () => {
|
||||
await getWorkflowDataFromFile(file('video/mp4'))
|
||||
await getWorkflowDataFromFile(file('', 'clip.mov'))
|
||||
await getWorkflowDataFromFile(file('', 'clip.m4v'))
|
||||
expect(getFromIsobmffFile).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('routes svg and gltf by mime type or extension', async () => {
|
||||
await getWorkflowDataFromFile(file('image/svg+xml'))
|
||||
await getWorkflowDataFromFile(file('', 'icon.svg'))
|
||||
expect(getSvgMetadata).toHaveBeenCalledTimes(2)
|
||||
|
||||
await getWorkflowDataFromFile(file('model/gltf-binary'))
|
||||
await getWorkflowDataFromFile(file('', 'model.glb'))
|
||||
expect(getGltfBinaryMetadata).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('routes latent/safetensors and json by extension or mime type', async () => {
|
||||
await getWorkflowDataFromFile(file('', 'x.latent'))
|
||||
await getWorkflowDataFromFile(file('', 'x.safetensors'))
|
||||
expect(getLatentMetadata).toHaveBeenCalledTimes(2)
|
||||
|
||||
await getWorkflowDataFromFile(file('application/json'))
|
||||
await getWorkflowDataFromFile(file('', 'x.json'))
|
||||
expect(getDataFromJSON).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('returns undefined for an unrecognized file', async () => {
|
||||
expect(
|
||||
await getWorkflowDataFromFile(file('application/zip', 'a.zip'))
|
||||
).toBe(undefined)
|
||||
})
|
||||
})
|
||||
@@ -2,8 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
mockFileReaderAbort,
|
||||
mockFileReaderError,
|
||||
mockFileReaderResult
|
||||
mockFileReaderError
|
||||
} from './__fixtures__/helpers'
|
||||
import { getFromPngBuffer, getFromPngFile } from './png'
|
||||
|
||||
@@ -192,27 +191,6 @@ describe('getFromPngBuffer', () => {
|
||||
const result = await getFromPngBuffer(buffer)
|
||||
expect(result['workflow']).toBe(workflow)
|
||||
})
|
||||
|
||||
it('logs error and skips compressed iTXt with invalid deflate data', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const buffer = createPngWithChunk(
|
||||
'iTXt',
|
||||
'workflow',
|
||||
new Uint8Array([1, 2, 3]),
|
||||
{
|
||||
compressionFlag: 1,
|
||||
compressionMethod: 0
|
||||
}
|
||||
)
|
||||
|
||||
const result = await getFromPngBuffer(buffer)
|
||||
|
||||
expect(result['workflow']).toBeUndefined()
|
||||
expect(console.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Failed to decompress iTXt chunk "workflow"'),
|
||||
expect.anything()
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getFromPngFile', () => {
|
||||
@@ -250,12 +228,5 @@ describe('getFromPngFile', () => {
|
||||
mockFileReaderAbort('readAsArrayBuffer')
|
||||
await expect(getFromPngFile(file)).rejects.toThrow('FileReader aborted')
|
||||
})
|
||||
|
||||
it('rejects when the FileReader load has no ArrayBuffer result', async () => {
|
||||
mockFileReaderResult('readAsArrayBuffer', null)
|
||||
await expect(getFromPngFile(file)).rejects.toThrow(
|
||||
'Failed to read file as ArrayBuffer'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,23 +1,13 @@
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
|
||||
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { api } from '@/scripts/api'
|
||||
import {
|
||||
createMockLGraph,
|
||||
createMockLGraphNode
|
||||
} from '@/utils/__tests__/litegraphTestUtils'
|
||||
import { getFromAvifFile } from './metadata/avif'
|
||||
import { getFromFlacFile } from './metadata/flac'
|
||||
import { getFromPngFile } from './metadata/png'
|
||||
import {
|
||||
getAvifMetadata,
|
||||
getFlacMetadata,
|
||||
importA1111,
|
||||
getLatentMetadata,
|
||||
getPngMetadata,
|
||||
getWebpMetadata
|
||||
@@ -66,20 +56,6 @@ function encodeAsciiIfd(entries: AsciiIfdEntry[]): Uint8Array {
|
||||
return buf
|
||||
}
|
||||
|
||||
function encodeNonAsciiIfdEntry(tag: number): Uint8Array {
|
||||
const buf = new Uint8Array(22)
|
||||
const dv = new DataView(buf.buffer)
|
||||
buf.set([0x49, 0x49], 0)
|
||||
dv.setUint16(2, 0x002a, true)
|
||||
dv.setUint32(4, 8, true)
|
||||
dv.setUint16(8, 1, true)
|
||||
dv.setUint16(10, tag, true)
|
||||
dv.setUint16(12, 3, true)
|
||||
dv.setUint32(14, 1, true)
|
||||
dv.setUint32(18, 123, true)
|
||||
return buf
|
||||
}
|
||||
|
||||
type WebpChunk = { type: string; payload: Uint8Array }
|
||||
|
||||
function wrapInWebp(chunks: WebpChunk[]): File {
|
||||
@@ -181,16 +157,6 @@ describe('getWebpMetadata', () => {
|
||||
|
||||
expect(metadata).toEqual({ workflow: '{"a":1}' })
|
||||
})
|
||||
|
||||
it('ignores EXIF entries that are not ASCII strings', async () => {
|
||||
const file = wrapInWebp([
|
||||
{ type: 'EXIF', payload: encodeNonAsciiIfdEntry(270) }
|
||||
])
|
||||
|
||||
const metadata = await getWebpMetadata(file)
|
||||
|
||||
expect(metadata).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe('getLatentMetadata', () => {
|
||||
@@ -268,313 +234,3 @@ describe('format-specific metadata wrappers', () => {
|
||||
expect(result).toEqual({ workflow: '{"avif":1}' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('importA1111', () => {
|
||||
function widget(
|
||||
name: string,
|
||||
options: string[] = []
|
||||
): IBaseWidget & { value?: string | number } {
|
||||
return fromPartial<IBaseWidget & { value?: string | number }>({
|
||||
name,
|
||||
options: { values: options },
|
||||
value: undefined
|
||||
})
|
||||
}
|
||||
|
||||
function createNode(type: string): LGraphNode {
|
||||
const widgetsByType: Record<string, IBaseWidget[]> = {
|
||||
CheckpointLoaderSimple: [widget('ckpt_name', ['sd15.safetensors'])],
|
||||
CLIPSetLastLayer: [widget('stop_at_clip_layer')],
|
||||
CLIPTextEncode: [widget('text')],
|
||||
EmptyLatentImage: [widget('width'), widget('height')],
|
||||
ImageScale: [widget('width'), widget('height')],
|
||||
ImageUpscaleWithModel: [],
|
||||
KSampler: [
|
||||
widget('cfg'),
|
||||
widget('sampler_name', ['euler_a', 'dpmpp_2m']),
|
||||
widget('scheduler', ['normal', 'karras']),
|
||||
widget('seed'),
|
||||
widget('steps'),
|
||||
widget('denoise')
|
||||
],
|
||||
LatentUpscale: [
|
||||
widget('upscale_method', ['nearest-exact']),
|
||||
widget('width'),
|
||||
widget('height')
|
||||
],
|
||||
LoraLoader: [
|
||||
widget('lora_name', ['foo.safetensors']),
|
||||
widget('strength_model'),
|
||||
widget('strength_clip')
|
||||
],
|
||||
UpscaleModelLoader: [widget('model_name', ['ESRGAN'])],
|
||||
VAEEncodeTiled: [],
|
||||
VAEDecodeTiled: [],
|
||||
SaveImage: [],
|
||||
VAEDecode: []
|
||||
}
|
||||
return createMockLGraphNode({
|
||||
type,
|
||||
widgets: widgetsByType[type] ?? [],
|
||||
connect: vi.fn()
|
||||
})
|
||||
}
|
||||
|
||||
function createGraph(): LGraph {
|
||||
return createMockLGraph({
|
||||
add: vi.fn(),
|
||||
arrange: vi.fn(),
|
||||
clear: vi.fn()
|
||||
})
|
||||
}
|
||||
|
||||
function findWidget(node: LGraphNode, name: string) {
|
||||
return node.widgets?.find((widget) => widget.name === name)
|
||||
}
|
||||
|
||||
it('ignores text without parsed generation settings', async () => {
|
||||
const graph = createGraph()
|
||||
vi.spyOn(api, 'getEmbeddings').mockResolvedValue([])
|
||||
|
||||
await importA1111(graph, 'positive prompt only')
|
||||
await importA1111(graph, 'positive prompt\nSteps:\n')
|
||||
|
||||
expect(graph.clear).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ignores text without a negative prompt section', async () => {
|
||||
const graph = createGraph()
|
||||
vi.spyOn(api, 'getEmbeddings').mockResolvedValue([])
|
||||
|
||||
await importA1111(
|
||||
graph,
|
||||
['positive prompt only', 'Steps: 20, Sampler: Euler a'].join('\n')
|
||||
)
|
||||
|
||||
expect(graph.clear).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('stops when a required base node cannot be created', async () => {
|
||||
const graph = createGraph()
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
vi.spyOn(api, 'getEmbeddings').mockResolvedValue([])
|
||||
vi.spyOn(LiteGraph, 'createNode').mockImplementation((type) =>
|
||||
type === 'KSampler' ? null : createNode(type)
|
||||
)
|
||||
|
||||
await importA1111(
|
||||
graph,
|
||||
[
|
||||
'prompt',
|
||||
'Negative prompt: blurry',
|
||||
'Steps: 20, Sampler: Euler a, Size: 512x512'
|
||||
].join('\n')
|
||||
)
|
||||
|
||||
expect(graph.clear).not.toHaveBeenCalled()
|
||||
expect(console.error).toHaveBeenCalledWith(
|
||||
'Failed to create required nodes for A1111 import'
|
||||
)
|
||||
})
|
||||
|
||||
it('builds a basic graph from A1111 parameters', async () => {
|
||||
const graph = createGraph()
|
||||
const nodes: LGraphNode[] = []
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
vi.spyOn(api, 'getEmbeddings').mockResolvedValue(['easynegative'])
|
||||
vi.spyOn(LiteGraph, 'createNode').mockImplementation((type) => {
|
||||
const node = createNode(type)
|
||||
nodes.push(node)
|
||||
return node
|
||||
})
|
||||
|
||||
await importA1111(
|
||||
graph,
|
||||
[
|
||||
'<lora:foo:0.7> portrait easynegative',
|
||||
'Negative prompt: blurry <lora:bad:not-number>',
|
||||
'Steps: 20, Sampler: Euler a, CFG scale: 7, Seed: 42, Size: 512x512, Model: sd15, Clip skip: 2, Model hash: ignored'
|
||||
].join('\n')
|
||||
)
|
||||
|
||||
const checkpoint = nodes.find(
|
||||
(node) => node.type === 'CheckpointLoaderSimple'
|
||||
)
|
||||
const clipSkip = nodes.find((node) => node.type === 'CLIPSetLastLayer')
|
||||
const sampler = nodes.find((node) => node.type === 'KSampler')
|
||||
const image = nodes.find((node) => node.type === 'EmptyLatentImage')
|
||||
const lora = nodes.find((node) => node.type === 'LoraLoader')
|
||||
const textNodes = nodes.filter((node) => node.type === 'CLIPTextEncode')
|
||||
|
||||
expect(graph.clear).toHaveBeenCalledOnce()
|
||||
expect(graph.arrange).toHaveBeenCalledOnce()
|
||||
expect(findWidget(checkpoint!, 'ckpt_name')?.value).toBe('sd15.safetensors')
|
||||
expect(findWidget(clipSkip!, 'stop_at_clip_layer')?.value).toBe(-2)
|
||||
expect(findWidget(sampler!, 'cfg')?.value).toBe(7)
|
||||
expect(findWidget(sampler!, 'sampler_name')?.value).toBe('euler_a')
|
||||
expect(findWidget(sampler!, 'scheduler')?.value).toBe('normal')
|
||||
expect(findWidget(sampler!, 'seed')?.value).toBe(42)
|
||||
expect(findWidget(sampler!, 'steps')?.value).toBe(20)
|
||||
expect(findWidget(image!, 'width')?.value).toBe(512)
|
||||
expect(findWidget(image!, 'height')?.value).toBe(512)
|
||||
expect(findWidget(lora!, 'lora_name')?.value).toBe('foo.safetensors')
|
||||
expect(findWidget(lora!, 'strength_model')?.value).toBe(0.7)
|
||||
expect(findWidget(lora!, 'strength_clip')?.value).toBe(0.7)
|
||||
expect(findWidget(textNodes[0], 'text')?.value).toBe(
|
||||
' portrait embedding:easynegative'
|
||||
)
|
||||
expect(findWidget(textNodes[1], 'text')?.value).toBe('blurry ')
|
||||
})
|
||||
|
||||
it('keeps unknown option-prefix values and logs the mismatch', async () => {
|
||||
const graph = createGraph()
|
||||
const nodes: LGraphNode[] = []
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
vi.spyOn(api, 'getEmbeddings').mockResolvedValue([])
|
||||
vi.spyOn(LiteGraph, 'createNode').mockImplementation((type) => {
|
||||
const node = createNode(type)
|
||||
nodes.push(node)
|
||||
return node
|
||||
})
|
||||
|
||||
await importA1111(
|
||||
graph,
|
||||
[
|
||||
'portrait',
|
||||
'Negative prompt: blurry',
|
||||
'Steps: 20, Sampler: Unknown Sampler, CFG scale: 7, Seed: 42, Size: 512x512, Model: unknown-model'
|
||||
].join('\n')
|
||||
)
|
||||
|
||||
const checkpoint = nodes.find(
|
||||
(node) => node.type === 'CheckpointLoaderSimple'
|
||||
)
|
||||
expect(findWidget(checkpoint!, 'ckpt_name')?.value).toBe('unknown-model')
|
||||
expect(console.warn).toHaveBeenCalledWith(
|
||||
"Unknown value 'unknown-model' for widget 'ckpt_name'",
|
||||
checkpoint
|
||||
)
|
||||
})
|
||||
|
||||
it('skips missing LoraLoader nodes while keeping prompt text cleaned', async () => {
|
||||
const graph = createGraph()
|
||||
const nodes: LGraphNode[] = []
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
vi.spyOn(api, 'getEmbeddings').mockResolvedValue([])
|
||||
vi.spyOn(LiteGraph, 'createNode').mockImplementation((type) => {
|
||||
if (type === 'LoraLoader') return null
|
||||
const node = createNode(type)
|
||||
nodes.push(node)
|
||||
return node
|
||||
})
|
||||
|
||||
await importA1111(
|
||||
graph,
|
||||
[
|
||||
'<lora:missing:0.5> portrait',
|
||||
'Negative prompt: blurry',
|
||||
'Steps: 20, Sampler: Euler a, Size: 512x512'
|
||||
].join('\n')
|
||||
)
|
||||
|
||||
const textNodes = nodes.filter((node) => node.type === 'CLIPTextEncode')
|
||||
expect(findWidget(textNodes[0], 'text')?.value).toBe(' portrait')
|
||||
})
|
||||
|
||||
it('returns from latent hires setup when LatentUpscale cannot be created', async () => {
|
||||
const graph = createGraph()
|
||||
const nodes: LGraphNode[] = []
|
||||
vi.spyOn(api, 'getEmbeddings').mockResolvedValue([])
|
||||
vi.spyOn(LiteGraph, 'createNode').mockImplementation((type) => {
|
||||
if (type === 'LatentUpscale') return null
|
||||
const node = createNode(type)
|
||||
nodes.push(node)
|
||||
return node
|
||||
})
|
||||
|
||||
await importA1111(
|
||||
graph,
|
||||
[
|
||||
'portrait',
|
||||
'Negative prompt: blurry',
|
||||
'Steps: 8, Sampler: Euler a, Size: 512x512, Hires upscale: 2, Hires upscaler: Latent'
|
||||
].join('\n')
|
||||
)
|
||||
|
||||
expect(nodes.some((node) => node.type === 'KSampler')).toBe(true)
|
||||
expect(nodes.some((node) => node.type === 'LatentUpscale')).toBe(false)
|
||||
})
|
||||
|
||||
it('builds a latent hires pass with explicit resize and denoise settings', async () => {
|
||||
const graph = createGraph()
|
||||
const nodes: LGraphNode[] = []
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
vi.spyOn(api, 'getEmbeddings').mockResolvedValue([])
|
||||
vi.spyOn(LiteGraph, 'createNode').mockImplementation((type) => {
|
||||
const node = createNode(type)
|
||||
nodes.push(node)
|
||||
return node
|
||||
})
|
||||
|
||||
await importA1111(
|
||||
graph,
|
||||
[
|
||||
'portrait',
|
||||
'Negative prompt: blurry',
|
||||
'Steps: 12, Sampler: DPM++ 2M Karras, CFG scale: 5, Seed: 1, Size: 513x577, Model: sd15, Hires resize: 1025x1089, Hires steps: 4, Hires upscaler: Latent (nearest-exact), Denoising strength: 0.35'
|
||||
].join('\n')
|
||||
)
|
||||
|
||||
const image = nodes.find((node) => node.type === 'EmptyLatentImage')
|
||||
const latentUpscale = nodes.find((node) => node.type === 'LatentUpscale')
|
||||
const samplers = nodes.filter((node) => node.type === 'KSampler')
|
||||
|
||||
expect(findWidget(image!, 'width')?.value).toBe(576)
|
||||
expect(findWidget(image!, 'height')?.value).toBe(640)
|
||||
expect(findWidget(latentUpscale!, 'upscale_method')?.value).toBe(
|
||||
'nearest-exact'
|
||||
)
|
||||
expect(findWidget(latentUpscale!, 'width')?.value).toBe(1088)
|
||||
expect(findWidget(latentUpscale!, 'height')?.value).toBe(1152)
|
||||
expect(findWidget(samplers[0], 'scheduler')?.value).toBe('karras')
|
||||
expect(findWidget(samplers[0], 'sampler_name')?.value).toBe('dpmpp_2m')
|
||||
expect(findWidget(samplers[1], 'steps')?.value).toBe(4)
|
||||
expect(findWidget(samplers[1], 'cfg')?.value).toBe(5)
|
||||
expect(findWidget(samplers[1], 'scheduler')?.value).toBe('karras')
|
||||
expect(findWidget(samplers[1], 'sampler_name')?.value).toBe('dpmpp_2m')
|
||||
expect(findWidget(samplers[1], 'denoise')?.value).toBe(0.35)
|
||||
})
|
||||
|
||||
it('builds an image upscaler hires pass with fallback steps and denoise', async () => {
|
||||
const graph = createGraph()
|
||||
const nodes: LGraphNode[] = []
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
vi.spyOn(api, 'getEmbeddings').mockResolvedValue([])
|
||||
vi.spyOn(LiteGraph, 'createNode').mockImplementation((type) => {
|
||||
const node = createNode(type)
|
||||
nodes.push(node)
|
||||
return node
|
||||
})
|
||||
|
||||
await importA1111(
|
||||
graph,
|
||||
[
|
||||
'portrait',
|
||||
'Negative prompt: blurry',
|
||||
'Steps: 8, Sampler: Euler a, CFG scale: 6, Seed: 2, Size: 512x512, Model: sd15, Hires upscale: 1.5, Hires upscaler: ESRGAN'
|
||||
].join('\n')
|
||||
)
|
||||
|
||||
const upscaleLoader = nodes.find(
|
||||
(node) => node.type === 'UpscaleModelLoader'
|
||||
)
|
||||
const imageScale = nodes.find((node) => node.type === 'ImageScale')
|
||||
const samplers = nodes.filter((node) => node.type === 'KSampler')
|
||||
|
||||
expect(findWidget(upscaleLoader!, 'model_name')?.value).toBe('ESRGAN')
|
||||
expect(findWidget(imageScale!, 'width')?.value).toBe(768)
|
||||
expect(findWidget(imageScale!, 'height')?.value).toBe(768)
|
||||
expect(findWidget(samplers[1], 'steps')?.value).toBe(8)
|
||||
expect(findWidget(samplers[1], 'denoise')?.value).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,472 +0,0 @@
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import type { PartialDeep } from '@total-typescript/shoehorn'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { mockSettingStore } = vi.hoisted(() => ({
|
||||
mockSettingStore: {
|
||||
get: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useRunButtonTelemetry', () => ({
|
||||
useRunButtonTelemetry: () => ({ trackRunButton: vi.fn() })
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/remote/comfyui/jobs/fetchJobs', () => ({
|
||||
extractWorkflow: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/settings/composables/useSettingsDialog', () => ({
|
||||
useSettingsDialog: () => ({ show: vi.fn() })
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => mockSettingStore
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: () => ({ trackWorkflowExecution: vi.fn() })
|
||||
}))
|
||||
|
||||
vi.mock('@/services/litegraphService', () => ({
|
||||
useLitegraphService: () => ({ resetView: vi.fn() })
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/commandStore', () => ({
|
||||
useCommandStore: () => ({ execute: vi.fn() })
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/workspaceStore', () => ({
|
||||
useWorkspaceStore: () => ({ focusMode: false })
|
||||
}))
|
||||
|
||||
vi.mock('./api', () => ({
|
||||
api: {
|
||||
addEventListener: vi.fn(),
|
||||
clearItems: vi.fn(),
|
||||
deleteItem: vi.fn(),
|
||||
dispatchCustomEvent: vi.fn(),
|
||||
getHistory: vi.fn(),
|
||||
getJobDetail: vi.fn(),
|
||||
getQueue: vi.fn(),
|
||||
interrupt: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('./app', () => ({
|
||||
app: {
|
||||
clean: vi.fn(),
|
||||
handleFile: vi.fn(),
|
||||
loadGraphData: vi.fn(),
|
||||
openClipspace: vi.fn(),
|
||||
queuePrompt: vi.fn(),
|
||||
refreshComboInNodes: vi.fn(() => Promise.resolve())
|
||||
},
|
||||
ComfyApp: class ComfyApp {}
|
||||
}))
|
||||
|
||||
vi.mock('./ui/dialog', () => ({
|
||||
ComfyDialog: class ComfyDialog {}
|
||||
}))
|
||||
|
||||
vi.mock('./ui/settings', () => ({
|
||||
ComfySettingsDialog: class ComfySettingsDialog {}
|
||||
}))
|
||||
|
||||
vi.mock('./ui/toggleSwitch', () => ({
|
||||
toggleSwitch: () => document.createElement('div')
|
||||
}))
|
||||
|
||||
import { extractWorkflow } from '@/platform/remote/comfyui/jobs/fetchJobs'
|
||||
import type {
|
||||
JobDetail,
|
||||
JobListItem
|
||||
} from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
|
||||
import { api } from './api'
|
||||
import { app } from './app'
|
||||
import { $el, ComfyUI } from './ui'
|
||||
|
||||
beforeEach(() => {
|
||||
document.body.replaceChildren()
|
||||
localStorage.clear()
|
||||
vi.clearAllMocks()
|
||||
mockSettingStore.get.mockReturnValue(undefined)
|
||||
Object.assign(app, {
|
||||
lastExecutionError: undefined,
|
||||
nodeOutputs: undefined
|
||||
})
|
||||
})
|
||||
|
||||
async function click(button: HTMLButtonElement) {
|
||||
const handler = button.onclick
|
||||
expect(handler).toBeTypeOf('function')
|
||||
await Promise.resolve(handler?.call(button, new PointerEvent('click')))
|
||||
}
|
||||
|
||||
function buttonByText(root: ParentNode, text: string): HTMLButtonElement {
|
||||
const button = [...root.querySelectorAll('button')].find(
|
||||
(candidate) => candidate.textContent === text
|
||||
)
|
||||
if (!button) throw new Error(`Missing button: ${text}`)
|
||||
return button
|
||||
}
|
||||
|
||||
describe('$el', () => {
|
||||
it('creates elements with classes, children, props, and callbacks', () => {
|
||||
const parent = document.createElement('section')
|
||||
const child = document.createElement('span')
|
||||
const callback = vi.fn()
|
||||
|
||||
const element = $el(
|
||||
'label.primary.secondary',
|
||||
{
|
||||
parent,
|
||||
$: callback,
|
||||
dataset: { role: 'name' },
|
||||
for: 'target-input',
|
||||
style: { display: 'block' },
|
||||
title: 'Label'
|
||||
},
|
||||
child
|
||||
)
|
||||
|
||||
expect(element.tagName).toBe('LABEL')
|
||||
expect(element.classList.contains('primary')).toBe(true)
|
||||
expect(element.classList.contains('secondary')).toBe(true)
|
||||
expect(element.dataset.role).toBe('name')
|
||||
expect(element.getAttribute('for')).toBe('target-input')
|
||||
expect(element.style.display).toBe('block')
|
||||
expect(element.title).toBe('Label')
|
||||
expect(element.firstElementChild).toBe(child)
|
||||
expect(parent.firstElementChild).toBe(element)
|
||||
expect(callback).toHaveBeenCalledWith(element)
|
||||
})
|
||||
|
||||
it('accepts string and single-element children shorthands', () => {
|
||||
const textElement = $el('button', 'Run')
|
||||
const child = document.createElement('strong')
|
||||
const wrapper = $el('div', child)
|
||||
|
||||
expect(textElement.textContent).toBe('Run')
|
||||
expect(wrapper.firstElementChild).toBe(child)
|
||||
})
|
||||
})
|
||||
|
||||
describe('ComfyUI legacy menu', () => {
|
||||
it('loads queue items and runs list actions', async () => {
|
||||
vi.mocked(api.getQueue).mockResolvedValue({
|
||||
Running: [fromPartial<JobListItem>({ id: 'running', priority: 1 })],
|
||||
Pending: [fromPartial<JobListItem>({ id: 'pending', priority: 2 })]
|
||||
})
|
||||
vi.mocked(api.getJobDetail).mockResolvedValue(
|
||||
fromPartial<JobDetail>({
|
||||
outputs: { node: { images: ['image.png'] } }
|
||||
} as PartialDeep<JobDetail>)
|
||||
)
|
||||
vi.mocked(extractWorkflow).mockResolvedValue(
|
||||
fromPartial<ComfyWorkflowJSON>({ nodes: [] })
|
||||
)
|
||||
const ui = new ComfyUI(app)
|
||||
|
||||
await ui.queue.show()
|
||||
await click(buttonByText(ui.queue.element, 'Load'))
|
||||
|
||||
expect(api.getJobDetail).toHaveBeenCalledWith('running')
|
||||
expect(extractWorkflow).toHaveBeenCalled()
|
||||
expect(app.loadGraphData).toHaveBeenCalledWith({ nodes: [] }, true, false)
|
||||
expect(app.nodeOutputs).toEqual({ node: { images: ['image.png'] } })
|
||||
|
||||
await click(buttonByText(ui.queue.element, 'Cancel'))
|
||||
expect(api.interrupt).toHaveBeenCalledWith('running')
|
||||
|
||||
await click(buttonByText(ui.queue.element, 'Delete'))
|
||||
expect(api.deleteItem).toHaveBeenCalledWith('queue', 'pending')
|
||||
|
||||
await click(buttonByText(ui.queue.element, 'Clear Queue'))
|
||||
expect(api.clearItems).toHaveBeenCalledWith('queue')
|
||||
|
||||
await click(buttonByText(ui.queue.element, 'Refresh'))
|
||||
expect(api.getQueue).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('skips loading queue items when job details are unavailable', async () => {
|
||||
vi.mocked(api.getQueue).mockResolvedValue({
|
||||
Running: [fromPartial<JobListItem>({ id: 'running', priority: 1 })],
|
||||
Pending: []
|
||||
})
|
||||
vi.mocked(api.getJobDetail).mockResolvedValue(undefined)
|
||||
const ui = new ComfyUI(app)
|
||||
|
||||
await ui.queue.show()
|
||||
await click(buttonByText(ui.queue.element, 'Load'))
|
||||
|
||||
expect(extractWorkflow).not.toHaveBeenCalled()
|
||||
expect(app.loadGraphData).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('loads queue item workflows without outputs', async () => {
|
||||
vi.mocked(api.getQueue).mockResolvedValue({
|
||||
Running: [fromPartial<JobListItem>({ id: 'running', priority: 1 })],
|
||||
Pending: []
|
||||
})
|
||||
vi.mocked(api.getJobDetail).mockResolvedValue(
|
||||
fromPartial<JobDetail>({ id: 'running' })
|
||||
)
|
||||
vi.mocked(extractWorkflow).mockResolvedValue(
|
||||
fromPartial<ComfyWorkflowJSON>({ nodes: [] })
|
||||
)
|
||||
const ui = new ComfyUI(app)
|
||||
|
||||
await ui.queue.show()
|
||||
await click(buttonByText(ui.queue.element, 'Load'))
|
||||
|
||||
expect(app.loadGraphData).toHaveBeenCalledWith({ nodes: [] }, true, false)
|
||||
expect(app.nodeOutputs).toBeUndefined()
|
||||
})
|
||||
|
||||
it('loads history in reverse order', async () => {
|
||||
vi.mocked(api.getHistory).mockResolvedValue([
|
||||
fromPartial<JobListItem>({ id: 'old', priority: 1 }),
|
||||
fromPartial<JobListItem>({ id: 'new', priority: 2 })
|
||||
])
|
||||
const ui = new ComfyUI(app)
|
||||
|
||||
await ui.history.show()
|
||||
|
||||
expect(ui.history.element.textContent).toContain('2: LoadDelete')
|
||||
expect(ui.history.element.textContent?.indexOf('2:')).toBeLessThan(
|
||||
ui.history.element.textContent?.indexOf('1:') ?? Number.MAX_SAFE_INTEGER
|
||||
)
|
||||
})
|
||||
|
||||
it('updates queue status and auto-queues when enabled', () => {
|
||||
const ui = new ComfyUI(app)
|
||||
ui.autoQueueEnabled = true
|
||||
ui.autoQueueMode = 'instant'
|
||||
ui.lastQueueSize = 1
|
||||
ui.batchCount = 3
|
||||
|
||||
ui.setStatus({ exec_info: { queue_remaining: 0 } })
|
||||
|
||||
expect(app.queuePrompt).toHaveBeenCalledWith(0, 3)
|
||||
expect(ui.queueSize.textContent).toBe('Queue size: 0')
|
||||
expect(ui.lastQueueSize).toBe(3)
|
||||
|
||||
ui.setStatus(null)
|
||||
expect(ui.queueSize.textContent).toBe('Queue size: ERR')
|
||||
})
|
||||
|
||||
it('does not auto-queue while a prior execution error is present', () => {
|
||||
const ui = new ComfyUI(app)
|
||||
ui.autoQueueEnabled = true
|
||||
ui.autoQueueMode = 'instant'
|
||||
ui.lastQueueSize = 1
|
||||
Object.assign(app, { lastExecutionError: new Error('failed') })
|
||||
|
||||
ui.setStatus({ exec_info: { queue_remaining: 0 } })
|
||||
|
||||
expect(app.queuePrompt).not.toHaveBeenCalled()
|
||||
expect(ui.lastQueueSize).toBe(0)
|
||||
})
|
||||
|
||||
it('tracks graph changes for change-mode auto queueing', () => {
|
||||
const ui = new ComfyUI(app)
|
||||
const graphChanged = vi
|
||||
.mocked(api.addEventListener)
|
||||
.mock.calls.find(([eventName]) => eventName === 'graphChanged')?.[1]
|
||||
if (!graphChanged) throw new Error('Missing graphChanged listener')
|
||||
|
||||
ui.autoQueueEnabled = true
|
||||
ui.autoQueueMode = 'change'
|
||||
ui.lastQueueSize = 1
|
||||
;(graphChanged as EventListener)(
|
||||
fromPartial<CustomEvent<ComfyWorkflowJSON>>(
|
||||
new CustomEvent('graphChanged')
|
||||
)
|
||||
)
|
||||
|
||||
expect(ui.graphHasChanged).toBe(true)
|
||||
|
||||
ui.lastQueueSize = 0
|
||||
;(graphChanged as EventListener)(
|
||||
fromPartial<CustomEvent<ComfyWorkflowJSON>>(
|
||||
new CustomEvent('graphChanged')
|
||||
)
|
||||
)
|
||||
|
||||
expect(app.queuePrompt).toHaveBeenCalledWith(0, 1)
|
||||
expect(ui.graphHasChanged).toBe(false)
|
||||
})
|
||||
|
||||
it('wires primary menu buttons to app and command actions', async () => {
|
||||
const ui = new ComfyUI(app)
|
||||
|
||||
await click(buttonByText(document, 'Queue Prompt'))
|
||||
expect(app.queuePrompt).toHaveBeenCalledWith(0, 1)
|
||||
|
||||
await click(buttonByText(document, 'Queue Front'))
|
||||
expect(app.queuePrompt).toHaveBeenCalledWith(-1, 1)
|
||||
|
||||
await click(buttonByText(document, 'Save'))
|
||||
await click(buttonByText(document, 'Save (API Format)'))
|
||||
await click(buttonByText(document, 'Refresh'))
|
||||
await click(buttonByText(document, 'Clipspace'))
|
||||
await click(buttonByText(document, 'Clear'))
|
||||
await click(buttonByText(document, 'Load Default'))
|
||||
await click(buttonByText(document, 'Reset View'))
|
||||
|
||||
expect(app.refreshComboInNodes).toHaveBeenCalled()
|
||||
expect(app.openClipspace).toHaveBeenCalled()
|
||||
expect(app.clean).toHaveBeenCalled()
|
||||
expect(app.loadGraphData).toHaveBeenCalledWith()
|
||||
expect(ui.menuContainer.style.display).toBe('none')
|
||||
})
|
||||
|
||||
it('wires file input and legacy option controls', async () => {
|
||||
const ui = new ComfyUI(app)
|
||||
const file = new File(['{}'], 'workflow.json', {
|
||||
type: 'application/json'
|
||||
})
|
||||
const fileInput = document.getElementById(
|
||||
'comfy-file-input'
|
||||
) as HTMLInputElement
|
||||
Object.defineProperty(fileInput, 'files', {
|
||||
configurable: true,
|
||||
value: [file]
|
||||
})
|
||||
|
||||
await Promise.resolve(
|
||||
fileInput.onchange?.call(fileInput, new Event('change'))
|
||||
)
|
||||
expect(app.handleFile).toHaveBeenCalledWith(file, 'file_button')
|
||||
expect(fileInput.value).toBe('')
|
||||
|
||||
const range = document.getElementById(
|
||||
'batchCountInputRange'
|
||||
) as HTMLInputElement
|
||||
const number = document.getElementById(
|
||||
'batchCountInputNumber'
|
||||
) as HTMLInputElement
|
||||
range.value = '4'
|
||||
const extraOptionsCheckbox = document.querySelector(
|
||||
'label input[type="checkbox"]'
|
||||
) as HTMLInputElement
|
||||
extraOptionsCheckbox.checked = true
|
||||
extraOptionsCheckbox.onchange?.call(
|
||||
extraOptionsCheckbox,
|
||||
fromPartial({ srcElement: extraOptionsCheckbox })
|
||||
)
|
||||
expect(ui.batchCount).toBe(4)
|
||||
expect(document.getElementById('extraOptions')?.style.display).toBe('block')
|
||||
|
||||
number.value = '7'
|
||||
number.oninput?.call(number, fromPartial({ target: number }))
|
||||
expect(range.value).toBe('7')
|
||||
|
||||
range.value = '9'
|
||||
range.oninput?.call(range, fromPartial({ srcElement: range }))
|
||||
expect(number.value).toBe('9')
|
||||
|
||||
const autoQueueCheckbox = document.getElementById(
|
||||
'autoQueueCheckbox'
|
||||
) as HTMLInputElement
|
||||
autoQueueCheckbox.checked = true
|
||||
autoQueueCheckbox.onchange?.call(
|
||||
autoQueueCheckbox,
|
||||
fromPartial({ target: autoQueueCheckbox })
|
||||
)
|
||||
expect(ui.autoQueueEnabled).toBe(true)
|
||||
|
||||
extraOptionsCheckbox.checked = false
|
||||
extraOptionsCheckbox.onchange?.call(
|
||||
extraOptionsCheckbox,
|
||||
fromPartial({ srcElement: extraOptionsCheckbox })
|
||||
)
|
||||
expect(ui.batchCount).toBe(1)
|
||||
expect(ui.autoQueueEnabled).toBe(false)
|
||||
expect(document.getElementById('extraOptions')?.style.display).toBe('none')
|
||||
})
|
||||
|
||||
it('toggles queue visibility through the menu button', async () => {
|
||||
vi.mocked(api.getQueue).mockResolvedValue({
|
||||
Running: [],
|
||||
Pending: []
|
||||
})
|
||||
const ui = new ComfyUI(app)
|
||||
|
||||
await click(buttonByText(document, 'View Queue'))
|
||||
expect(ui.queue.visible).toBe(true)
|
||||
|
||||
await click(buttonByText(document, 'Close'))
|
||||
expect(ui.queue.visible).toBe(false)
|
||||
})
|
||||
|
||||
it('does not clear or load defaults when confirmation is declined', async () => {
|
||||
mockSettingStore.get.mockReturnValue(true)
|
||||
vi.stubGlobal(
|
||||
'confirm',
|
||||
vi.fn(() => false)
|
||||
)
|
||||
try {
|
||||
new ComfyUI(app)
|
||||
|
||||
await click(buttonByText(document, 'Clear'))
|
||||
await click(buttonByText(document, 'Load Default'))
|
||||
|
||||
expect(app.clean).not.toHaveBeenCalled()
|
||||
expect(app.loadGraphData).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
vi.unstubAllGlobals()
|
||||
}
|
||||
})
|
||||
|
||||
it('persists manual menu dragging', () => {
|
||||
Object.defineProperty(document.body, 'clientWidth', {
|
||||
configurable: true,
|
||||
value: 1000
|
||||
})
|
||||
Object.defineProperty(document.body, 'clientHeight', {
|
||||
configurable: true,
|
||||
value: 800
|
||||
})
|
||||
const ui = new ComfyUI(app)
|
||||
ui.menuContainer.style.display = 'block'
|
||||
Object.defineProperty(ui.menuContainer, 'clientWidth', {
|
||||
configurable: true,
|
||||
value: 100
|
||||
})
|
||||
Object.defineProperty(ui.menuContainer, 'clientHeight', {
|
||||
configurable: true,
|
||||
value: 80
|
||||
})
|
||||
Object.defineProperty(ui.menuContainer, 'offsetLeft', {
|
||||
configurable: true,
|
||||
get: () => 700
|
||||
})
|
||||
Object.defineProperty(ui.menuContainer, 'offsetTop', {
|
||||
configurable: true,
|
||||
get: () => 20
|
||||
})
|
||||
const handle = ui.menuContainer.querySelector('.drag-handle') as HTMLElement
|
||||
|
||||
handle.onmousedown?.(
|
||||
new MouseEvent('mousedown', { clientX: 10, clientY: 10 })
|
||||
)
|
||||
document.onmousemove?.(
|
||||
new MouseEvent('mousemove', { clientX: 20, clientY: 30 })
|
||||
)
|
||||
document.onmouseup?.(new MouseEvent('mouseup'))
|
||||
|
||||
expect(ui.menuContainer.classList.contains('comfy-menu-manual-pos')).toBe(
|
||||
true
|
||||
)
|
||||
expect(ui.menuContainer.style.right).toBe('190px')
|
||||
expect(localStorage.getItem('Comfy.MenuPosition')).toBe(
|
||||
JSON.stringify({ x: 700, y: 20 })
|
||||
)
|
||||
expect(document.onmousemove).toBeNull()
|
||||
expect(document.onmouseup).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -1,202 +0,0 @@
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { ComfyApp } from '@/scripts/app'
|
||||
import type { ComfyPopup } from '@/scripts/ui/components/popup'
|
||||
|
||||
vi.mock('../../ui', () => ({
|
||||
$el: (tag: string, props?: Record<string, unknown>, children?: Node[]) => {
|
||||
const [tagName, ...classes] = tag.split('.')
|
||||
const element = document.createElement(tagName)
|
||||
if (classes.length) element.classList.add(...classes)
|
||||
if (props) {
|
||||
const listeners = Object.entries(props).filter(([key]) =>
|
||||
key.startsWith('on')
|
||||
)
|
||||
for (const [key, listener] of listeners) {
|
||||
if (typeof listener === 'function') {
|
||||
element.addEventListener(key.slice(2), listener as EventListener)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (children) element.append(...children)
|
||||
return element
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('../../utils', () => ({
|
||||
prop: <T>(
|
||||
target: object,
|
||||
name: string,
|
||||
defaultValue: T,
|
||||
onChanged?: (currentValue: T, previousValue: T) => void
|
||||
) => {
|
||||
let currentValue: T
|
||||
Object.defineProperty(target, name, {
|
||||
get() {
|
||||
return currentValue
|
||||
},
|
||||
set(newValue: T) {
|
||||
const previousValue = currentValue
|
||||
currentValue = newValue
|
||||
onChanged?.(currentValue, previousValue)
|
||||
}
|
||||
})
|
||||
;(target as Record<string, T>)[name] = defaultValue
|
||||
return defaultValue
|
||||
}
|
||||
}))
|
||||
|
||||
import { ComfyButton } from './button'
|
||||
|
||||
class MockPopup extends EventTarget {
|
||||
element = document.createElement('div')
|
||||
open = false
|
||||
toggle = vi.fn(() => {
|
||||
this.open = !this.open
|
||||
this.dispatchEvent(new CustomEvent('change'))
|
||||
})
|
||||
}
|
||||
|
||||
function mockApp(settingValue: boolean) {
|
||||
let listener: (() => void) | undefined
|
||||
const app = fromPartial<ComfyApp>({
|
||||
ui: {
|
||||
settings: {
|
||||
getSettingValue: vi.fn(() => settingValue),
|
||||
addEventListener: vi.fn((_event: string, callback: () => void) => {
|
||||
listener = callback
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
return {
|
||||
app,
|
||||
setSettingValue(value: boolean) {
|
||||
settingValue = value
|
||||
listener?.()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('ComfyButton', () => {
|
||||
beforeEach(() => {
|
||||
document.body.replaceChildren()
|
||||
})
|
||||
|
||||
it('renders icon, content, tooltip, enabled state, and click action', () => {
|
||||
const action = vi.fn()
|
||||
const button = new ComfyButton({
|
||||
icon: 'play',
|
||||
overIcon: 'pause',
|
||||
iconSize: 18,
|
||||
content: 'Run',
|
||||
tooltip: 'Queue prompt',
|
||||
enabled: false,
|
||||
classList: { primary: true, hiddenClass: false },
|
||||
action
|
||||
})
|
||||
|
||||
expect(button.iconElement.className).toBe('mdi mdi-play mdi-18px')
|
||||
expect(button.contentElement.textContent).toBe('Run')
|
||||
expect(button.element.title).toBe('Queue prompt')
|
||||
expect(button.element.getAttribute('aria-label')).toBe('Queue prompt')
|
||||
expect(button.element.classList.contains('primary')).toBe(true)
|
||||
expect(button.element.classList.contains('disabled')).toBe(true)
|
||||
expect((button.element as HTMLButtonElement).disabled).toBe(true)
|
||||
|
||||
button.enabled = true
|
||||
button.element.dispatchEvent(new MouseEvent('mouseenter'))
|
||||
expect(button.iconElement.className).toBe('mdi mdi-pause mdi-18px')
|
||||
button.element.dispatchEvent(new MouseEvent('mouseleave'))
|
||||
expect(button.iconElement.className).toBe('mdi mdi-play mdi-18px')
|
||||
|
||||
button.element.dispatchEvent(new MouseEvent('click'))
|
||||
expect(action).toHaveBeenCalledWith(expect.any(MouseEvent), button)
|
||||
})
|
||||
|
||||
it('supports HTMLElement content and removing tooltip text', () => {
|
||||
const button = new ComfyButton({ content: 'Text', tooltip: 'Hint' })
|
||||
const content = document.createElement('strong')
|
||||
content.textContent = 'Element'
|
||||
|
||||
button.content = content
|
||||
button.tooltip = ''
|
||||
|
||||
expect(button.contentElement.firstElementChild).toBe(content)
|
||||
expect(button.element.hasAttribute('title')).toBe(false)
|
||||
})
|
||||
|
||||
it('updates the hover icon when overIcon changes while hovered', () => {
|
||||
const button = new ComfyButton({ icon: 'play' })
|
||||
|
||||
button.element.dispatchEvent(new MouseEvent('mouseenter'))
|
||||
button.overIcon = 'pause'
|
||||
|
||||
expect(button.iconElement.className).toBe('mdi mdi-pause')
|
||||
})
|
||||
|
||||
it('hides and shows from a visibility setting', () => {
|
||||
const settings = mockApp(false)
|
||||
const button = new ComfyButton({
|
||||
app: settings.app,
|
||||
visibilitySetting: {
|
||||
id: 'Comfy.UseNewMenu',
|
||||
showValue: true
|
||||
}
|
||||
})
|
||||
|
||||
expect(button.hidden).toBe(true)
|
||||
expect(button.element.classList.contains('hidden')).toBe(true)
|
||||
|
||||
settings.setSettingValue(true)
|
||||
|
||||
expect(button.hidden).toBe(false)
|
||||
expect(button.element.classList.contains('hidden')).toBe(false)
|
||||
})
|
||||
|
||||
it('toggles click popups and reflects popup open state in classes', () => {
|
||||
const popup = new MockPopup()
|
||||
const button = new ComfyButton({ icon: 'dots' }).withPopup(
|
||||
fromPartial<ComfyPopup>(popup)
|
||||
)
|
||||
|
||||
button.element.dispatchEvent(new MouseEvent('click'))
|
||||
|
||||
expect(popup.toggle).toHaveBeenCalledOnce()
|
||||
expect(button.element.classList.contains('popup-open')).toBe(true)
|
||||
|
||||
popup.toggle()
|
||||
|
||||
expect(button.element.classList.contains('popup-closed')).toBe(true)
|
||||
})
|
||||
|
||||
it('opens hover popups while either the button or popup is hovered', () => {
|
||||
const popup = new MockPopup()
|
||||
const button = new ComfyButton({ icon: 'dots' }).withPopup(
|
||||
fromPartial<ComfyPopup>(popup),
|
||||
'hover'
|
||||
)
|
||||
|
||||
button.element.dispatchEvent(new MouseEvent('mouseenter'))
|
||||
expect(popup.open).toBe(true)
|
||||
popup.element.dispatchEvent(new MouseEvent('mouseenter'))
|
||||
button.element.dispatchEvent(new MouseEvent('mouseleave'))
|
||||
expect(popup.open).toBe(true)
|
||||
popup.element.dispatchEvent(new MouseEvent('mouseleave'))
|
||||
expect(popup.open).toBe(false)
|
||||
})
|
||||
|
||||
it('does not click-toggle a hover popup while hovered', () => {
|
||||
const popup = new MockPopup()
|
||||
const button = new ComfyButton({ icon: 'dots' }).withPopup(
|
||||
fromPartial<ComfyPopup>(popup),
|
||||
'hover'
|
||||
)
|
||||
|
||||
button.element.dispatchEvent(new MouseEvent('mouseenter'))
|
||||
button.element.dispatchEvent(new MouseEvent('click'))
|
||||
|
||||
expect(popup.toggle).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -10,7 +10,7 @@ export class ComfyButtonGroup {
|
||||
this.buttons = prop(this, 'buttons', buttons, () => this.update())
|
||||
}
|
||||
|
||||
insert(button: HTMLElement | ComfyButton, index: number) {
|
||||
insert(button: ComfyButton, index: number) {
|
||||
this.buttons.splice(index, 0, button)
|
||||
this.update()
|
||||
}
|
||||
|
||||
@@ -1,283 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
type ElChild = Node | string
|
||||
type ElInput = Record<string, unknown> | ElChild | ElChild[]
|
||||
|
||||
function appendChildren(element: HTMLElement, children: ElChild | ElChild[]) {
|
||||
const list = Array.isArray(children) ? children : [children]
|
||||
for (const child of list) {
|
||||
element.append(child)
|
||||
}
|
||||
}
|
||||
|
||||
vi.mock('../../ui', () => ({
|
||||
$el: (tag: string, propsOrChildren?: ElInput, children?: ElChild[]) => {
|
||||
const [tagName, ...classes] = tag.split('.')
|
||||
const element = document.createElement(tagName)
|
||||
if (classes.length) element.classList.add(...classes)
|
||||
|
||||
if (
|
||||
propsOrChildren instanceof Node ||
|
||||
typeof propsOrChildren === 'string' ||
|
||||
Array.isArray(propsOrChildren)
|
||||
) {
|
||||
appendChildren(element, propsOrChildren)
|
||||
} else if (propsOrChildren) {
|
||||
for (const [key, value] of Object.entries(propsOrChildren)) {
|
||||
if (key === '$' && typeof value === 'function') {
|
||||
value(element)
|
||||
} else if (key === 'parent' && value instanceof HTMLElement) {
|
||||
value.append(element)
|
||||
} else if (key === 'textContent') {
|
||||
element.textContent = String(value)
|
||||
} else if (key === 'ariaLabel') {
|
||||
element.setAttribute('aria-label', String(value))
|
||||
} else if (key === 'ariaHasPopup') {
|
||||
element.setAttribute('aria-haspopup', String(value))
|
||||
} else if (key === 'type') {
|
||||
element.setAttribute('type', String(value))
|
||||
} else if (
|
||||
key.toLowerCase().startsWith('on') &&
|
||||
typeof value === 'function'
|
||||
) {
|
||||
element.addEventListener(
|
||||
key.slice(2).toLowerCase(),
|
||||
value as EventListener
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (children) appendChildren(element, children)
|
||||
return element
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('../../utils', () => ({
|
||||
prop: <T>(
|
||||
target: object,
|
||||
name: string,
|
||||
defaultValue: T,
|
||||
onChanged?: (currentValue: T, previousValue: T) => void
|
||||
) => {
|
||||
let currentValue: T
|
||||
Object.defineProperty(target, name, {
|
||||
get() {
|
||||
return currentValue
|
||||
},
|
||||
set(newValue: T) {
|
||||
const previousValue = currentValue
|
||||
currentValue = newValue
|
||||
onChanged?.(currentValue, previousValue)
|
||||
}
|
||||
})
|
||||
;(target as Record<string, T>)[name] = defaultValue
|
||||
return defaultValue
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('../utils', () => ({
|
||||
applyClasses: (
|
||||
element: HTMLElement,
|
||||
classList: string | Record<string, boolean>,
|
||||
...baseClasses: string[]
|
||||
) => {
|
||||
element.className = baseClasses.join(' ')
|
||||
if (typeof classList === 'string') {
|
||||
element.classList.add(...classList.split(' ').filter(Boolean))
|
||||
} else {
|
||||
for (const [className, enabled] of Object.entries(classList)) {
|
||||
element.classList.toggle(className, enabled)
|
||||
}
|
||||
}
|
||||
},
|
||||
toggleElement:
|
||||
<T>(
|
||||
element: HTMLElement,
|
||||
{
|
||||
onShow
|
||||
}: {
|
||||
onShow?: (element: HTMLElement, value: T) => void
|
||||
} = {}
|
||||
) =>
|
||||
(value: T) => {
|
||||
element.hidden = !value
|
||||
if (value) onShow?.(element, value)
|
||||
}
|
||||
}))
|
||||
|
||||
import { ComfyAsyncDialog } from './asyncDialog'
|
||||
import { ComfyButton } from './button'
|
||||
import { ComfyButtonGroup } from './buttonGroup'
|
||||
import { ComfyPopup } from './popup'
|
||||
import { ComfySplitButton } from './splitButton'
|
||||
|
||||
function targetWithRect(rect: DOMRect) {
|
||||
const target = document.createElement('button')
|
||||
vi.spyOn(target, 'getBoundingClientRect').mockReturnValue(rect)
|
||||
document.body.append(target)
|
||||
return target
|
||||
}
|
||||
|
||||
describe('ComfyPopup and related UI components', () => {
|
||||
beforeEach(() => {
|
||||
document.body.replaceChildren()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('opens, positions, updates children and classes, and closes from escape', () => {
|
||||
const target = targetWithRect(
|
||||
DOMRect.fromRect({ x: 10, y: 20, width: 80, height: 30 })
|
||||
)
|
||||
const child = document.createElement('span')
|
||||
const popup = new ComfyPopup(
|
||||
{ target, classList: { menu: true, hidden: false } },
|
||||
child
|
||||
)
|
||||
const open = vi.fn()
|
||||
const close = vi.fn()
|
||||
const change = vi.fn()
|
||||
popup.addEventListener('open', open)
|
||||
popup.addEventListener('close', close)
|
||||
popup.addEventListener('change', change)
|
||||
vi.spyOn(popup.element, 'getBoundingClientRect').mockReturnValue(
|
||||
DOMRect.fromRect({ height: 20 })
|
||||
)
|
||||
|
||||
popup.open = true
|
||||
|
||||
expect(open).toHaveBeenCalledOnce()
|
||||
expect(change).toHaveBeenCalledOnce()
|
||||
expect(popup.element).toHaveClass('open')
|
||||
expect(popup.element.style.getPropertyValue('--left')).toBe('10px')
|
||||
expect(popup.element.style.getPropertyValue('--bottom')).toBe('35px')
|
||||
|
||||
const nextChild = document.createElement('strong')
|
||||
popup.children = [nextChild]
|
||||
popup.classList = 'extra'
|
||||
|
||||
expect(popup.element.firstElementChild).toBe(nextChild)
|
||||
expect(popup.element).toHaveClass('comfyui-popup', 'left', 'extra')
|
||||
|
||||
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' }))
|
||||
expect(popup.open).toBe(true)
|
||||
|
||||
const escape = new KeyboardEvent('keydown', {
|
||||
key: 'Escape',
|
||||
cancelable: true
|
||||
})
|
||||
window.dispatchEvent(escape)
|
||||
|
||||
expect(close).toHaveBeenCalledOnce()
|
||||
expect(escape.defaultPrevented).toBe(true)
|
||||
expect(popup.open).toBe(false)
|
||||
})
|
||||
|
||||
it('handles outside clicks, target clicks, and relative right positioning', () => {
|
||||
const target = targetWithRect(
|
||||
DOMRect.fromRect({ x: 100, y: 40, width: 60, height: 25 })
|
||||
)
|
||||
const container = document.createElement('section')
|
||||
document.body.append(container)
|
||||
const popup = new ComfyPopup({
|
||||
target,
|
||||
container,
|
||||
position: 'relative',
|
||||
horizontal: 'right'
|
||||
})
|
||||
vi.spyOn(popup.element, 'getBoundingClientRect').mockReturnValue(
|
||||
DOMRect.fromRect({ height: 100 })
|
||||
)
|
||||
Object.defineProperty(popup.element, 'clientWidth', {
|
||||
configurable: true,
|
||||
value: 40
|
||||
})
|
||||
|
||||
popup.open = true
|
||||
target.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
||||
expect(popup.open).toBe(true)
|
||||
|
||||
const outside = document.createElement('div')
|
||||
document.body.append(outside)
|
||||
outside.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
||||
|
||||
expect(popup.open).toBe(false)
|
||||
expect(popup.element.style.getPropertyValue('--left')).toBe('0px')
|
||||
expect(popup.element.style.getPropertyValue('--top')).toBe('25px')
|
||||
})
|
||||
|
||||
it('keeps outside clicks open when target clicks are not ignored', () => {
|
||||
const target = targetWithRect(DOMRect.fromRect({ height: 10 }))
|
||||
const popup = new ComfyPopup({
|
||||
target,
|
||||
ignoreTarget: false,
|
||||
closeOnEscape: false
|
||||
})
|
||||
const outside = document.createElement('div')
|
||||
document.body.append(outside)
|
||||
|
||||
popup.toggle()
|
||||
outside.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
||||
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
|
||||
|
||||
expect(popup.open).toBe(true)
|
||||
})
|
||||
|
||||
it('renders split button popup items and updates button groups', () => {
|
||||
const primary = new ComfyButton({ content: 'Queue' })
|
||||
const itemButton = new ComfyButton({ content: 'Queue front' })
|
||||
const rawItem = document.createElement('button')
|
||||
rawItem.textContent = 'Queue back'
|
||||
const split = new ComfySplitButton(
|
||||
{
|
||||
primary,
|
||||
mode: 'hover',
|
||||
horizontal: 'right',
|
||||
position: 'absolute'
|
||||
},
|
||||
itemButton,
|
||||
rawItem
|
||||
)
|
||||
|
||||
expect(split.element).toHaveClass('comfyui-split-button', 'hover')
|
||||
expect(split.popup.element).toHaveTextContent('Queue front')
|
||||
expect(split.popup.element).toHaveTextContent('Queue back')
|
||||
expect(document.body).toContainElement(split.popup.element)
|
||||
|
||||
const group = new ComfyButtonGroup(primary)
|
||||
group.append(itemButton)
|
||||
group.insert(rawItem, 1)
|
||||
|
||||
expect(group.element.children).toHaveLength(3)
|
||||
expect(group.remove(itemButton)).toEqual([itemButton])
|
||||
expect(group.remove(itemButton)).toBeUndefined()
|
||||
expect(group.element.children).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('resolves async dialogs from buttons, close events, and prompt actions', async () => {
|
||||
const dialog = new ComfyAsyncDialog<number>([
|
||||
{ text: 'Seven', value: 7 },
|
||||
'Fallback'
|
||||
])
|
||||
|
||||
const promise = dialog.show('Pick one')
|
||||
dialog.element.querySelector<HTMLButtonElement>('button')?.click()
|
||||
await expect(promise).resolves.toBe(7)
|
||||
|
||||
const closePromise = dialog.show(document.createElement('em'))
|
||||
dialog.element.dispatchEvent(new Event('close'))
|
||||
await expect(closePromise).resolves.toBeNull()
|
||||
|
||||
const promptPromise = ComfyAsyncDialog.prompt({
|
||||
title: 'Confirm',
|
||||
message: 'Continue?',
|
||||
actions: [{ text: 'Yes', value: 'yes' }]
|
||||
})
|
||||
const prompt = Array.from(document.querySelectorAll('dialog')).at(-1)
|
||||
expect(prompt).toHaveTextContent('Confirm')
|
||||
prompt?.querySelector<HTMLButtonElement>('button')?.click()
|
||||
|
||||
await expect(promptPromise).resolves.toBe('yes')
|
||||
expect(document.body).not.toContainElement(prompt ?? null)
|
||||
})
|
||||
})
|
||||
@@ -1,69 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { ComfyDialog } from './dialog'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
$el: (
|
||||
selector: string,
|
||||
propsOrChildren?: Record<string, unknown> | Node[],
|
||||
maybeChildren?: Node[]
|
||||
) => {
|
||||
const [tag, ...classes] = selector.split('.')
|
||||
const element = document.createElement(tag || 'div')
|
||||
element.classList.add(...classes.filter(Boolean))
|
||||
const children = Array.isArray(propsOrChildren)
|
||||
? propsOrChildren
|
||||
: maybeChildren
|
||||
|
||||
if (propsOrChildren && !Array.isArray(propsOrChildren)) {
|
||||
for (const [key, value] of Object.entries(propsOrChildren)) {
|
||||
if (key === 'parent' && value instanceof Node) {
|
||||
value.appendChild(element)
|
||||
} else if (key === '$' && typeof value === 'function') {
|
||||
value(element)
|
||||
} else {
|
||||
Reflect.set(element, key, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
element.append(...(children ?? []))
|
||||
return element
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('../ui', () => ({
|
||||
$el: mocks.$el
|
||||
}))
|
||||
|
||||
describe('ComfyDialog', () => {
|
||||
afterEach(() => {
|
||||
document.body.replaceChildren()
|
||||
})
|
||||
|
||||
it('shows string and element content and closes through the default button', () => {
|
||||
const dialog = new ComfyDialog()
|
||||
|
||||
dialog.show('<strong>Hello</strong>')
|
||||
|
||||
expect(dialog.element.style.display).toBe('flex')
|
||||
expect(dialog.textElement.innerHTML).toBe('<strong>Hello</strong>')
|
||||
|
||||
dialog.element.querySelector('button')?.click()
|
||||
expect(dialog.element.style.display).toBe('none')
|
||||
|
||||
const first = document.createElement('span')
|
||||
const second = document.createElement('em')
|
||||
dialog.show([first, second])
|
||||
|
||||
expect([...dialog.textElement.children]).toEqual([first, second])
|
||||
})
|
||||
|
||||
it('uses supplied custom buttons', () => {
|
||||
const button = document.createElement('button')
|
||||
const dialog = new ComfyDialog('section', [button])
|
||||
|
||||
expect(dialog.element.tagName).toBe('SECTION')
|
||||
expect(dialog.element.querySelector('button')).toBe(button)
|
||||
})
|
||||
})
|
||||
@@ -1,213 +0,0 @@
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { DraggableList } from './draggableList'
|
||||
|
||||
function createList(itemCount: number) {
|
||||
const container = document.createElement('div')
|
||||
const items = Array.from({ length: itemCount }, (_, index) => {
|
||||
const item = document.createElement('div')
|
||||
item.className = 'item'
|
||||
item.dataset.index = String(index)
|
||||
|
||||
const handle = document.createElement('button')
|
||||
handle.className = 'drag-handle'
|
||||
item.append(handle)
|
||||
container.append(item)
|
||||
|
||||
return item
|
||||
})
|
||||
document.body.append(container)
|
||||
return { container, items }
|
||||
}
|
||||
|
||||
function setRect(element: Element, top: number, height = 20) {
|
||||
return vi
|
||||
.spyOn(element, 'getBoundingClientRect')
|
||||
.mockReturnValue(new DOMRect(0, top, 100, height))
|
||||
}
|
||||
|
||||
function defineScrollMetrics(
|
||||
container: HTMLElement,
|
||||
scrollHeight: number,
|
||||
clientHeight: number
|
||||
) {
|
||||
Object.defineProperty(container, 'scrollHeight', {
|
||||
configurable: true,
|
||||
value: scrollHeight
|
||||
})
|
||||
Object.defineProperty(container, 'clientHeight', {
|
||||
configurable: true,
|
||||
value: clientHeight
|
||||
})
|
||||
}
|
||||
|
||||
function mouseDragEvent(
|
||||
target: Element,
|
||||
overrides: Partial<MouseEvent> = {}
|
||||
): MouseEvent {
|
||||
return {
|
||||
button: 0,
|
||||
clientX: 0,
|
||||
clientY: 0,
|
||||
preventDefault: vi.fn(),
|
||||
target,
|
||||
...overrides
|
||||
} satisfies Partial<MouseEvent> as MouseEvent
|
||||
}
|
||||
|
||||
describe('DraggableList', () => {
|
||||
afterEach(() => {
|
||||
document.body.replaceChildren()
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('ignores missing containers and non-primary drag starts', () => {
|
||||
const listWithoutContainer = new DraggableList(null, '.item')
|
||||
const { container, items } = createList(1)
|
||||
const list = new DraggableList(container, '.item')
|
||||
|
||||
list.dragStart(
|
||||
mouseDragEvent(items[0].querySelector('.drag-handle')!, { button: 1 })
|
||||
)
|
||||
list.dragEnd()
|
||||
|
||||
expect(listWithoutContainer.listContainer).toBeNull()
|
||||
expect(list.draggableItem).toBeUndefined()
|
||||
})
|
||||
|
||||
it('starts from a handle, scrolls downward, and reorders upward', () => {
|
||||
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
|
||||
callback(0)
|
||||
return 1
|
||||
})
|
||||
const { container, items } = createList(3)
|
||||
const scrollBy = vi.fn((_left: number, top: number) => {
|
||||
container.scrollTop += top
|
||||
})
|
||||
Reflect.set(container, 'scrollBy', scrollBy)
|
||||
defineScrollMetrics(container, 120, 80)
|
||||
vi.spyOn(container, 'getBoundingClientRect').mockReturnValue(
|
||||
new DOMRect(0, 0, 100, 80)
|
||||
)
|
||||
setRect(items[0], 0)
|
||||
setRect(items[1], 30)
|
||||
setRect(items[2], 60)
|
||||
|
||||
const list = new DraggableList(container, '.item')
|
||||
const dragStart = vi.fn()
|
||||
const dragEnd = vi.fn()
|
||||
list.addEventListener('dragstart', dragStart)
|
||||
list.addEventListener('dragend', dragEnd)
|
||||
|
||||
list.dragStart(
|
||||
mouseDragEvent(items[2].querySelector('.drag-handle')!, {
|
||||
clientX: 10,
|
||||
clientY: 70
|
||||
})
|
||||
)
|
||||
list.drag(
|
||||
mouseDragEvent(items[2].querySelector('.drag-handle')!, {
|
||||
clientX: 20,
|
||||
clientY: 100
|
||||
})
|
||||
)
|
||||
items[1].dataset.isToggled = ''
|
||||
list.dragEnd()
|
||||
|
||||
expect(dragStart).toHaveBeenCalledOnce()
|
||||
expect(dragEnd).toHaveBeenCalledOnce()
|
||||
expect(scrollBy).toHaveBeenCalledWith(0, 10)
|
||||
expect([...container.children]).toEqual([items[0], items[2], items[1]])
|
||||
expect(items[0].classList.contains('is-idle')).toBe(true)
|
||||
expect(items[1].classList.contains('is-idle')).toBe(true)
|
||||
})
|
||||
|
||||
it('supports touch coordinates, upward scrolling, and downward reorder', () => {
|
||||
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
|
||||
callback(0)
|
||||
return 1
|
||||
})
|
||||
const { container, items } = createList(3)
|
||||
const scrollBy = vi.fn((_left: number, top: number) => {
|
||||
container.scrollTop += top
|
||||
})
|
||||
container.scrollTop = 10
|
||||
Reflect.set(container, 'scrollBy', scrollBy)
|
||||
defineScrollMetrics(container, 120, 80)
|
||||
vi.spyOn(container, 'getBoundingClientRect').mockReturnValue(
|
||||
new DOMRect(0, 20, 100, 80)
|
||||
)
|
||||
setRect(items[0], 0)
|
||||
setRect(items[1], 30)
|
||||
setRect(items[2], 60)
|
||||
|
||||
const list = new DraggableList(container, '.item')
|
||||
const touchStart = fromPartial<TouchEvent>({
|
||||
preventDefault: vi.fn(),
|
||||
target: items[0].querySelector('.drag-handle')!,
|
||||
touches: fromPartial<TouchList>({
|
||||
0: fromPartial<Touch>({ clientX: 5, clientY: 30 }),
|
||||
length: 1
|
||||
})
|
||||
})
|
||||
const touchMove = fromPartial<TouchEvent>({
|
||||
preventDefault: vi.fn(),
|
||||
target: items[0].querySelector('.drag-handle')!,
|
||||
touches: fromPartial<TouchList>({
|
||||
0: fromPartial<Touch>({ clientX: 8, clientY: 10 }),
|
||||
length: 1
|
||||
})
|
||||
})
|
||||
|
||||
list.dragStart(touchStart)
|
||||
list.drag(touchMove)
|
||||
items[1].dataset.isToggled = ''
|
||||
list.dragEnd()
|
||||
|
||||
expect(scrollBy).toHaveBeenCalledWith(0, -10)
|
||||
expect([...container.children]).toEqual([items[1], items[0], items[2]])
|
||||
})
|
||||
|
||||
it('updates idle item state around the dragged item midpoint', () => {
|
||||
const { container, items } = createList(3)
|
||||
const list = new DraggableList(container, '.item')
|
||||
Reflect.set(list, 'items', items)
|
||||
Reflect.set(list, 'draggableItem', items[1])
|
||||
list.itemsGap = 5
|
||||
items[0].classList.add('is-idle')
|
||||
items[1].classList.add('is-idle')
|
||||
items[2].classList.add('is-idle')
|
||||
items[0].dataset.isAbove = ''
|
||||
const draggedRect = setRect(items[1], -10)
|
||||
setRect(items[0], 0)
|
||||
setRect(items[2], 60)
|
||||
|
||||
list.updateIdleItemsStateAndPosition()
|
||||
|
||||
expect(items[0].dataset.isToggled).toBe('')
|
||||
expect(items[0].style.transform).toBe('translateY(25px)')
|
||||
expect(items[2].style.transform).toBe('')
|
||||
|
||||
draggedRect.mockReturnValue(new DOMRect(0, 100, 100, 20))
|
||||
list.updateIdleItemsStateAndPosition()
|
||||
|
||||
expect(items[0].dataset.isToggled).toBeUndefined()
|
||||
expect(items[2].dataset.isToggled).toBe('')
|
||||
expect(items[2].style.transform).toBe('translateY(-25px)')
|
||||
})
|
||||
|
||||
it('uses zero gap for short lists and disposes listeners', () => {
|
||||
const { container } = createList(1)
|
||||
const list = new DraggableList(container, '.item')
|
||||
const off = vi.fn()
|
||||
Reflect.set(list, 'off', [off])
|
||||
|
||||
list.setItemsGap()
|
||||
list.dispose()
|
||||
|
||||
expect(list.itemsGap).toBe(0)
|
||||
expect(off).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
@@ -1,33 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { calculateImageGrid } from './imagePreview'
|
||||
|
||||
function createImage(width: number, height: number) {
|
||||
const img = document.createElement('img')
|
||||
Object.defineProperty(img, 'naturalWidth', {
|
||||
configurable: true,
|
||||
value: width
|
||||
})
|
||||
Object.defineProperty(img, 'naturalHeight', {
|
||||
configurable: true,
|
||||
value: height
|
||||
})
|
||||
return img
|
||||
}
|
||||
|
||||
describe('imagePreview', () => {
|
||||
it('calculates the highest-area grid', () => {
|
||||
const images = [
|
||||
createImage(100, 100),
|
||||
createImage(100, 100),
|
||||
createImage(100, 100)
|
||||
]
|
||||
|
||||
expect(calculateImageGrid(images, 300, 120)).toMatchObject({
|
||||
cellWidth: 100,
|
||||
cellHeight: 100,
|
||||
cols: 3,
|
||||
rows: 1
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,86 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { toggleSwitch } from './toggleSwitch'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
$el: (
|
||||
selector: string,
|
||||
propsOrChildren?: Record<string, unknown> | Node[] | Node,
|
||||
maybeChildren?: Node[] | Node
|
||||
) => {
|
||||
const [tag, ...classes] = selector.split('.')
|
||||
const element = document.createElement(tag || 'div')
|
||||
element.classList.add(...classes.filter(Boolean))
|
||||
const children = Array.isArray(propsOrChildren)
|
||||
? propsOrChildren
|
||||
: propsOrChildren instanceof Node
|
||||
? [propsOrChildren]
|
||||
: Array.isArray(maybeChildren)
|
||||
? maybeChildren
|
||||
: maybeChildren instanceof Node
|
||||
? [maybeChildren]
|
||||
: []
|
||||
|
||||
if (
|
||||
propsOrChildren &&
|
||||
!(propsOrChildren instanceof Node) &&
|
||||
!Array.isArray(propsOrChildren)
|
||||
) {
|
||||
for (const [key, value] of Object.entries(propsOrChildren)) {
|
||||
Reflect.set(element, key, value)
|
||||
}
|
||||
}
|
||||
|
||||
element.append(...children)
|
||||
return element
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('../ui', () => ({
|
||||
$el: mocks.$el
|
||||
}))
|
||||
|
||||
describe('toggleSwitch', () => {
|
||||
afterEach(() => {
|
||||
document.body.replaceChildren()
|
||||
})
|
||||
|
||||
it('selects the first item when none is preselected', () => {
|
||||
const onChange = vi.fn()
|
||||
const container = toggleSwitch('mode', ['first', 'second'], { onChange })
|
||||
const labels = [...container.querySelectorAll('label')]
|
||||
const inputs = [...container.querySelectorAll('input')]
|
||||
|
||||
expect(labels[0].classList.contains('comfy-toggle-selected')).toBe(true)
|
||||
expect((inputs[0] as HTMLInputElement).checked).toBe(true)
|
||||
expect(onChange).toHaveBeenCalledWith({
|
||||
item: 'first',
|
||||
prev: undefined
|
||||
})
|
||||
})
|
||||
|
||||
it('moves selection and reports the previous item', () => {
|
||||
const onChange = vi.fn()
|
||||
const container = toggleSwitch(
|
||||
'mode',
|
||||
[
|
||||
{ text: 'first', tooltip: 'First option' },
|
||||
{ text: 'second', value: '2' }
|
||||
],
|
||||
{ onChange }
|
||||
)
|
||||
const labels = [...container.querySelectorAll('label')]
|
||||
const secondInput = labels[1].querySelector('input') as HTMLInputElement
|
||||
|
||||
secondInput.onchange?.(new Event('change'))
|
||||
|
||||
expect(labels[0].classList.contains('comfy-toggle-selected')).toBe(false)
|
||||
expect(labels[1].classList.contains('comfy-toggle-selected')).toBe(true)
|
||||
expect(labels[0].title).toBe('First option')
|
||||
expect(secondInput.value).toBe('2')
|
||||
expect(onChange).toHaveBeenLastCalledWith({
|
||||
item: { text: 'second', value: '2' },
|
||||
prev: { text: 'first', tooltip: 'First option', value: 'first' }
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,45 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { applyClasses, toggleElement } from './utils'
|
||||
|
||||
describe('ui utils', () => {
|
||||
it('applies string, array, object, and required classes', () => {
|
||||
const element = document.createElement('div')
|
||||
|
||||
applyClasses(element, 'one two', 'required')
|
||||
expect([...element.classList]).toEqual(['one', 'two', 'required'])
|
||||
|
||||
applyClasses(element, ['three', 'four'])
|
||||
expect([...element.classList]).toEqual(['three', 'four'])
|
||||
|
||||
applyClasses(element, { five: true, six: false, seven: true })
|
||||
expect([...element.classList]).toEqual(['five', 'seven'])
|
||||
|
||||
applyClasses(element, null)
|
||||
expect(element.className).toBe('')
|
||||
})
|
||||
|
||||
it('toggles an element through a placeholder', () => {
|
||||
const parent = document.createElement('div')
|
||||
const element = document.createElement('span')
|
||||
const onHide = vi.fn()
|
||||
const onShow = vi.fn()
|
||||
parent.append(element)
|
||||
const toggle = toggleElement(element, { onHide, onShow })
|
||||
|
||||
toggle(false)
|
||||
expect(parent.firstChild).toBeInstanceOf(Comment)
|
||||
expect(onHide).toHaveBeenCalledWith(element)
|
||||
|
||||
toggle(true)
|
||||
expect(parent.firstChild).toBe(element)
|
||||
expect(onShow).toHaveBeenCalledWith(element, true)
|
||||
|
||||
toggle('visible')
|
||||
expect(onShow).toHaveBeenCalledWith(element, 'visible')
|
||||
|
||||
toggle(false)
|
||||
expect(parent.firstChild).toBeInstanceOf(Comment)
|
||||
expect(onHide).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
@@ -2,7 +2,7 @@ export type ClassList = string | string[] | Record<string, boolean>
|
||||
|
||||
export function applyClasses(
|
||||
element: HTMLElement,
|
||||
classList: ClassList | null | undefined,
|
||||
classList: ClassList,
|
||||
...requiredClasses: string[]
|
||||
) {
|
||||
classList ??= ''
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
addStylesheet,
|
||||
clone,
|
||||
getStorageValue,
|
||||
prop,
|
||||
setStorageValue
|
||||
} from './utils'
|
||||
|
||||
interface LinkAttrs {
|
||||
href: string
|
||||
onerror: (error: Event) => void
|
||||
onload: () => void
|
||||
parent: HTMLElement
|
||||
rel: string
|
||||
type: string
|
||||
}
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
api: {
|
||||
clientId: null as string | null,
|
||||
initialClientId: null as string | null
|
||||
},
|
||||
$el: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./api', () => ({
|
||||
api: mocks.api
|
||||
}))
|
||||
|
||||
vi.mock('./ui', () => ({
|
||||
$el: mocks.$el
|
||||
}))
|
||||
|
||||
function lastLinkAttrs() {
|
||||
return mocks.$el.mock.calls.at(-1)?.[1] as LinkAttrs
|
||||
}
|
||||
|
||||
describe('scripts utils', () => {
|
||||
afterEach(() => {
|
||||
localStorage.clear()
|
||||
sessionStorage.clear()
|
||||
mocks.api.clientId = null
|
||||
mocks.api.initialClientId = null
|
||||
mocks.$el.mockReset()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('clones with structuredClone and falls back to JSON cloning', () => {
|
||||
const source = { nested: { value: 1 } }
|
||||
|
||||
expect(clone(source)).toEqual(source)
|
||||
|
||||
vi.stubGlobal(
|
||||
'structuredClone',
|
||||
vi.fn(() => {
|
||||
throw new Error('unsupported')
|
||||
})
|
||||
)
|
||||
|
||||
const cloned = clone(source)
|
||||
cloned.nested.value = 2
|
||||
|
||||
expect(cloned).toEqual({ nested: { value: 2 } })
|
||||
expect(source).toEqual({ nested: { value: 1 } })
|
||||
})
|
||||
|
||||
it('adds stylesheets from script and relative URLs', async () => {
|
||||
const scriptPromise = addStylesheet('/extensions/example.js')
|
||||
lastLinkAttrs().onload()
|
||||
|
||||
await expect(scriptPromise).resolves.toBeUndefined()
|
||||
expect(lastLinkAttrs()).toMatchObject({
|
||||
href: '/extensions/example.css',
|
||||
parent: document.head,
|
||||
rel: 'stylesheet',
|
||||
type: 'text/css'
|
||||
})
|
||||
|
||||
const cssPromise = addStylesheet('theme.css', 'https://example.com/base/')
|
||||
lastLinkAttrs().onload()
|
||||
|
||||
await expect(cssPromise).resolves.toBeUndefined()
|
||||
expect(lastLinkAttrs().href).toBe('https://example.com/base/theme.css')
|
||||
})
|
||||
|
||||
it('rejects when stylesheet loading fails', async () => {
|
||||
const promise = addStylesheet('missing.css', 'https://example.com/')
|
||||
const error = new Event('error')
|
||||
lastLinkAttrs().onerror(error)
|
||||
|
||||
await expect(promise).rejects.toBe(error)
|
||||
})
|
||||
|
||||
it('defines an observable property with the supplied default', () => {
|
||||
const target = {}
|
||||
const onChanged = vi.fn()
|
||||
|
||||
expect(prop(target, 'mode', 'initial', onChanged)).toBe('initial')
|
||||
Object.assign(target, { mode: 'next' })
|
||||
|
||||
expect((target as { mode: string }).mode).toBe('next')
|
||||
expect(onChanged).toHaveBeenCalledWith('next', undefined, target, 'mode')
|
||||
})
|
||||
|
||||
it('uses client-scoped storage before local fallback', () => {
|
||||
mocks.api.clientId = 'client-1'
|
||||
setStorageValue('setting', 'client-value')
|
||||
sessionStorage.removeItem('setting:client-1')
|
||||
|
||||
expect(getStorageValue('setting')).toBe('client-value')
|
||||
expect(localStorage.getItem('setting')).toBe('client-value')
|
||||
|
||||
sessionStorage.setItem('setting:client-1', 'session-value')
|
||||
|
||||
expect(getStorageValue('setting')).toBe('session-value')
|
||||
})
|
||||
|
||||
it('uses initial client id when the current client id is unavailable', () => {
|
||||
mocks.api.initialClientId = 'initial-1'
|
||||
setStorageValue('setting', 'initial-value')
|
||||
|
||||
expect(sessionStorage.getItem('setting:initial-1')).toBe('initial-value')
|
||||
expect(getStorageValue('setting')).toBe('initial-value')
|
||||
})
|
||||
})
|
||||
@@ -1,362 +0,0 @@
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type {
|
||||
IBaseWidget,
|
||||
IComboWidget
|
||||
} from '@/lib/litegraph/src/types/widgets'
|
||||
import type { ComfyApp } from '@/scripts/app'
|
||||
import type { InputSpec } from '@/schemas/nodeDefSchema'
|
||||
|
||||
const mockSettingGet = vi.hoisted(() => vi.fn())
|
||||
const mockNextValueForLinkedTarget = vi.hoisted(() => vi.fn())
|
||||
const mockIsComboWidget = vi.hoisted(() => vi.fn())
|
||||
const mockTransformInputSpecV1ToV2 = vi.hoisted(() => vi.fn())
|
||||
|
||||
function v2WidgetConstructor(kind: string) {
|
||||
return () => (_node: LGraphNode, inputSpec: { name: string }) => ({
|
||||
name: `${kind}:${inputSpec.name}`,
|
||||
options: { minNodeSize: [20, 30] }
|
||||
})
|
||||
}
|
||||
|
||||
vi.mock('@/i18n', () => ({
|
||||
t: (key: string) => `translated:${key}`
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => ({
|
||||
get: mockSettingGet
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/litegraph/src/litegraph', () => ({
|
||||
isComboWidget: mockIsComboWidget
|
||||
}))
|
||||
|
||||
vi.mock('./valueControl', () => ({
|
||||
nextValueForLinkedTarget: mockNextValueForLinkedTarget
|
||||
}))
|
||||
|
||||
vi.mock('@/schemas/nodeDef/migration', () => ({
|
||||
transformInputSpecV1ToV2: mockTransformInputSpecV1ToV2
|
||||
}))
|
||||
|
||||
vi.mock('@/core/graph/widgets/dynamicWidgets', () => ({
|
||||
dynamicWidgets: {
|
||||
DYNAMIC: () => ({
|
||||
widget: { name: 'dynamic', options: {} },
|
||||
minWidth: 1,
|
||||
minHeight: 1
|
||||
})
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock(
|
||||
'@/renderer/extensions/vueNodes/widgets/composables/useBooleanWidget',
|
||||
() => ({ useBooleanWidget: v2WidgetConstructor('BOOLEAN') })
|
||||
)
|
||||
vi.mock(
|
||||
'@/renderer/extensions/vueNodes/widgets/composables/useBoundingBoxWidget',
|
||||
() => ({ useBoundingBoxWidget: v2WidgetConstructor('BOUNDING_BOX') })
|
||||
)
|
||||
vi.mock(
|
||||
'@/renderer/extensions/vueNodes/widgets/composables/useCurveWidget',
|
||||
() => ({ useCurveWidget: v2WidgetConstructor('CURVE') })
|
||||
)
|
||||
vi.mock(
|
||||
'@/renderer/extensions/vueNodes/widgets/composables/useChartWidget',
|
||||
() => ({ useChartWidget: v2WidgetConstructor('CHART') })
|
||||
)
|
||||
vi.mock(
|
||||
'@/renderer/extensions/vueNodes/widgets/composables/useColorWidget',
|
||||
() => ({ useColorWidget: v2WidgetConstructor('COLOR') })
|
||||
)
|
||||
vi.mock(
|
||||
'@/renderer/extensions/vueNodes/widgets/composables/useComboWidget',
|
||||
() => ({ useComboWidget: v2WidgetConstructor('COMBO') })
|
||||
)
|
||||
vi.mock(
|
||||
'@/renderer/extensions/vueNodes/widgets/composables/useFloatWidget',
|
||||
() => ({ useFloatWidget: v2WidgetConstructor('FLOAT') })
|
||||
)
|
||||
vi.mock(
|
||||
'@/renderer/extensions/vueNodes/widgets/composables/useGalleriaWidget',
|
||||
() => ({ useGalleriaWidget: v2WidgetConstructor('GALLERIA') })
|
||||
)
|
||||
vi.mock(
|
||||
'@/renderer/extensions/vueNodes/widgets/composables/useBoundingBoxesWidget',
|
||||
() => ({ useBoundingBoxesWidget: v2WidgetConstructor('BOUNDING_BOXES') })
|
||||
)
|
||||
vi.mock(
|
||||
'@/renderer/extensions/vueNodes/widgets/composables/useColorsWidget',
|
||||
() => ({ useColorsWidget: v2WidgetConstructor('COLORS') })
|
||||
)
|
||||
vi.mock(
|
||||
'@/renderer/extensions/vueNodes/widgets/composables/useImageCompareWidget',
|
||||
() => ({ useImageCompareWidget: v2WidgetConstructor('IMAGECOMPARE') })
|
||||
)
|
||||
vi.mock(
|
||||
'@/renderer/extensions/vueNodes/widgets/composables/useImageUploadWidget',
|
||||
() => ({
|
||||
useImageUploadWidget: () => (_node: LGraphNode, inputName: string) => ({
|
||||
widget: { name: `IMAGEUPLOAD:${inputName}`, options: {} },
|
||||
minWidth: 5,
|
||||
minHeight: 6
|
||||
})
|
||||
})
|
||||
)
|
||||
vi.mock(
|
||||
'@/renderer/extensions/vueNodes/widgets/composables/useIntWidget',
|
||||
() => ({ useIntWidget: v2WidgetConstructor('INT') })
|
||||
)
|
||||
vi.mock(
|
||||
'@/renderer/extensions/vueNodes/widgets/composables/useMarkdownWidget',
|
||||
() => ({ useMarkdownWidget: v2WidgetConstructor('MARKDOWN') })
|
||||
)
|
||||
vi.mock(
|
||||
'@/renderer/extensions/vueNodes/widgets/composables/usePainterWidget',
|
||||
() => ({ usePainterWidget: v2WidgetConstructor('PAINTER') })
|
||||
)
|
||||
vi.mock(
|
||||
'@/renderer/extensions/vueNodes/widgets/composables/useRangeWidget',
|
||||
() => ({ useRangeWidget: v2WidgetConstructor('RANGE') })
|
||||
)
|
||||
vi.mock(
|
||||
'@/renderer/extensions/vueNodes/widgets/composables/useStringWidget',
|
||||
() => ({ useStringWidget: v2WidgetConstructor('STRING') })
|
||||
)
|
||||
vi.mock(
|
||||
'@/renderer/extensions/vueNodes/widgets/composables/useTextareaWidget',
|
||||
() => ({ useTextareaWidget: v2WidgetConstructor('TEXTAREA') })
|
||||
)
|
||||
|
||||
vi.mock('./domWidget', () => ({}))
|
||||
vi.mock('./errorNodeWidgets', () => ({}))
|
||||
|
||||
import {
|
||||
ComfyWidgets,
|
||||
IS_CONTROL_WIDGET,
|
||||
addValueControlWidget,
|
||||
addValueControlWidgets,
|
||||
isValidWidgetType,
|
||||
updateControlWidgetLabel
|
||||
} from './widgets'
|
||||
|
||||
// `linkedWidgets`, `beforeQueued`, and `afterQueued` already exist on
|
||||
// IBaseWidget (via the litegraph augmentation), so no extra members needed.
|
||||
type MockWidget = IBaseWidget
|
||||
|
||||
function makeTargetWidget(overrides: Partial<MockWidget> = {}): MockWidget {
|
||||
return {
|
||||
name: 'seed',
|
||||
value: 1,
|
||||
callback: vi.fn(),
|
||||
options: {},
|
||||
linkedWidgets: [],
|
||||
computedDisabled: false,
|
||||
...overrides
|
||||
} as MockWidget
|
||||
}
|
||||
|
||||
function makeNode(inputs: LGraphNode['inputs'] = []) {
|
||||
const widgets: MockWidget[] = []
|
||||
const node = Object.assign(fromPartial<LGraphNode>({}), {
|
||||
id: 42,
|
||||
inputs,
|
||||
addWidget: vi.fn(
|
||||
(
|
||||
type: string,
|
||||
name: string,
|
||||
value: string,
|
||||
callback: () => void,
|
||||
options: Record<string, unknown>
|
||||
) => {
|
||||
const widget: MockWidget = fromPartial<MockWidget>({
|
||||
type,
|
||||
name,
|
||||
value,
|
||||
callback,
|
||||
options,
|
||||
linkedWidgets: [],
|
||||
computedDisabled: false
|
||||
})
|
||||
widgets.push(widget)
|
||||
return widget
|
||||
}
|
||||
)
|
||||
})
|
||||
return { node, widgets }
|
||||
}
|
||||
|
||||
describe('widgets', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockSettingGet.mockReturnValue('after')
|
||||
mockNextValueForLinkedTarget.mockReturnValue('next')
|
||||
mockIsComboWidget.mockImplementation(
|
||||
(widget: MockWidget) => widget.type === 'combo'
|
||||
)
|
||||
mockTransformInputSpecV1ToV2.mockImplementation(
|
||||
(_inputData: InputSpec, options: { name: string }) => ({
|
||||
name: options.name
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('updates the control widget label from the configured run mode', () => {
|
||||
const widget = makeTargetWidget()
|
||||
|
||||
mockSettingGet.mockReturnValue('before')
|
||||
updateControlWidgetLabel(widget)
|
||||
expect(widget.label).toBe('translated:g.control_before_generate')
|
||||
|
||||
mockSettingGet.mockReturnValue('after')
|
||||
updateControlWidgetLabel(widget)
|
||||
expect(widget.label).toBe('translated:g.control_after_generate')
|
||||
})
|
||||
|
||||
it('adds control and filter widgets for combo targets', () => {
|
||||
const { node, widgets } = makeNode()
|
||||
const target = makeTargetWidget({ type: 'combo', computedDisabled: true })
|
||||
|
||||
const result = addValueControlWidgets(node, target, '', undefined, [
|
||||
'COMBO',
|
||||
{
|
||||
control_prefix: 'custom'
|
||||
}
|
||||
] as InputSpec)
|
||||
|
||||
expect(result).toHaveLength(2)
|
||||
expect(widgets[0].name).toBe('custom control_after_generate')
|
||||
expect(widgets[0].value).toBe('randomize')
|
||||
expect((widgets[0] as IComboWidget).options.values).toContain(
|
||||
'increment-wrap'
|
||||
)
|
||||
expect(widgets[0][IS_CONTROL_WIDGET]).toBe(true)
|
||||
expect(widgets[0].disabled).toBe(true)
|
||||
expect(widgets[1].name).toBe('custom control_filter_list')
|
||||
expect(widgets[1].disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('uses explicit option names and can skip the combo filter widget', () => {
|
||||
const { node, widgets } = makeNode()
|
||||
const target = makeTargetWidget({ type: 'combo' })
|
||||
|
||||
addValueControlWidgets(
|
||||
node,
|
||||
target,
|
||||
'fixed',
|
||||
{
|
||||
addFilterList: false,
|
||||
controlAfterGenerateName: 'mode'
|
||||
},
|
||||
['COMBO', {}] as InputSpec
|
||||
)
|
||||
|
||||
expect(widgets).toHaveLength(1)
|
||||
expect(widgets[0].name).toBe('mode')
|
||||
})
|
||||
|
||||
it('applies linked target values after queueing in after mode', () => {
|
||||
const { node, widgets } = makeNode()
|
||||
const target = makeTargetWidget()
|
||||
|
||||
addValueControlWidgets(node, target)
|
||||
widgets[0].afterQueued?.({ isPartialExecution: true })
|
||||
|
||||
expect(mockNextValueForLinkedTarget).toHaveBeenCalledWith({
|
||||
target,
|
||||
linkedWidgets: target.linkedWidgets,
|
||||
nodeId: 42,
|
||||
isPartialExecution: true
|
||||
})
|
||||
expect(target.value).toBe('next')
|
||||
expect(target.callback).toHaveBeenCalledWith('next')
|
||||
})
|
||||
|
||||
it('waits until the second beforeQueued call in before mode', () => {
|
||||
mockSettingGet.mockReturnValue('before')
|
||||
const { node, widgets } = makeNode()
|
||||
const target = makeTargetWidget()
|
||||
|
||||
addValueControlWidgets(node, target)
|
||||
widgets[0].beforeQueued?.()
|
||||
expect(mockNextValueForLinkedTarget).not.toHaveBeenCalled()
|
||||
|
||||
widgets[0].beforeQueued?.({ isPartialExecution: false })
|
||||
expect(mockNextValueForLinkedTarget).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ isPartialExecution: false })
|
||||
)
|
||||
})
|
||||
|
||||
it('does not change the target when the target has a linked input or no next value', () => {
|
||||
const { node, widgets } = makeNode([
|
||||
{ widget: { name: 'seed' }, link: 1 }
|
||||
] as LGraphNode['inputs'])
|
||||
const target = makeTargetWidget()
|
||||
|
||||
addValueControlWidgets(node, target)
|
||||
widgets[0].afterQueued?.()
|
||||
expect(mockNextValueForLinkedTarget).not.toHaveBeenCalled()
|
||||
|
||||
const unlinked = makeNode()
|
||||
mockNextValueForLinkedTarget.mockReturnValue(undefined)
|
||||
addValueControlWidgets(unlinked.node, target)
|
||||
unlinked.widgets[0].afterQueued?.()
|
||||
expect(target.callback).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses the legacy single control widget name from input data before widgetName', () => {
|
||||
const { node, widgets } = makeNode()
|
||||
const target = makeTargetWidget()
|
||||
|
||||
const result = addValueControlWidget(
|
||||
node,
|
||||
target,
|
||||
'fixed',
|
||||
undefined,
|
||||
'fallback',
|
||||
[
|
||||
'INT',
|
||||
{
|
||||
control_after_generate: 'from_input_data'
|
||||
}
|
||||
] as InputSpec
|
||||
)
|
||||
|
||||
expect(result).toBe(widgets[0])
|
||||
expect(widgets[0].name).toBe('from_input_data')
|
||||
})
|
||||
|
||||
it('exposes transformed widget constructors and type validation', () => {
|
||||
const { node } = makeNode()
|
||||
|
||||
const intWidget = ComfyWidgets.INT(
|
||||
node,
|
||||
'value',
|
||||
['INT', {}] as InputSpec,
|
||||
fromPartial<ComfyApp>({})
|
||||
)
|
||||
|
||||
expect(intWidget.widget.name).toBe('INT:value')
|
||||
expect(intWidget.minWidth).toBe(20)
|
||||
expect(intWidget.minHeight).toBe(30)
|
||||
expect(
|
||||
ComfyWidgets.IMAGEUPLOAD(
|
||||
node,
|
||||
'image',
|
||||
['IMAGE', {}],
|
||||
fromPartial<ComfyApp>({})
|
||||
)
|
||||
).toMatchObject({
|
||||
widget: { name: 'IMAGEUPLOAD:image' },
|
||||
minWidth: 5,
|
||||
minHeight: 6
|
||||
})
|
||||
expect(isValidWidgetType('INT')).toBe(true)
|
||||
expect(isValidWidgetType('DYNAMIC')).toBe(true)
|
||||
expect(isValidWidgetType('missing')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,3 @@
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useAudioService } from '@/services/audioService'
|
||||
@@ -39,12 +38,6 @@ describe('useAudioService', () => {
|
||||
name: 'test-audio-123.wav'
|
||||
}
|
||||
|
||||
async function freshService() {
|
||||
vi.resetModules()
|
||||
const audioServiceModule = await import('@/services/audioService')
|
||||
return audioServiceModule.useAudioService()
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
@@ -103,44 +96,15 @@ describe('useAudioService', () => {
|
||||
expect(mockRegister).toHaveBeenCalledTimes(0)
|
||||
expect(console.error).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should log encoder registration errors', async () => {
|
||||
const error = new Error('Encoder failed')
|
||||
mockRegister.mockReset()
|
||||
mockRegister.mockRejectedValueOnce(error)
|
||||
|
||||
const isolatedService = await freshService()
|
||||
await isolatedService.registerWavEncoder()
|
||||
|
||||
expect(console.error).toHaveBeenCalledWith(
|
||||
'Audio Service Error (encoder):',
|
||||
'Failed to register WAV encoder',
|
||||
error
|
||||
)
|
||||
})
|
||||
|
||||
it('should log non-Error encoder registration failures', async () => {
|
||||
mockRegister.mockReset()
|
||||
mockRegister.mockRejectedValueOnce('Encoder failed')
|
||||
|
||||
const isolatedService = await freshService()
|
||||
await isolatedService.registerWavEncoder()
|
||||
|
||||
expect(console.error).toHaveBeenCalledWith(
|
||||
'Audio Service Error (encoder):',
|
||||
'Failed to register WAV encoder',
|
||||
'Encoder failed'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('stopAllTracks', () => {
|
||||
it('should stop all tracks in a stream', () => {
|
||||
const mockTrack1 = { stop: vi.fn() }
|
||||
const mockTrack2 = { stop: vi.fn() }
|
||||
const mockStream = fromPartial<MediaStream>({
|
||||
const mockStream = {
|
||||
getTracks: vi.fn().mockReturnValue([mockTrack1, mockTrack2])
|
||||
})
|
||||
} as Partial<MediaStream> as MediaStream
|
||||
|
||||
service.stopAllTracks(mockStream)
|
||||
|
||||
@@ -154,9 +118,9 @@ describe('useAudioService', () => {
|
||||
})
|
||||
|
||||
it('should handle stream with no tracks', () => {
|
||||
const mockStream = fromPartial<MediaStream>({
|
||||
const mockStream = {
|
||||
getTracks: vi.fn().mockReturnValue([])
|
||||
})
|
||||
} as Partial<MediaStream> as MediaStream
|
||||
|
||||
expect(() => service.stopAllTracks(mockStream)).not.toThrow()
|
||||
expect(mockStream.getTracks).toHaveBeenCalledTimes(1)
|
||||
@@ -169,9 +133,9 @@ describe('useAudioService', () => {
|
||||
throw new Error('Stop failed')
|
||||
})
|
||||
}
|
||||
const mockStream = fromPartial<MediaStream>({
|
||||
const mockStream = {
|
||||
getTracks: vi.fn().mockReturnValue([mockTrack1, mockTrack2])
|
||||
})
|
||||
} as Partial<MediaStream> as MediaStream
|
||||
|
||||
expect(() => service.stopAllTracks(mockStream)).toThrow()
|
||||
expect(mockTrack1.stop).toHaveBeenCalledTimes(1)
|
||||
|
||||
@@ -1,118 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { setupAutoQueueHandler } from '@/services/autoQueueService'
|
||||
|
||||
type ApiEvent = 'graphChanged'
|
||||
type ApiListener = () => void
|
||||
type Subscription = () => Promise<void> | void
|
||||
|
||||
const {
|
||||
listeners,
|
||||
queueCountStore,
|
||||
queueSettingsStore,
|
||||
appState,
|
||||
addEventListener,
|
||||
isInstantRunningMode
|
||||
} = vi.hoisted(() => ({
|
||||
listeners: new Map<ApiEvent, ApiListener>(),
|
||||
queueCountStore: {
|
||||
count: 0,
|
||||
subscription: undefined as Subscription | undefined,
|
||||
$subscribe: vi.fn((_callback: Subscription) => {
|
||||
queueCountStore.subscription = _callback
|
||||
})
|
||||
},
|
||||
queueSettingsStore: {
|
||||
mode: 'manual',
|
||||
batchCount: 1
|
||||
},
|
||||
appState: {
|
||||
lastExecutionError: null as unknown,
|
||||
queuePrompt: vi.fn()
|
||||
},
|
||||
addEventListener: vi.fn((event: ApiEvent, listener: ApiListener) => {
|
||||
listeners.set(event, listener)
|
||||
}),
|
||||
isInstantRunningMode: vi.fn((mode: string) => mode === 'instant')
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: { addEventListener }
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: appState
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/queueStore', () => ({
|
||||
isInstantRunningMode,
|
||||
useQueuePendingTaskCountStore: () => queueCountStore,
|
||||
useQueueSettingsStore: () => queueSettingsStore
|
||||
}))
|
||||
|
||||
beforeEach(() => {
|
||||
listeners.clear()
|
||||
queueCountStore.count = 0
|
||||
queueCountStore.subscription = undefined
|
||||
queueCountStore.$subscribe.mockClear()
|
||||
queueSettingsStore.mode = 'manual'
|
||||
queueSettingsStore.batchCount = 1
|
||||
appState.lastExecutionError = null
|
||||
appState.queuePrompt.mockReset().mockResolvedValue(undefined)
|
||||
addEventListener.mockClear()
|
||||
isInstantRunningMode
|
||||
.mockClear()
|
||||
.mockImplementation((mode) => mode === 'instant')
|
||||
})
|
||||
|
||||
describe('setupAutoQueueHandler', () => {
|
||||
it('queues immediately on graph changes when change mode is idle', () => {
|
||||
queueSettingsStore.mode = 'change'
|
||||
queueSettingsStore.batchCount = 3
|
||||
|
||||
setupAutoQueueHandler()
|
||||
listeners.get('graphChanged')?.()
|
||||
|
||||
expect(appState.queuePrompt).toHaveBeenCalledWith(0, 3)
|
||||
})
|
||||
|
||||
it('queues after pending work drains in instant mode', async () => {
|
||||
queueSettingsStore.mode = 'instant'
|
||||
queueSettingsStore.batchCount = 2
|
||||
queueCountStore.count = 0
|
||||
|
||||
setupAutoQueueHandler()
|
||||
await queueCountStore.subscription?.()
|
||||
|
||||
expect(appState.queuePrompt).toHaveBeenCalledWith(0, 2)
|
||||
})
|
||||
|
||||
it('queues after a changed graph drains from an active queue', async () => {
|
||||
queueSettingsStore.mode = 'change'
|
||||
queueCountStore.count = 1
|
||||
|
||||
setupAutoQueueHandler()
|
||||
await queueCountStore.subscription?.()
|
||||
listeners.get('graphChanged')?.()
|
||||
expect(appState.queuePrompt).not.toHaveBeenCalled()
|
||||
|
||||
queueCountStore.count = 0
|
||||
await queueCountStore.subscription?.()
|
||||
|
||||
expect(appState.queuePrompt).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not requeue while work remains or the last run failed', async () => {
|
||||
queueSettingsStore.mode = 'instant'
|
||||
queueCountStore.count = 1
|
||||
|
||||
setupAutoQueueHandler()
|
||||
await queueCountStore.subscription?.()
|
||||
|
||||
appState.lastExecutionError = { message: 'failed' }
|
||||
queueCountStore.count = 0
|
||||
await queueCountStore.subscription?.()
|
||||
|
||||
expect(appState.queuePrompt).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,363 +0,0 @@
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { DEFAULT_DARK_COLOR_PALETTE } from '@/constants/coreColorPalettes'
|
||||
import {
|
||||
LGraphCanvas,
|
||||
LiteGraph,
|
||||
RenderShape
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import type { CompletedPalette, Palette } from '@/schemas/colorPaletteSchema'
|
||||
|
||||
const mockCanvas = vi.hoisted(() => ({
|
||||
default_connection_color_byType: {} as Record<string, string>,
|
||||
node_title_color: '',
|
||||
default_link_color: '',
|
||||
background_image: '',
|
||||
clear_background_color: '',
|
||||
_pattern: 'pattern' as string | undefined,
|
||||
setDirty: vi.fn()
|
||||
}))
|
||||
|
||||
const mockColorPaletteStore = vi.hoisted(() => ({
|
||||
customPalettes: {} as Record<string, unknown>,
|
||||
palettesLookup: {} as Record<string, unknown>,
|
||||
completedActivePalette: undefined as unknown,
|
||||
activePaletteId: 'dark',
|
||||
addCustomPalette: vi.fn(),
|
||||
deleteCustomPalette: vi.fn(),
|
||||
completePalette: vi.fn()
|
||||
}))
|
||||
|
||||
const mockSettingStore = vi.hoisted(() => ({
|
||||
get: vi.fn(),
|
||||
set: vi.fn()
|
||||
}))
|
||||
|
||||
const mockNodeDefStore = vi.hoisted(() => ({
|
||||
nodeDataTypes: new Set(['IMAGE', 'MISSING'])
|
||||
}))
|
||||
|
||||
const mockDownloadBlob = vi.hoisted(() => vi.fn())
|
||||
const mockUploadFile = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: { canvas: mockCanvas }
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/workspace/colorPaletteStore', () => ({
|
||||
useColorPaletteStore: () => mockColorPaletteStore
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => mockSettingStore
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/nodeDefStore', () => ({
|
||||
useNodeDefStore: () => mockNodeDefStore
|
||||
}))
|
||||
|
||||
vi.mock('@/base/common/downloadUtil', () => ({
|
||||
downloadBlob: mockDownloadBlob
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/utils', () => ({
|
||||
uploadFile: mockUploadFile
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useErrorHandling', () => ({
|
||||
useErrorHandling: () => ({
|
||||
wrapWithErrorHandling: <T>(action: T) => action,
|
||||
wrapWithErrorHandlingAsync: <T>(action: T) => action
|
||||
})
|
||||
}))
|
||||
|
||||
import { useColorPaletteService } from './colorPaletteService'
|
||||
|
||||
const validCustomPalette = {
|
||||
id: 'custom',
|
||||
name: 'Custom',
|
||||
colors: {
|
||||
node_slot: {},
|
||||
litegraph_base: {},
|
||||
comfy_base: {}
|
||||
}
|
||||
} satisfies Palette
|
||||
|
||||
function makeCompletedPalette(id = 'custom'): CompletedPalette {
|
||||
const palette = structuredClone(
|
||||
DEFAULT_DARK_COLOR_PALETTE
|
||||
) as CompletedPalette
|
||||
palette.id = id
|
||||
palette.name = 'Custom'
|
||||
palette.colors.node_slot.IMAGE = '#123456'
|
||||
palette.colors.litegraph_base.NODE_TITLE_COLOR = '#abcdef'
|
||||
palette.colors.litegraph_base.LINK_COLOR = '#fedcba'
|
||||
palette.colors.litegraph_base.BACKGROUND_IMAGE = 'grid.png'
|
||||
palette.colors.litegraph_base.CLEAR_BACKGROUND_COLOR = '#010203'
|
||||
palette.colors.litegraph_base.NODE_DEFAULT_SHAPE = 'legacy'
|
||||
palette.colors.comfy_base['fg-color'] = '#111111'
|
||||
palette.colors.comfy_base['bg-color'] = '#222222'
|
||||
delete palette.colors.comfy_base['contrast-mix-color']
|
||||
return palette
|
||||
}
|
||||
|
||||
describe('useColorPaletteService', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockCanvas.default_connection_color_byType = {}
|
||||
mockCanvas.node_title_color = ''
|
||||
mockCanvas.default_link_color = ''
|
||||
mockCanvas.background_image = ''
|
||||
mockCanvas.clear_background_color = ''
|
||||
mockCanvas._pattern = 'pattern'
|
||||
LGraphCanvas.link_type_colors = {}
|
||||
mockSettingStore.get.mockReturnValue('')
|
||||
mockSettingStore.set.mockResolvedValue(undefined)
|
||||
mockColorPaletteStore.customPalettes = { custom: validCustomPalette }
|
||||
mockColorPaletteStore.palettesLookup = { custom: validCustomPalette }
|
||||
mockColorPaletteStore.completedActivePalette = makeCompletedPalette()
|
||||
mockColorPaletteStore.activePaletteId = 'dark'
|
||||
mockColorPaletteStore.completePalette.mockReturnValue(
|
||||
makeCompletedPalette()
|
||||
)
|
||||
document.documentElement.style.cssText = ''
|
||||
document.documentElement.style.setProperty(
|
||||
'--color-datatype-MISSING',
|
||||
'#ffffff'
|
||||
)
|
||||
})
|
||||
|
||||
it('adds valid custom palettes and persists the custom palette map', async () => {
|
||||
const service = useColorPaletteService()
|
||||
|
||||
await service.addCustomColorPalette(validCustomPalette)
|
||||
|
||||
expect(mockColorPaletteStore.addCustomPalette).toHaveBeenCalledWith(
|
||||
validCustomPalette
|
||||
)
|
||||
expect(mockSettingStore.set).toHaveBeenCalledWith(
|
||||
'Comfy.CustomColorPalettes',
|
||||
mockColorPaletteStore.customPalettes
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects invalid custom palettes before mutating the store', async () => {
|
||||
const service = useColorPaletteService()
|
||||
|
||||
await expect(service.addCustomColorPalette({} as Palette)).rejects.toThrow(
|
||||
'Invalid color palette against zod schema'
|
||||
)
|
||||
expect(mockColorPaletteStore.addCustomPalette).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('deletes custom palettes and persists the custom palette map', async () => {
|
||||
const service = useColorPaletteService()
|
||||
|
||||
await service.deleteCustomColorPalette('custom')
|
||||
|
||||
expect(mockColorPaletteStore.deleteCustomPalette).toHaveBeenCalledWith(
|
||||
'custom'
|
||||
)
|
||||
expect(mockSettingStore.set).toHaveBeenCalledWith(
|
||||
'Comfy.CustomColorPalettes',
|
||||
mockColorPaletteStore.customPalettes
|
||||
)
|
||||
})
|
||||
|
||||
it('loads palette colors into litegraph, Vue CSS variables, and canvas state', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const service = useColorPaletteService()
|
||||
|
||||
await service.loadColorPalette('custom')
|
||||
|
||||
expect(mockCanvas.default_connection_color_byType.IMAGE).toBe('#123456')
|
||||
expect(LGraphCanvas.link_type_colors.IMAGE).toBe('#123456')
|
||||
expect(
|
||||
document.documentElement.style.getPropertyValue('--color-datatype-IMAGE')
|
||||
).toBe('#123456')
|
||||
expect(
|
||||
document.documentElement.style.getPropertyValue(
|
||||
'--color-datatype-MISSING'
|
||||
)
|
||||
).toBe('')
|
||||
expect(mockCanvas.node_title_color).toBe('#abcdef')
|
||||
expect(mockCanvas.default_link_color).toBe('#fedcba')
|
||||
expect(mockCanvas.background_image).toBe('grid.png')
|
||||
expect(mockCanvas.clear_background_color).toBe('#010203')
|
||||
expect(mockCanvas._pattern).toBeUndefined()
|
||||
expect(LiteGraph.NODE_DEFAULT_SHAPE).toBe(RenderShape.ROUND)
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
`litegraph_base.NODE_DEFAULT_SHAPE only accepts [${[
|
||||
RenderShape.BOX,
|
||||
RenderShape.ROUND,
|
||||
RenderShape.CARD
|
||||
].join(', ')}] but got legacy`
|
||||
)
|
||||
expect(document.documentElement.style.getPropertyValue('--fg-color')).toBe(
|
||||
'#111111'
|
||||
)
|
||||
expect(
|
||||
document.documentElement.style.getPropertyValue('--contrast-mix-color')
|
||||
).toBe('var(--palette-contrast-mix-color)')
|
||||
expect(mockCanvas.setDirty).toHaveBeenCalledWith(true, true)
|
||||
expect(mockColorPaletteStore.activePaletteId).toBe('custom')
|
||||
})
|
||||
|
||||
it('skips absent palette sections while still activating the palette', async () => {
|
||||
const completedPalette = makeCompletedPalette()
|
||||
mockColorPaletteStore.completePalette.mockReturnValue(
|
||||
fromPartial<CompletedPalette>({
|
||||
...completedPalette,
|
||||
colors: {
|
||||
node_slot: undefined,
|
||||
litegraph_base: completedPalette.colors.litegraph_base,
|
||||
comfy_base: undefined
|
||||
}
|
||||
})
|
||||
)
|
||||
const service = useColorPaletteService()
|
||||
|
||||
await service.loadColorPalette('custom')
|
||||
|
||||
expect(mockCanvas.node_title_color).toBe('#abcdef')
|
||||
expect(mockCanvas.setDirty).toHaveBeenCalledWith(true, true)
|
||||
expect(mockColorPaletteStore.activePaletteId).toBe('custom')
|
||||
})
|
||||
|
||||
it('removes Vue node theme overrides for built-in palettes', async () => {
|
||||
mockColorPaletteStore.palettesLookup = { dark: validCustomPalette }
|
||||
mockColorPaletteStore.completePalette.mockReturnValue(
|
||||
makeCompletedPalette('dark')
|
||||
)
|
||||
document.documentElement.style.setProperty(
|
||||
'--component-node-border',
|
||||
'#ffffff'
|
||||
)
|
||||
const service = useColorPaletteService()
|
||||
|
||||
await service.loadColorPalette('dark')
|
||||
|
||||
expect(
|
||||
document.documentElement.style.getPropertyValue('--component-node-border')
|
||||
).toBe('')
|
||||
})
|
||||
|
||||
it('removes Vue node theme variables when completed palette values are absent', async () => {
|
||||
const completedPalette = makeCompletedPalette()
|
||||
// NODE_BOX_OUTLINE_COLOR is required on the completed palette type; the
|
||||
// test needs it absent, so delete via Reflect to keep the type intact.
|
||||
Reflect.deleteProperty(
|
||||
completedPalette.colors.litegraph_base,
|
||||
'NODE_BOX_OUTLINE_COLOR'
|
||||
)
|
||||
mockColorPaletteStore.completePalette.mockReturnValue(completedPalette)
|
||||
document.documentElement.style.setProperty(
|
||||
'--component-node-border',
|
||||
'#ffffff'
|
||||
)
|
||||
const service = useColorPaletteService()
|
||||
|
||||
await service.loadColorPalette('custom')
|
||||
|
||||
expect(
|
||||
document.documentElement.style.getPropertyValue('--component-node-border')
|
||||
).toBe('')
|
||||
})
|
||||
|
||||
it('preserves numeric LiteGraph node shapes without warning', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const completedPalette = makeCompletedPalette()
|
||||
completedPalette.colors.litegraph_base.NODE_DEFAULT_SHAPE = RenderShape.CARD
|
||||
mockColorPaletteStore.completePalette.mockReturnValue(completedPalette)
|
||||
const service = useColorPaletteService()
|
||||
|
||||
await service.loadColorPalette('custom')
|
||||
|
||||
expect(LiteGraph.NODE_DEFAULT_SHAPE).toBe(RenderShape.CARD)
|
||||
expect(warn).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses explicit optional comfy color values when present', async () => {
|
||||
const completedPalette = makeCompletedPalette()
|
||||
completedPalette.colors.comfy_base['contrast-mix-color'] = '#333333'
|
||||
mockColorPaletteStore.completePalette.mockReturnValue(completedPalette)
|
||||
const service = useColorPaletteService()
|
||||
|
||||
await service.loadColorPalette('custom')
|
||||
|
||||
expect(
|
||||
document.documentElement.style.getPropertyValue('--contrast-mix-color')
|
||||
).toBe('#333333')
|
||||
})
|
||||
|
||||
it('uses a white splash background for light themes', async () => {
|
||||
const completedPalette = makeCompletedPalette()
|
||||
completedPalette.light_theme = true
|
||||
mockColorPaletteStore.completePalette.mockReturnValue(completedPalette)
|
||||
const service = useColorPaletteService()
|
||||
|
||||
await service.loadColorPalette('custom')
|
||||
|
||||
expect(localStorage.getItem('comfy-splash-bg')).toBe('#FFFFFF')
|
||||
expect(localStorage.getItem('comfy-splash-fg')).toBe('#111111')
|
||||
})
|
||||
|
||||
it('uses transparent canvas background and bg image CSS when a background image setting exists', async () => {
|
||||
mockSettingStore.get.mockReturnValue('/custom/background.png')
|
||||
const service = useColorPaletteService()
|
||||
|
||||
await service.loadColorPalette('custom')
|
||||
|
||||
expect(mockCanvas.clear_background_color).toBe('transparent')
|
||||
expect(document.documentElement.style.getPropertyValue('--bg-img')).toBe(
|
||||
"url('/custom/background.png')"
|
||||
)
|
||||
})
|
||||
|
||||
it('throws when loading or exporting an unknown palette', async () => {
|
||||
mockColorPaletteStore.palettesLookup = {}
|
||||
const service = useColorPaletteService()
|
||||
|
||||
await expect(service.loadColorPalette('missing')).rejects.toThrow(
|
||||
'Color palette missing not found'
|
||||
)
|
||||
expect(() => service.exportColorPalette('missing')).toThrow(
|
||||
'Color palette missing not found'
|
||||
)
|
||||
})
|
||||
|
||||
it('exports palette JSON by id', async () => {
|
||||
const service = useColorPaletteService()
|
||||
|
||||
service.exportColorPalette('custom')
|
||||
|
||||
expect(mockDownloadBlob).toHaveBeenCalledOnce()
|
||||
const [filename, blob] = mockDownloadBlob.mock.calls[0] as [string, Blob]
|
||||
expect(filename).toBe('custom.json')
|
||||
await expect(blob.text()).resolves.toContain('"id": "custom"')
|
||||
})
|
||||
|
||||
it('imports palette JSON through the custom palette path', async () => {
|
||||
mockUploadFile.mockResolvedValue({
|
||||
text: () => Promise.resolve(JSON.stringify(validCustomPalette))
|
||||
})
|
||||
const service = useColorPaletteService()
|
||||
|
||||
const palette = await service.importColorPalette()
|
||||
|
||||
expect(mockUploadFile).toHaveBeenCalledWith('application/json')
|
||||
expect(palette).toEqual(validCustomPalette)
|
||||
expect(mockColorPaletteStore.addCustomPalette).toHaveBeenCalledWith(
|
||||
validCustomPalette
|
||||
)
|
||||
})
|
||||
|
||||
it('returns the completed active palette from the store', () => {
|
||||
const service = useColorPaletteService()
|
||||
|
||||
expect(service.getActiveColorPalette()).toBe(
|
||||
mockColorPaletteStore.completedActivePalette
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,325 +0,0 @@
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { AuthUserInfo } from '@/types/authTypes'
|
||||
import type { ComfyExtension } from '@/types/comfy'
|
||||
import { useExtensionService } from './extensionService'
|
||||
|
||||
const mockLoadDisabledExtensionNames = vi.hoisted(() => vi.fn())
|
||||
const mockRegisterExtension = vi.hoisted(() => vi.fn())
|
||||
const mockCaptureCoreExtensions = vi.hoisted(() => vi.fn())
|
||||
const mockEnabledExtensions = vi.hoisted(() => ({
|
||||
value: [] as ComfyExtension[]
|
||||
}))
|
||||
vi.mock('@/stores/extensionStore', () => ({
|
||||
useExtensionStore: () => ({
|
||||
loadDisabledExtensionNames: mockLoadDisabledExtensionNames,
|
||||
registerExtension: mockRegisterExtension,
|
||||
captureCoreExtensions: mockCaptureCoreExtensions,
|
||||
get enabledExtensions() {
|
||||
return mockEnabledExtensions.value
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
const mockGetSetting = vi.hoisted(() => vi.fn())
|
||||
const mockAddSetting = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => ({
|
||||
get: mockGetSetting,
|
||||
addSetting: mockAddSetting
|
||||
})
|
||||
}))
|
||||
|
||||
const mockAddDefaultKeybinding = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/platform/keybindings/keybindingStore', () => ({
|
||||
useKeybindingStore: () => ({
|
||||
addDefaultKeybinding: mockAddDefaultKeybinding
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/keybindings/keybinding', () => ({
|
||||
KeybindingImpl: class KeybindingImpl {
|
||||
constructor(readonly source: unknown) {}
|
||||
}
|
||||
}))
|
||||
|
||||
const mockLoadExtensionCommands = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/stores/commandStore', () => ({
|
||||
useCommandStore: () => ({
|
||||
loadExtensionCommands: mockLoadExtensionCommands
|
||||
})
|
||||
}))
|
||||
|
||||
const mockLoadExtensionMenuCommands = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/stores/menuItemStore', () => ({
|
||||
useMenuItemStore: () => ({
|
||||
loadExtensionMenuCommands: mockLoadExtensionMenuCommands
|
||||
})
|
||||
}))
|
||||
|
||||
const mockRegisterBottomPanelTabs = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/stores/workspace/bottomPanelStore', () => ({
|
||||
useBottomPanelStore: () => ({
|
||||
registerExtensionBottomPanelTabs: mockRegisterBottomPanelTabs
|
||||
})
|
||||
}))
|
||||
|
||||
const mockRegisterCustomWidgets = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/stores/widgetStore', () => ({
|
||||
useWidgetStore: () => ({
|
||||
registerCustomWidgets: mockRegisterCustomWidgets
|
||||
})
|
||||
}))
|
||||
|
||||
const mockToastErrorHandler = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/composables/useErrorHandling', () => ({
|
||||
useErrorHandling: () => ({
|
||||
wrapWithErrorHandling:
|
||||
<Args extends unknown[], Return>(fn: (...args: Args) => Return) =>
|
||||
(...args: Args) =>
|
||||
fn(...args),
|
||||
wrapWithErrorHandlingAsync:
|
||||
<Args extends unknown[], Return>(
|
||||
fn: (...args: Args) => Return | Promise<Return>,
|
||||
handler: (error: unknown) => void
|
||||
) =>
|
||||
async (...args: Args) => {
|
||||
try {
|
||||
return await fn(...args)
|
||||
} catch (error) {
|
||||
handler(error)
|
||||
}
|
||||
},
|
||||
toastErrorHandler: mockToastErrorHandler
|
||||
})
|
||||
}))
|
||||
|
||||
const mockUserResolvedCallbacks = vi.hoisted(() => ({
|
||||
values: [] as Array<(user: AuthUserInfo) => void>
|
||||
}))
|
||||
const mockTokenRefreshedCallbacks = vi.hoisted(() => ({
|
||||
values: [] as Array<() => void>
|
||||
}))
|
||||
const mockUserLogoutCallbacks = vi.hoisted(() => ({
|
||||
values: [] as Array<() => void>
|
||||
}))
|
||||
vi.mock('@/composables/auth/useCurrentUser', () => ({
|
||||
useCurrentUser: () => ({
|
||||
onUserResolved: (callback: (user: AuthUserInfo) => void) => {
|
||||
mockUserResolvedCallbacks.values.push(callback)
|
||||
},
|
||||
onTokenRefreshed: (callback: () => void) => {
|
||||
mockTokenRefreshedCallbacks.values.push(callback)
|
||||
},
|
||||
onUserLogout: (callback: () => void) => {
|
||||
mockUserLogoutCallbacks.values.push(callback)
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
const mockSetCurrentExtension = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/lib/litegraph/src/contextMenuCompat', () => ({
|
||||
legacyMenuCompat: {
|
||||
setCurrentExtension: mockSetCurrentExtension
|
||||
}
|
||||
}))
|
||||
|
||||
const mockApp = vi.hoisted(() => ({ value: { name: 'app' } }))
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: mockApp.value
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
getExtensions: vi.fn(),
|
||||
fileURL: vi.fn((path: string) => path)
|
||||
}
|
||||
}))
|
||||
|
||||
describe('useExtensionService', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockEnabledExtensions.value = []
|
||||
mockUserResolvedCallbacks.values = []
|
||||
mockTokenRefreshedCallbacks.values = []
|
||||
mockUserLogoutCallbacks.values = []
|
||||
})
|
||||
|
||||
it('registers extension contributions across stores', async () => {
|
||||
const widgets = { CustomWidget: vi.fn() }
|
||||
const extension = Object.assign(fromPartial<ComfyExtension>({}), {
|
||||
name: 'registration-extension',
|
||||
keybindings: [{ commandId: 'command.one', combo: { key: 'K' } }],
|
||||
commands: [{ id: 'command.one', label: 'Command One' }],
|
||||
menuCommands: [{ path: ['File'], commands: ['command.one'] }],
|
||||
settings: [{ id: 'setting.one', name: 'Setting One' }],
|
||||
bottomPanelTabs: [{ id: 'tab.one', title: 'Tab One' }],
|
||||
getCustomWidgets: vi.fn().mockResolvedValue(widgets)
|
||||
})
|
||||
const service = useExtensionService()
|
||||
|
||||
service.registerExtension(extension)
|
||||
|
||||
expect(mockRegisterExtension).toHaveBeenCalledWith(extension)
|
||||
expect(mockAddDefaultKeybinding).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
source: { commandId: 'command.one', combo: { key: 'K' } }
|
||||
})
|
||||
)
|
||||
expect(mockLoadExtensionCommands).toHaveBeenCalledWith(extension)
|
||||
expect(mockLoadExtensionMenuCommands).toHaveBeenCalledWith(extension)
|
||||
expect(mockAddSetting.mock.calls[0][0]).toEqual({
|
||||
id: 'setting.one',
|
||||
name: 'Setting One'
|
||||
})
|
||||
expect(mockRegisterBottomPanelTabs).toHaveBeenCalledWith(extension)
|
||||
await vi.waitFor(() => {
|
||||
expect(mockRegisterCustomWidgets).toHaveBeenCalledWith(widgets)
|
||||
})
|
||||
})
|
||||
|
||||
it('invokes auth lifecycle hooks through registered callbacks', async () => {
|
||||
const onAuthUserResolved = vi.fn()
|
||||
const onAuthTokenRefreshed = vi.fn()
|
||||
const onAuthUserLogout = vi.fn()
|
||||
const extension = fromPartial<ComfyExtension>({
|
||||
name: 'auth-extension',
|
||||
onAuthUserResolved,
|
||||
onAuthTokenRefreshed,
|
||||
onAuthUserLogout
|
||||
})
|
||||
const user = fromPartial<AuthUserInfo>({ id: 'user-1' })
|
||||
const service = useExtensionService()
|
||||
|
||||
service.registerExtension(extension)
|
||||
mockUserResolvedCallbacks.values[0](user)
|
||||
mockTokenRefreshedCallbacks.values[0]()
|
||||
mockUserLogoutCallbacks.values[0]()
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(onAuthUserResolved).toHaveBeenCalledWith(user, mockApp.value)
|
||||
expect(onAuthTokenRefreshed).toHaveBeenCalled()
|
||||
expect(onAuthUserLogout).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('reports auth hook errors through the toast handler', async () => {
|
||||
const error = new Error('auth failed')
|
||||
const extension = fromPartial<ComfyExtension>({
|
||||
name: 'failing-auth-extension',
|
||||
onAuthUserResolved: vi.fn(() => {
|
||||
throw error
|
||||
})
|
||||
})
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const service = useExtensionService()
|
||||
|
||||
service.registerExtension(extension)
|
||||
mockUserResolvedCallbacks.values[0](
|
||||
fromPartial<AuthUserInfo>({ id: 'user-1' })
|
||||
)
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(mockToastErrorHandler).toHaveBeenCalledWith(error)
|
||||
})
|
||||
expect(consoleError).toHaveBeenCalledWith(
|
||||
'[Extension Auth Hook Error]',
|
||||
expect.objectContaining({
|
||||
extension: 'failing-auth-extension',
|
||||
hook: 'onAuthUserResolved',
|
||||
error
|
||||
})
|
||||
)
|
||||
consoleError.mockRestore()
|
||||
})
|
||||
|
||||
it('invokes synchronous extension methods and keeps failures isolated', () => {
|
||||
const getSelectionToolboxCommands = vi.fn(() => ['command.one'])
|
||||
const failingGetSelectionToolboxCommands = vi.fn(() => {
|
||||
throw new Error('menu failed')
|
||||
})
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
mockEnabledExtensions.value = [
|
||||
fromPartial<ComfyExtension>({
|
||||
name: 'working-extension',
|
||||
getSelectionToolboxCommands
|
||||
}),
|
||||
fromPartial<ComfyExtension>({
|
||||
name: 'non-function-extension',
|
||||
getSelectionToolboxCommands: ['not callable']
|
||||
}),
|
||||
fromPartial<ComfyExtension>({
|
||||
name: 'failing-extension',
|
||||
getSelectionToolboxCommands: failingGetSelectionToolboxCommands
|
||||
}),
|
||||
{ name: 'missing-method-extension' }
|
||||
]
|
||||
const service = useExtensionService()
|
||||
|
||||
const results = service.invokeExtensions(
|
||||
'getSelectionToolboxCommands',
|
||||
Object.assign(fromPartial<LGraphNode>({}), { id: toNodeId(1) })
|
||||
)
|
||||
|
||||
expect(results).toEqual([['command.one']])
|
||||
expect(getSelectionToolboxCommands).toHaveBeenCalledWith(
|
||||
Object.assign(fromPartial<LGraphNode>({}), { id: toNodeId(1) }),
|
||||
mockApp.value
|
||||
)
|
||||
expect(consoleError).toHaveBeenCalledWith(
|
||||
"Error calling extension 'failing-extension' method 'getSelectionToolboxCommands'",
|
||||
expect.objectContaining({ error: expect.any(Error) }),
|
||||
expect.objectContaining({
|
||||
extension: expect.objectContaining({ name: 'failing-extension' })
|
||||
}),
|
||||
expect.objectContaining({
|
||||
args: [Object.assign(fromPartial<LGraphNode>({}), { id: toNodeId(1) })]
|
||||
})
|
||||
)
|
||||
consoleError.mockRestore()
|
||||
})
|
||||
|
||||
it('tracks current extension around async setup callbacks', async () => {
|
||||
const setup = vi.fn().mockResolvedValue('setup-result')
|
||||
const failingSetup = vi.fn().mockRejectedValue(new Error('setup failed'))
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
mockEnabledExtensions.value = [
|
||||
fromPartial<ComfyExtension>({
|
||||
name: 'setup-extension',
|
||||
setup
|
||||
}),
|
||||
fromPartial<ComfyExtension>({
|
||||
name: 'non-function-extension',
|
||||
setup: true
|
||||
}),
|
||||
fromPartial<ComfyExtension>({
|
||||
name: 'failing-setup-extension',
|
||||
setup: failingSetup
|
||||
}),
|
||||
{ name: 'missing-method-extension' }
|
||||
]
|
||||
const service = useExtensionService()
|
||||
|
||||
const results = await service.invokeExtensionsAsync('setup')
|
||||
|
||||
expect(results).toEqual(['setup-result', undefined, undefined, undefined])
|
||||
expect(mockSetCurrentExtension.mock.calls.map((call) => call[0])).toEqual([
|
||||
'setup-extension',
|
||||
'failing-setup-extension',
|
||||
null,
|
||||
null
|
||||
])
|
||||
expect(consoleError).toHaveBeenCalledWith(
|
||||
"Error calling extension 'failing-setup-extension' method 'setup'",
|
||||
expect.objectContaining({ error: expect.any(Error) }),
|
||||
expect.objectContaining({
|
||||
extension: expect.objectContaining({ name: 'failing-setup-extension' })
|
||||
}),
|
||||
expect.objectContaining({ args: [] })
|
||||
)
|
||||
consoleError.mockRestore()
|
||||
})
|
||||
})
|
||||
@@ -1,16 +1,13 @@
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useMediaCache } from './mediaCacheService'
|
||||
|
||||
const NativeURL = URL
|
||||
|
||||
// Mock fetch
|
||||
global.fetch = vi.fn()
|
||||
global.URL = fromPartial<typeof URL>({
|
||||
global.URL = {
|
||||
createObjectURL: vi.fn(() => 'blob:mock-url'),
|
||||
revokeObjectURL: vi.fn()
|
||||
})
|
||||
} as Partial<typeof URL> as typeof URL
|
||||
|
||||
describe('mediaCacheService', () => {
|
||||
describe('URL reference counting', () => {
|
||||
@@ -36,180 +33,3 @@ describe('mediaCacheService', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
type MediaCache = ReturnType<typeof useMediaCache>
|
||||
|
||||
const mockFetch = vi.fn()
|
||||
const mockCreateObjectURL = vi.fn()
|
||||
const mockRevokeObjectURL = vi.fn()
|
||||
|
||||
class MockURL extends NativeURL {
|
||||
static override createObjectURL(blob: Blob): string {
|
||||
return mockCreateObjectURL(blob)
|
||||
}
|
||||
|
||||
static override revokeObjectURL(url: string): void {
|
||||
mockRevokeObjectURL(url)
|
||||
}
|
||||
}
|
||||
|
||||
function response(ok: boolean, blob = new Blob(['image'])): Response {
|
||||
return {
|
||||
ok,
|
||||
status: ok ? 200 : 404,
|
||||
blob: () => Promise.resolve(blob)
|
||||
} as Response
|
||||
}
|
||||
|
||||
async function freshCache(options?: {
|
||||
maxSize?: number
|
||||
maxAge?: number
|
||||
}): Promise<MediaCache> {
|
||||
vi.resetModules()
|
||||
const module = await import('./mediaCacheService')
|
||||
return module.useMediaCache(options)
|
||||
}
|
||||
|
||||
describe('useMediaCache', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(0)
|
||||
mockFetch.mockReset()
|
||||
mockCreateObjectURL.mockReset()
|
||||
mockRevokeObjectURL.mockReset()
|
||||
mockCreateObjectURL.mockImplementation(
|
||||
(_blob: Blob) => `blob:${mockCreateObjectURL.mock.calls.length}`
|
||||
)
|
||||
vi.stubGlobal('fetch', mockFetch)
|
||||
vi.stubGlobal('URL', MockURL)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
window.dispatchEvent(new Event('beforeunload'))
|
||||
vi.useRealTimers()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('fetches media once and returns cached entries on later reads', async () => {
|
||||
mockFetch.mockResolvedValue(response(true))
|
||||
const cache = await freshCache()
|
||||
|
||||
const first = await cache.getCachedMedia('/image.png')
|
||||
vi.setSystemTime(100)
|
||||
const second = await cache.getCachedMedia('/image.png')
|
||||
|
||||
expect(first).toMatchObject({
|
||||
src: '/image.png',
|
||||
objectUrl: 'blob:1',
|
||||
isLoading: false
|
||||
})
|
||||
expect(second).toEqual(first)
|
||||
expect(second.lastAccessed).toBe(100)
|
||||
expect(mockFetch).toHaveBeenCalledOnce()
|
||||
expect(mockFetch).toHaveBeenCalledWith('/image.png', {
|
||||
cache: 'force-cache'
|
||||
})
|
||||
})
|
||||
|
||||
it('stores an error entry when fetch fails', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
mockFetch.mockResolvedValue(response(false))
|
||||
const cache = await freshCache()
|
||||
|
||||
const entry = await cache.getCachedMedia('/missing.png')
|
||||
|
||||
expect(entry).toMatchObject({
|
||||
src: '/missing.png',
|
||||
error: true,
|
||||
isLoading: false
|
||||
})
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
'Failed to cache media:',
|
||||
'/missing.png',
|
||||
expect.any(Error)
|
||||
)
|
||||
})
|
||||
|
||||
it('ref-counts acquired object URLs and removes the cache entry on final release', async () => {
|
||||
mockFetch.mockResolvedValue(response(true))
|
||||
const cache = await freshCache()
|
||||
await cache.getCachedMedia('/image.png')
|
||||
|
||||
expect(cache.acquireUrl('/image.png')).toBe('blob:1')
|
||||
expect(cache.acquireUrl('/image.png')).toBe('blob:1')
|
||||
cache.releaseUrl('/image.png')
|
||||
expect(mockRevokeObjectURL).not.toHaveBeenCalled()
|
||||
expect(cache.cache.has('/image.png')).toBe(true)
|
||||
|
||||
cache.releaseUrl('/image.png')
|
||||
|
||||
expect(mockRevokeObjectURL).toHaveBeenCalledWith('blob:1')
|
||||
expect(cache.cache.has('/image.png')).toBe(false)
|
||||
})
|
||||
|
||||
it('returns undefined when acquiring a URL that is not cached', async () => {
|
||||
const cache = await freshCache()
|
||||
|
||||
expect(cache.acquireUrl('/missing.png')).toBeUndefined()
|
||||
cache.releaseUrl('/missing.png')
|
||||
expect(mockRevokeObjectURL).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('expires old cache entries during scheduled cleanup', async () => {
|
||||
mockFetch.mockResolvedValue(response(true))
|
||||
const cache = await freshCache({ maxAge: 100 })
|
||||
await cache.getCachedMedia('/old.png')
|
||||
|
||||
vi.setSystemTime(200)
|
||||
vi.advanceTimersByTime(5 * 60 * 1000)
|
||||
|
||||
expect(cache.cache.has('/old.png')).toBe(false)
|
||||
expect(mockRevokeObjectURL).toHaveBeenCalledWith('blob:1')
|
||||
})
|
||||
|
||||
it('keeps expired entries while their object URL is still acquired', async () => {
|
||||
mockFetch.mockResolvedValue(response(true))
|
||||
const cache = await freshCache({ maxAge: 100 })
|
||||
await cache.getCachedMedia('/held.png')
|
||||
cache.acquireUrl('/held.png')
|
||||
|
||||
vi.setSystemTime(200)
|
||||
vi.advanceTimersByTime(5 * 60 * 1000)
|
||||
|
||||
expect(cache.cache.has('/held.png')).toBe(true)
|
||||
expect(mockRevokeObjectURL).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('removes the oldest unused entries when the cache is over size', async () => {
|
||||
mockFetch.mockResolvedValue(response(true))
|
||||
const cache = await freshCache({ maxSize: 1, maxAge: 1_000_000 })
|
||||
await cache.getCachedMedia('/old.png')
|
||||
vi.setSystemTime(1)
|
||||
await cache.getCachedMedia('/new.png')
|
||||
|
||||
vi.advanceTimersByTime(5 * 60 * 1000)
|
||||
|
||||
expect(cache.cache.has('/old.png')).toBe(false)
|
||||
expect(cache.cache.has('/new.png')).toBe(true)
|
||||
expect(mockRevokeObjectURL).toHaveBeenCalledWith('blob:1')
|
||||
})
|
||||
|
||||
it('clears all cached URLs on demand and before unload', async () => {
|
||||
mockFetch.mockResolvedValue(response(true))
|
||||
const cache = await freshCache()
|
||||
await cache.getCachedMedia('/first.png')
|
||||
await cache.getCachedMedia('/second.png')
|
||||
|
||||
cache.clearCache()
|
||||
|
||||
expect(cache.cache.size).toBe(0)
|
||||
expect(mockRevokeObjectURL).toHaveBeenCalledWith('blob:1')
|
||||
expect(mockRevokeObjectURL).toHaveBeenCalledWith('blob:2')
|
||||
|
||||
await cache.getCachedMedia('/third.png')
|
||||
window.dispatchEvent(new Event('beforeunload'))
|
||||
|
||||
expect(cache.cache.size).toBe(0)
|
||||
expect(mockRevokeObjectURL).toHaveBeenCalledWith('blob:3')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,162 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { api } from '@/scripts/api'
|
||||
import { nodeHelpService } from '@/services/nodeHelpService'
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
fileURL: vi.fn((path: string) => `/files${path}`)
|
||||
}
|
||||
}))
|
||||
|
||||
describe('nodeHelpService', () => {
|
||||
const mockFetch = vi.fn<typeof fetch>()
|
||||
|
||||
function nodeDef(options: {
|
||||
name?: string
|
||||
python_module?: string
|
||||
description?: string
|
||||
}): ComfyNodeDefImpl {
|
||||
return {
|
||||
name: options.name ?? 'TestNode',
|
||||
display_name: options.name ?? 'Test Node',
|
||||
category: 'test',
|
||||
python_module: options.python_module ?? 'nodes',
|
||||
description: options.description ?? ''
|
||||
} as ComfyNodeDefImpl
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockFetch.mockReset()
|
||||
vi.stubGlobal('fetch', mockFetch)
|
||||
})
|
||||
|
||||
it('returns blueprint descriptions without fetching markdown', async () => {
|
||||
const help = await nodeHelpService.fetchNodeHelp(
|
||||
nodeDef({
|
||||
python_module: 'blueprint',
|
||||
description: 'Saved workflow help'
|
||||
}),
|
||||
'en'
|
||||
)
|
||||
|
||||
expect(help).toBe('Saved workflow help')
|
||||
expect(mockFetch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('fetches localized core node markdown', async () => {
|
||||
mockFetch.mockResolvedValueOnce(
|
||||
new Response('Core help', {
|
||||
headers: { 'content-type': 'text/markdown' }
|
||||
})
|
||||
)
|
||||
|
||||
const help = await nodeHelpService.fetchNodeHelp(
|
||||
nodeDef({ name: 'LoadImage' }),
|
||||
'zh'
|
||||
)
|
||||
|
||||
expect(api.fileURL).toHaveBeenCalledWith('/docs/LoadImage/zh.md')
|
||||
expect(mockFetch).toHaveBeenCalledWith('/files/docs/LoadImage/zh.md')
|
||||
expect(help).toBe('Core help')
|
||||
})
|
||||
|
||||
it('rejects core node HTML fallbacks', async () => {
|
||||
mockFetch.mockResolvedValueOnce(
|
||||
new Response('<html></html>', {
|
||||
headers: { 'content-type': 'text/html' },
|
||||
statusText: 'OK'
|
||||
})
|
||||
)
|
||||
|
||||
await expect(
|
||||
nodeHelpService.fetchNodeHelp(nodeDef({ name: 'PreviewImage' }), 'en')
|
||||
).rejects.toThrow('OK')
|
||||
})
|
||||
|
||||
it('uses the default missing-help error for empty markdown responses', async () => {
|
||||
mockFetch.mockResolvedValueOnce(new Response(''))
|
||||
|
||||
await expect(
|
||||
nodeHelpService.fetchNodeHelp(nodeDef({ name: 'EmptyHelp' }), 'en')
|
||||
).rejects.toThrow('Help not found')
|
||||
})
|
||||
|
||||
it('fetches custom node localized markdown before fallback markdown', async () => {
|
||||
mockFetch.mockResolvedValueOnce(
|
||||
new Response('Custom localized help', {
|
||||
headers: { 'content-type': 'text/markdown' }
|
||||
})
|
||||
)
|
||||
|
||||
const help = await nodeHelpService.fetchNodeHelp(
|
||||
nodeDef({
|
||||
name: 'CustomNode',
|
||||
python_module: 'custom_nodes.ComfyUI-TestPack@1.2.3.nodes'
|
||||
}),
|
||||
'ja'
|
||||
)
|
||||
|
||||
expect(api.fileURL).toHaveBeenCalledWith(
|
||||
'/extensions/ComfyUI-TestPack/docs/CustomNode/ja.md'
|
||||
)
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1)
|
||||
expect(help).toBe('Custom localized help')
|
||||
})
|
||||
|
||||
it('falls back to custom node default markdown after locale miss', async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(
|
||||
new Response('Not found', {
|
||||
status: 404,
|
||||
statusText: 'Locale missing'
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
new Response('Custom fallback help', {
|
||||
headers: { 'content-type': 'text/markdown' }
|
||||
})
|
||||
)
|
||||
|
||||
const help = await nodeHelpService.fetchNodeHelp(
|
||||
nodeDef({
|
||||
name: 'CustomNode',
|
||||
python_module: 'custom_nodes.TestPack.nodes'
|
||||
}),
|
||||
'fr'
|
||||
)
|
||||
|
||||
expect(api.fileURL).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'/extensions/TestPack/docs/CustomNode/fr.md'
|
||||
)
|
||||
expect(api.fileURL).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'/extensions/TestPack/docs/CustomNode.md'
|
||||
)
|
||||
expect(help).toBe('Custom fallback help')
|
||||
})
|
||||
|
||||
it('reports the locale miss when the custom fallback is empty', async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(
|
||||
new Response('Not found', {
|
||||
status: 404,
|
||||
statusText: 'Locale missing'
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce(new Response(''))
|
||||
|
||||
await expect(
|
||||
nodeHelpService.fetchNodeHelp(
|
||||
nodeDef({
|
||||
name: 'CustomNode',
|
||||
python_module: 'custom_nodes.TestPack.nodes'
|
||||
}),
|
||||
'de'
|
||||
)
|
||||
).rejects.toThrow('Locale missing')
|
||||
})
|
||||
})
|
||||
@@ -124,80 +124,6 @@ describe('nodeOrganizationService', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('organizeNodesTab', () => {
|
||||
it('returns no sections for an empty node list', () => {
|
||||
expect(nodeOrganizationService.organizeNodesTab([])).toEqual([])
|
||||
})
|
||||
|
||||
it('classifies blueprints, partner nodes, Comfy nodes, and extensions', () => {
|
||||
const sections = nodeOrganizationService.organizeNodesTab([
|
||||
createMockNodeDef({
|
||||
name: 'MyBlueprint',
|
||||
nodeSource: {
|
||||
type: NodeSourceType.Blueprint,
|
||||
className: 'blueprint',
|
||||
displayText: 'Blueprint',
|
||||
badgeText: 'B'
|
||||
},
|
||||
isGlobal: false
|
||||
}),
|
||||
createMockNodeDef({
|
||||
name: 'ComfyBlueprint',
|
||||
python_module: 'blueprint.comfy',
|
||||
isGlobal: true
|
||||
}),
|
||||
createMockNodeDef({
|
||||
name: 'PartnerApi',
|
||||
category: 'api node/image'
|
||||
}),
|
||||
createMockNodeDef({
|
||||
name: 'CoreNode',
|
||||
nodeSource: {
|
||||
type: NodeSourceType.Core,
|
||||
className: 'core',
|
||||
displayText: 'Core',
|
||||
badgeText: 'C'
|
||||
}
|
||||
}),
|
||||
createMockNodeDef({
|
||||
name: 'EssentialNode',
|
||||
nodeSource: {
|
||||
type: NodeSourceType.Essentials,
|
||||
className: 'essentials',
|
||||
displayText: 'Essentials',
|
||||
badgeText: 'E'
|
||||
}
|
||||
}),
|
||||
createMockNodeDef({
|
||||
name: 'ExtensionNode'
|
||||
})
|
||||
])
|
||||
|
||||
expect(sections.map((section) => section.category)).toEqual([
|
||||
'blueprints',
|
||||
'comfyNodes',
|
||||
'partnerNodes',
|
||||
'extensions'
|
||||
])
|
||||
expect(sections[0].tree.children?.map((child) => child.key)).toEqual([
|
||||
'root/my-blueprints',
|
||||
'root/comfy-blueprints'
|
||||
])
|
||||
})
|
||||
|
||||
it('omits sections that have no matching nodes', () => {
|
||||
const sections = nodeOrganizationService.organizeNodesTab([
|
||||
createMockNodeDef({
|
||||
name: 'OnlyExtension'
|
||||
})
|
||||
])
|
||||
|
||||
expect(sections.map((section) => section.category)).toEqual([
|
||||
'extensions'
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('getGroupingIcon', () => {
|
||||
it('should return strategy icon', () => {
|
||||
const icon = nodeOrganizationService.getGroupingIcon('category')
|
||||
@@ -290,12 +216,6 @@ describe('nodeOrganizationService', () => {
|
||||
expect(path).toEqual(['core', 'TestNode'])
|
||||
})
|
||||
|
||||
it('should handle custom_nodes without a package segment', () => {
|
||||
const nodeDef = createMockNodeDef({ python_module: 'custom_nodes' })
|
||||
const path = strategy?.getNodePath(nodeDef)
|
||||
expect(path).toEqual(['custom_nodes', 'TestNode'])
|
||||
})
|
||||
|
||||
it('should handle non-standard module paths', () => {
|
||||
const nodeDef = createMockNodeDef({
|
||||
python_module: 'some.other.module.path'
|
||||
@@ -362,19 +282,6 @@ describe('nodeOrganizationService', () => {
|
||||
const path = strategy?.getNodePath(nodeDef)
|
||||
expect(path).toEqual(['Unknown', 'TestNode'])
|
||||
})
|
||||
|
||||
it('should handle core source type', () => {
|
||||
const nodeDef = createMockNodeDef({
|
||||
nodeSource: {
|
||||
type: NodeSourceType.Core,
|
||||
className: 'core',
|
||||
displayText: 'Core',
|
||||
badgeText: 'C'
|
||||
}
|
||||
})
|
||||
const path = strategy?.getNodePath(nodeDef)
|
||||
expect(path).toEqual(['Core', 'TestNode'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('node name edge cases', () => {
|
||||
@@ -419,14 +326,5 @@ describe('nodeOrganizationService', () => {
|
||||
expect(strategy?.compare(nodeA, nodeB)).toBeGreaterThan(0)
|
||||
expect(strategy?.compare(nodeB, nodeA)).toBeLessThan(0)
|
||||
})
|
||||
|
||||
it('alphabetical sort handles missing display names', () => {
|
||||
const strategy =
|
||||
nodeOrganizationService.getSortingStrategy('alphabetical')
|
||||
const nodeA = createMockNodeDef({ display_name: undefined })
|
||||
const nodeB = createMockNodeDef({ display_name: undefined })
|
||||
|
||||
expect(strategy?.compare(nodeA, nodeB)).toBe(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,175 +0,0 @@
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import type { PartialDeep } from '@total-typescript/shoehorn'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type {
|
||||
ExportedSubgraph,
|
||||
ExportedSubgraphInstance,
|
||||
Subgraph
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import { createTestSubgraphData } from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
|
||||
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import type { ComfyNodeDef as ComfyNodeDefV1 } from '@/schemas/nodeDefSchema'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
addNodeDef: vi.fn(),
|
||||
createSubgraph: vi.fn((subgraph: unknown) => ({
|
||||
createdFrom: subgraph
|
||||
})),
|
||||
registerSubgraphNodeDef: vi.fn(),
|
||||
subgraphs: new Map<string, unknown>()
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/nodeDefStore', () => ({
|
||||
useNodeDefStore: () => ({
|
||||
addNodeDef: mocks.addNodeDef
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
rootGraph: {
|
||||
subgraphs: mocks.subgraphs,
|
||||
createSubgraph: mocks.createSubgraph
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('./litegraphService', () => ({
|
||||
useLitegraphService: () => ({
|
||||
registerSubgraphNodeDef: mocks.registerSubgraphNodeDef
|
||||
})
|
||||
}))
|
||||
|
||||
const { useSubgraphService } = await import('./subgraphService')
|
||||
|
||||
function createExportedSubgraph(
|
||||
overrides: Partial<ExportedSubgraph> = {}
|
||||
): ExportedSubgraph {
|
||||
return createTestSubgraphData({
|
||||
id: 'subgraph-1',
|
||||
name: 'Test Subgraph',
|
||||
...overrides
|
||||
})
|
||||
}
|
||||
|
||||
function createWorkflow(subgraphs?: ExportedSubgraph[]): ComfyWorkflowJSON {
|
||||
return fromPartial<ComfyWorkflowJSON>({
|
||||
definitions: subgraphs
|
||||
? {
|
||||
subgraphs
|
||||
}
|
||||
: undefined
|
||||
} as PartialDeep<ComfyWorkflowJSON>)
|
||||
}
|
||||
|
||||
describe('useSubgraphService', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.subgraphs.clear()
|
||||
})
|
||||
|
||||
it('registers a new subgraph node definition', () => {
|
||||
const service = useSubgraphService()
|
||||
const subgraph = fromPartial<Subgraph>({ id: 'runtime-subgraph' })
|
||||
const exportedSubgraph = createExportedSubgraph()
|
||||
|
||||
service.registerNewSubgraph(subgraph, exportedSubgraph)
|
||||
|
||||
expect(mocks.addNodeDef).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: 'subgraph-1',
|
||||
display_name: 'Test Subgraph',
|
||||
description: 'Subgraph node for Test Subgraph',
|
||||
category: 'subgraph',
|
||||
output_node: false,
|
||||
python_module: 'nodes'
|
||||
})
|
||||
)
|
||||
|
||||
const [nodeDef, registeredSubgraph, instanceData] = mocks
|
||||
.registerSubgraphNodeDef.mock.calls[0] as [
|
||||
ComfyNodeDefV1,
|
||||
Subgraph,
|
||||
ExportedSubgraphInstance
|
||||
]
|
||||
|
||||
expect(nodeDef.name).toBe('subgraph-1')
|
||||
expect(registeredSubgraph).toBe(subgraph)
|
||||
expect(instanceData).toMatchObject({
|
||||
id: -1,
|
||||
type: 'subgraph-1',
|
||||
pos: [0, 0],
|
||||
size: [100, 100],
|
||||
inputs: [],
|
||||
outputs: []
|
||||
})
|
||||
})
|
||||
|
||||
it('uses an exported description when present', () => {
|
||||
const service = useSubgraphService()
|
||||
const subgraph = fromPartial<Subgraph>({ id: 'runtime-subgraph' })
|
||||
|
||||
service.registerNewSubgraph(
|
||||
subgraph,
|
||||
createExportedSubgraph({
|
||||
description: 'Reusable workflow section'
|
||||
})
|
||||
)
|
||||
|
||||
expect(mocks.addNodeDef).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
description: 'Reusable workflow section'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('does nothing when workflow data has no subgraph definitions', () => {
|
||||
const service = useSubgraphService()
|
||||
|
||||
service.loadSubgraphs(createWorkflow())
|
||||
|
||||
expect(mocks.addNodeDef).not.toHaveBeenCalled()
|
||||
expect(mocks.registerSubgraphNodeDef).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('registers existing root graph subgraphs from workflow data', () => {
|
||||
const service = useSubgraphService()
|
||||
const subgraph = fromPartial<Subgraph>({ id: 'existing-subgraph' })
|
||||
const exportedSubgraph = createExportedSubgraph()
|
||||
mocks.subgraphs.set('subgraph-1', subgraph)
|
||||
|
||||
service.loadSubgraphs(createWorkflow([exportedSubgraph]))
|
||||
|
||||
expect(mocks.createSubgraph).not.toHaveBeenCalled()
|
||||
expect(mocks.registerSubgraphNodeDef).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: 'subgraph-1'
|
||||
}),
|
||||
subgraph,
|
||||
expect.objectContaining({
|
||||
type: 'subgraph-1'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('creates missing root graph subgraphs from workflow data', () => {
|
||||
const service = useSubgraphService()
|
||||
const exportedSubgraph = createExportedSubgraph()
|
||||
|
||||
service.loadSubgraphs(createWorkflow([exportedSubgraph]))
|
||||
|
||||
expect(mocks.createSubgraph).toHaveBeenCalledWith(exportedSubgraph)
|
||||
expect(mocks.registerSubgraphNodeDef).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: 'subgraph-1'
|
||||
}),
|
||||
expect.objectContaining({
|
||||
createdFrom: exportedSubgraph
|
||||
}),
|
||||
expect.objectContaining({
|
||||
type: 'subgraph-1'
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,300 +0,0 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type * as VueUse from '@vueuse/core'
|
||||
|
||||
import type { AssetExportWsMessage } from '@/schemas/apiSchema'
|
||||
import { api } from '@/scripts/api'
|
||||
import type { TaskId } from '@/platform/tasks/services/taskService'
|
||||
import { useAssetExportStore } from '@/stores/assetExportStore'
|
||||
|
||||
const { getExportDownloadUrl, getTask, toastAdd, intervalState } = vi.hoisted(
|
||||
() => ({
|
||||
getExportDownloadUrl: vi.fn(),
|
||||
getTask: vi.fn(),
|
||||
toastAdd: vi.fn(),
|
||||
intervalState: { cb: null as null | (() => void) }
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock('@vueuse/core', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof VueUse>()),
|
||||
useIntervalFn: (cb: () => void) => {
|
||||
intervalState.cb = cb
|
||||
return { pause: vi.fn(), resume: vi.fn() }
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: { addEventListener: vi.fn() }
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/assets/services/assetService', () => ({
|
||||
assetService: { getExportDownloadUrl }
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/tasks/services/taskService', () => ({
|
||||
taskService: { getTask }
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/updates/common/toastStore', () => ({
|
||||
useToastStore: () => ({ add: toastAdd })
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n', () => ({
|
||||
t: (key: string) => key
|
||||
}))
|
||||
|
||||
function wsMessage(
|
||||
over: Partial<AssetExportWsMessage> = {}
|
||||
): AssetExportWsMessage {
|
||||
return {
|
||||
task_id: 'task-1',
|
||||
export_name: 'export.zip',
|
||||
assets_total: 10,
|
||||
assets_attempted: 5,
|
||||
assets_failed: 0,
|
||||
bytes_total: 1000,
|
||||
bytes_processed: 500,
|
||||
progress: 0.5,
|
||||
status: 'running',
|
||||
...over
|
||||
}
|
||||
}
|
||||
|
||||
const taskId = (id: string) => id as TaskId
|
||||
|
||||
/**
|
||||
* Build a store and an `emit` bound to the real `asset_export` listener the
|
||||
* store registers on `api`, so tests drive the state machine through its
|
||||
* actual entry point rather than a private method.
|
||||
*/
|
||||
function setup() {
|
||||
const store = useAssetExportStore()
|
||||
const entry = vi
|
||||
.mocked(api.addEventListener)
|
||||
.mock.calls.find((c) => c[0] === 'asset_export')
|
||||
const handler = entry![1] as (e: { detail: AssetExportWsMessage }) => void
|
||||
const emit = (msg: AssetExportWsMessage) => handler({ detail: msg })
|
||||
// Run the polling tick that `useIntervalFn` would normally fire, and let its
|
||||
// async work settle.
|
||||
const runPoll = async () => {
|
||||
intervalState.cb?.()
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
}
|
||||
return { store, emit, runPoll }
|
||||
}
|
||||
|
||||
const STALE_AGO_MS = 20_000
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.mocked(api.addEventListener).mockClear()
|
||||
getExportDownloadUrl
|
||||
.mockReset()
|
||||
.mockResolvedValue({ url: 'https://example.com/export.zip' })
|
||||
getTask.mockReset()
|
||||
toastAdd.mockReset()
|
||||
})
|
||||
|
||||
describe('assetExportStore', () => {
|
||||
it('tracks a new export as created and is idempotent', () => {
|
||||
const { store } = setup()
|
||||
|
||||
store.trackExport(taskId('t1'))
|
||||
store.trackExport(taskId('t1'))
|
||||
|
||||
expect(store.exportList).toHaveLength(1)
|
||||
expect(store.exportList[0].status).toBe('created')
|
||||
expect(store.hasExports).toBe(true)
|
||||
expect(store.hasActiveExports).toBe(true)
|
||||
})
|
||||
|
||||
it('separates active from finished exports by status', () => {
|
||||
const { store, emit } = setup()
|
||||
|
||||
emit(wsMessage({ task_id: 'running', status: 'running' }))
|
||||
emit(
|
||||
wsMessage({ task_id: 'failed', status: 'failed', export_name: 'f.zip' })
|
||||
)
|
||||
|
||||
expect(store.activeExports.map((e) => e.taskId)).toEqual(['running'])
|
||||
expect(store.finishedExports.map((e) => e.taskId)).toEqual(['failed'])
|
||||
})
|
||||
|
||||
it('updates an export from successive websocket messages', () => {
|
||||
const { store, emit } = setup()
|
||||
|
||||
emit(wsMessage({ progress: 0.5, status: 'running' }))
|
||||
emit(wsMessage({ progress: 0.9, status: 'running' }))
|
||||
|
||||
expect(store.exportList).toHaveLength(1)
|
||||
expect(store.exportList[0].progress).toBe(0.9)
|
||||
})
|
||||
|
||||
it('ignores updates for an export already completed and downloaded', async () => {
|
||||
const { store, emit } = setup()
|
||||
|
||||
emit(wsMessage({ status: 'completed' }))
|
||||
await Promise.resolve()
|
||||
const triggeredCalls = getExportDownloadUrl.mock.calls.length
|
||||
|
||||
// A late 'running' message must not revive a completed+downloaded export
|
||||
emit(wsMessage({ status: 'running', progress: 0.1 }))
|
||||
|
||||
expect(store.exportList[0].status).toBe('completed')
|
||||
expect(getExportDownloadUrl).toHaveBeenCalledTimes(triggeredCalls)
|
||||
})
|
||||
|
||||
it('falls back to the prior export name when a message omits it', async () => {
|
||||
const { store, emit } = setup()
|
||||
|
||||
emit(wsMessage({ status: 'running', progress: 0.4 }))
|
||||
emit(
|
||||
wsMessage({ status: 'running', export_name: undefined, progress: 0.6 })
|
||||
)
|
||||
|
||||
expect(store.exportList[0].exportName).toBe('export.zip')
|
||||
})
|
||||
|
||||
it('falls back to a blank export name when no message has named it', () => {
|
||||
const { store, emit } = setup()
|
||||
|
||||
emit(wsMessage({ export_name: undefined, status: 'running' }))
|
||||
|
||||
expect(store.exportList[0].exportName).toBe('')
|
||||
})
|
||||
|
||||
it('triggers a download for a named export and clears prior errors', async () => {
|
||||
const { store, emit } = setup()
|
||||
emit(wsMessage({ status: 'running' }))
|
||||
const [exp] = store.exportList
|
||||
|
||||
await store.triggerDownload(exp)
|
||||
|
||||
expect(getExportDownloadUrl).toHaveBeenCalledWith('export.zip')
|
||||
expect(exp.downloadTriggered).toBe(true)
|
||||
expect(exp.downloadError).toBeUndefined()
|
||||
})
|
||||
|
||||
it('does not re-trigger a download unless forced', async () => {
|
||||
const { store, emit } = setup()
|
||||
emit(wsMessage({ status: 'running' }))
|
||||
const [exp] = store.exportList
|
||||
exp.downloadTriggered = true
|
||||
|
||||
await store.triggerDownload(exp)
|
||||
expect(getExportDownloadUrl).not.toHaveBeenCalled()
|
||||
|
||||
await store.triggerDownload(exp, true)
|
||||
expect(getExportDownloadUrl).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('records a download error and surfaces a toast on failure', async () => {
|
||||
getExportDownloadUrl.mockRejectedValueOnce(new Error('network down'))
|
||||
const { store, emit } = setup()
|
||||
emit(wsMessage({ status: 'running' }))
|
||||
const [exp] = store.exportList
|
||||
|
||||
await store.triggerDownload(exp)
|
||||
|
||||
expect(exp.downloadError).toBe('network down')
|
||||
expect(exp.downloadTriggered).toBe(false)
|
||||
expect(toastAdd).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ severity: 'error' })
|
||||
)
|
||||
})
|
||||
|
||||
it('records a string download error', async () => {
|
||||
getExportDownloadUrl.mockRejectedValueOnce('offline')
|
||||
const { store, emit } = setup()
|
||||
emit(wsMessage({ status: 'running' }))
|
||||
const [exp] = store.exportList
|
||||
|
||||
await store.triggerDownload(exp)
|
||||
|
||||
expect(exp.downloadError).toBe('offline')
|
||||
})
|
||||
|
||||
it('clears finished exports while keeping active ones', () => {
|
||||
const { store, emit } = setup()
|
||||
emit(wsMessage({ task_id: 'a', status: 'running' }))
|
||||
emit(wsMessage({ task_id: 'b', status: 'failed', export_name: 'b.zip' }))
|
||||
|
||||
store.clearFinishedExports()
|
||||
|
||||
expect(store.exportList.map((e) => e.taskId)).toEqual(['a'])
|
||||
})
|
||||
|
||||
it('does not poll when no active export is stale', async () => {
|
||||
const { emit, runPoll } = setup()
|
||||
emit(wsMessage({ status: 'running' }))
|
||||
|
||||
await runPoll()
|
||||
|
||||
expect(getTask).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reconciles a stale export from the task service result', async () => {
|
||||
const { store, emit, runPoll } = setup()
|
||||
emit(wsMessage({ status: 'running' }))
|
||||
store.exportList[0].lastUpdate = Date.now() - STALE_AGO_MS
|
||||
getTask.mockResolvedValue({
|
||||
status: 'completed',
|
||||
result: { export_name: 'reconciled.zip', assets_total: 10 }
|
||||
})
|
||||
|
||||
await runPoll()
|
||||
|
||||
expect(getTask).toHaveBeenCalledWith('task-1')
|
||||
expect(store.exportList[0].status).toBe('completed')
|
||||
expect(store.exportList[0].exportName).toBe('reconciled.zip')
|
||||
})
|
||||
|
||||
it('leaves a stale export active when the task is still running', async () => {
|
||||
const { store, emit, runPoll } = setup()
|
||||
emit(wsMessage({ status: 'running' }))
|
||||
store.exportList[0].lastUpdate = Date.now() - STALE_AGO_MS
|
||||
getTask.mockResolvedValue({ status: 'running' })
|
||||
|
||||
await runPoll()
|
||||
|
||||
expect(store.exportList[0].status).toBe('running')
|
||||
})
|
||||
|
||||
it('reconciles a stale failed export using existing counters', async () => {
|
||||
const { store, emit, runPoll } = setup()
|
||||
emit(
|
||||
wsMessage({
|
||||
assets_attempted: 4,
|
||||
assets_failed: 1,
|
||||
status: 'running'
|
||||
})
|
||||
)
|
||||
store.exportList[0].lastUpdate = Date.now() - STALE_AGO_MS
|
||||
getTask.mockResolvedValue({
|
||||
status: 'failed',
|
||||
result: { error: 'failed in result' }
|
||||
})
|
||||
|
||||
await runPoll()
|
||||
|
||||
expect(store.exportList[0]).toMatchObject({
|
||||
assetsAttempted: 4,
|
||||
assetsFailed: 1,
|
||||
error: 'failed in result',
|
||||
status: 'failed'
|
||||
})
|
||||
})
|
||||
|
||||
it('leaves a stale export untouched when the task lookup fails', async () => {
|
||||
const { store, emit, runPoll } = setup()
|
||||
emit(wsMessage({ status: 'running' }))
|
||||
store.exportList[0].lastUpdate = Date.now() - STALE_AGO_MS
|
||||
getTask.mockRejectedValue(new Error('task not found'))
|
||||
|
||||
await runPoll()
|
||||
|
||||
expect(store.exportList[0].status).toBe('running')
|
||||
})
|
||||
})
|
||||
@@ -1,15 +1,9 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { fromAny } from '@total-typescript/shoehorn'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { MissingNodeType } from '@/types/comfy'
|
||||
import { createNodeExecutionId } from '@/types/nodeIdentification'
|
||||
import type { ExecutionErrorWsMessage, PromptError } from '@/schemas/apiSchema'
|
||||
import type { MissingMediaCandidate } from '@/platform/missingMedia/types'
|
||||
import type { MissingModelCandidate } from '@/platform/missingModel/types'
|
||||
import type { NodeLocatorId } from '@/types/nodeIdentification'
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('@/i18n', () => ({
|
||||
@@ -21,54 +15,6 @@ vi.mock('@/platform/distribution/types', () => ({
|
||||
}))
|
||||
|
||||
const mockShowErrorsTab = vi.hoisted(() => ({ value: false }))
|
||||
const {
|
||||
mockApp,
|
||||
mockCanvasStore,
|
||||
mockExecutionIdToNodeLocatorId,
|
||||
mockGetExecutionIdByNode,
|
||||
mockGetNodeByExecutionId,
|
||||
mockWorkflowStore
|
||||
} = vi.hoisted(() => ({
|
||||
mockApp: {
|
||||
isGraphReady: true,
|
||||
rootGraph: {}
|
||||
},
|
||||
mockCanvasStore: {
|
||||
currentGraph: undefined as object | undefined
|
||||
},
|
||||
mockExecutionIdToNodeLocatorId: vi.fn(
|
||||
(_rootGraph: unknown, id: string): NodeLocatorId | undefined =>
|
||||
id as NodeLocatorId
|
||||
),
|
||||
mockGetExecutionIdByNode: vi.fn(),
|
||||
mockGetNodeByExecutionId: vi.fn(),
|
||||
mockWorkflowStore: {
|
||||
nodeLocatorIdToNodeId: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({ app: mockApp }))
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => mockCanvasStore
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
|
||||
useWorkflowStore: () => mockWorkflowStore
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/graphTraversalUtil', () => ({
|
||||
executionIdToNodeLocatorId: (
|
||||
...args: Parameters<typeof mockExecutionIdToNodeLocatorId>
|
||||
) => mockExecutionIdToNodeLocatorId(...args),
|
||||
forEachNode: vi.fn(),
|
||||
getExecutionIdByNode: (
|
||||
...args: Parameters<typeof mockGetExecutionIdByNode>
|
||||
) => mockGetExecutionIdByNode(...args),
|
||||
getNodeByExecutionId: (
|
||||
...args: Parameters<typeof mockGetNodeByExecutionId>
|
||||
) => mockGetNodeByExecutionId(...args)
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/settingStore', () => ({
|
||||
useSettingStore: vi.fn(() => ({
|
||||
@@ -92,25 +38,6 @@ vi.mock(
|
||||
import { useExecutionErrorStore } from './executionErrorStore'
|
||||
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
import {
|
||||
createRequiredInputMissingNodeError,
|
||||
seedRequiredInputMissingNodeError
|
||||
} from '@/utils/__tests__/executionErrorTestUtils'
|
||||
|
||||
beforeEach(() => {
|
||||
mockShowErrorsTab.value = false
|
||||
mockApp.isGraphReady = true
|
||||
mockCanvasStore.currentGraph = undefined
|
||||
mockExecutionIdToNodeLocatorId.mockImplementation(
|
||||
(_rootGraph: unknown, id: string) => id as NodeLocatorId
|
||||
)
|
||||
mockGetExecutionIdByNode.mockReset()
|
||||
mockGetNodeByExecutionId.mockReset()
|
||||
mockWorkflowStore.nodeLocatorIdToNodeId.mockImplementation(
|
||||
(locator: NodeLocatorId) =>
|
||||
toNodeId(String(locator).split(':').at(-1) ?? locator)
|
||||
)
|
||||
})
|
||||
|
||||
describe('executionErrorStore — node error operations', () => {
|
||||
beforeEach(() => {
|
||||
@@ -217,31 +144,6 @@ describe('executionErrorStore — node error operations', () => {
|
||||
expect(store.lastNodeErrors?.['123'].errors).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('does nothing when the requested slot has no errors', () => {
|
||||
const store = useExecutionErrorStore()
|
||||
store.lastNodeErrors = {
|
||||
'123': {
|
||||
errors: [
|
||||
{
|
||||
type: 'value_bigger_than_max',
|
||||
message: 'Max exceeded',
|
||||
details: '',
|
||||
extra_info: { input_name: 'otherSlot' }
|
||||
}
|
||||
],
|
||||
dependent_outputs: [],
|
||||
class_type: 'TestNode'
|
||||
}
|
||||
}
|
||||
|
||||
store.clearSimpleNodeErrors(
|
||||
createNodeExecutionId([toNodeId(123)]),
|
||||
'testSlot'
|
||||
)
|
||||
|
||||
expect(store.lastNodeErrors?.['123'].errors).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('preserves complex errors when slot has both simple and complex errors', () => {
|
||||
const store = useExecutionErrorStore()
|
||||
store.lastNodeErrors = {
|
||||
@@ -486,312 +388,6 @@ describe('executionErrorStore — node error operations', () => {
|
||||
expect(store.lastNodeErrors).not.toBeNull()
|
||||
expect(store.lastNodeErrors?.['123'].errors).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('keeps numeric range errors when no range options prove them valid', () => {
|
||||
const store = useExecutionErrorStore()
|
||||
store.lastNodeErrors = {
|
||||
'123': {
|
||||
errors: [
|
||||
{
|
||||
type: 'value_bigger_than_max',
|
||||
message: '...',
|
||||
details: '',
|
||||
extra_info: { input_name: 'testWidget' }
|
||||
}
|
||||
],
|
||||
dependent_outputs: [],
|
||||
class_type: 'TestNode'
|
||||
}
|
||||
}
|
||||
|
||||
store.clearWidgetRelatedErrors(
|
||||
createNodeExecutionId([toNodeId(123)]),
|
||||
'testWidget',
|
||||
'testWidget',
|
||||
15
|
||||
)
|
||||
|
||||
expect(store.lastNodeErrors?.['123'].errors).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('is a no-op when the target execution id has no node error entry', () => {
|
||||
const store = useExecutionErrorStore()
|
||||
store.lastNodeErrors = {
|
||||
'999': {
|
||||
errors: [
|
||||
{
|
||||
type: 'value_bigger_than_max',
|
||||
message: '...',
|
||||
details: '',
|
||||
extra_info: { input_name: 'testWidget' }
|
||||
}
|
||||
],
|
||||
dependent_outputs: [],
|
||||
class_type: 'TestNode'
|
||||
}
|
||||
}
|
||||
|
||||
store.clearWidgetRelatedErrors(
|
||||
createNodeExecutionId([toNodeId(123)]),
|
||||
'testWidget',
|
||||
'testWidget',
|
||||
15,
|
||||
{ max: 10 }
|
||||
)
|
||||
|
||||
expect(store.lastNodeErrors?.['123']).toBeUndefined()
|
||||
expect(store.lastNodeErrors?.['999'].errors).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('startup clearing', () => {
|
||||
it('clears execution-start errors and closes the overlay when node errors are empty', () => {
|
||||
const store = useExecutionErrorStore()
|
||||
store.lastExecutionError = fromPartial<ExecutionErrorWsMessage>({
|
||||
node_id: '1'
|
||||
})
|
||||
store.lastPromptError = fromPartial<PromptError>({
|
||||
message: 'prompt failed'
|
||||
})
|
||||
store.lastNodeErrors = {}
|
||||
store.showErrorOverlay()
|
||||
|
||||
store.clearExecutionStartErrors()
|
||||
|
||||
expect(store.lastExecutionError).toBeNull()
|
||||
expect(store.lastPromptError).toBeNull()
|
||||
expect(store.isErrorOverlayOpen).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps the overlay open when node errors remain after execution start', () => {
|
||||
const store = useExecutionErrorStore()
|
||||
store.lastExecutionError = fromPartial<ExecutionErrorWsMessage>({
|
||||
node_id: '1'
|
||||
})
|
||||
store.lastPromptError = fromPartial<PromptError>({
|
||||
message: 'prompt failed'
|
||||
})
|
||||
seedRequiredInputMissingNodeError(
|
||||
store,
|
||||
createNodeExecutionId([toNodeId(1)]),
|
||||
'x'
|
||||
)
|
||||
store.showErrorOverlay()
|
||||
|
||||
store.clearExecutionStartErrors()
|
||||
|
||||
expect(store.isErrorOverlayOpen).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('executionErrorStore derived graph state', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
it('derives execution error node ids through locator mapping', () => {
|
||||
const store = useExecutionErrorStore()
|
||||
mockExecutionIdToNodeLocatorId.mockReturnValue('graph:7' as NodeLocatorId)
|
||||
store.lastExecutionError = fromPartial<ExecutionErrorWsMessage>({
|
||||
node_id: '7'
|
||||
})
|
||||
|
||||
expect(store.lastExecutionErrorNodeId).toBe(toNodeId(7))
|
||||
})
|
||||
|
||||
it('returns null when there is no execution error locator', () => {
|
||||
const store = useExecutionErrorStore()
|
||||
store.lastExecutionError = fromPartial<ExecutionErrorWsMessage>({
|
||||
node_id: '7'
|
||||
})
|
||||
mockExecutionIdToNodeLocatorId.mockReturnValue(undefined)
|
||||
|
||||
expect(store.lastExecutionErrorNodeId).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when there is no execution error', () => {
|
||||
const store = useExecutionErrorStore()
|
||||
|
||||
expect(store.lastExecutionErrorNodeId).toBeNull()
|
||||
})
|
||||
|
||||
it('combines prompt, node, execution, and missing-node error counts', () => {
|
||||
const store = useExecutionErrorStore()
|
||||
const missingNodesStore = useMissingNodesErrorStore()
|
||||
store.lastPromptError = fromPartial<PromptError>({
|
||||
message: 'prompt failed'
|
||||
})
|
||||
Reflect.set(store, 'lastExecutionError', { node_id: null })
|
||||
const nodeError = createRequiredInputMissingNodeError('x')
|
||||
store.lastNodeErrors = {
|
||||
'1': {
|
||||
...nodeError,
|
||||
errors: [
|
||||
...nodeError.errors,
|
||||
{
|
||||
type: 'value_bigger_than_max',
|
||||
message: 'Too large',
|
||||
details: '',
|
||||
extra_info: { input_name: 'y' }
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
missingNodesStore.setMissingNodeTypes([
|
||||
{ type: 'MissingNode', hint: '' }
|
||||
] as MissingNodeType[])
|
||||
|
||||
expect(store.hasPromptError).toBe(true)
|
||||
expect(store.hasNodeError).toBe(true)
|
||||
expect(store.hasExecutionError).toBe(true)
|
||||
expect(store.hasAnyError).toBe(true)
|
||||
expect(store.allErrorExecutionIds).toEqual(['1'])
|
||||
expect(store.totalErrorCount).toBe(5)
|
||||
})
|
||||
|
||||
it('reports empty derived state when there are no errors', () => {
|
||||
const store = useExecutionErrorStore()
|
||||
|
||||
expect(store.hasNodeError).toBe(false)
|
||||
expect(store.allErrorExecutionIds).toEqual([])
|
||||
expect(store.totalErrorCount).toBe(0)
|
||||
})
|
||||
|
||||
it('includes defined execution node ids in the error id list', () => {
|
||||
const store = useExecutionErrorStore()
|
||||
store.lastExecutionError = fromPartial<ExecutionErrorWsMessage>({
|
||||
node_id: '2'
|
||||
})
|
||||
|
||||
expect(store.allErrorExecutionIds).toEqual(['2'])
|
||||
})
|
||||
|
||||
it('excludes undefined execution node ids from the error id list', () => {
|
||||
const store = useExecutionErrorStore()
|
||||
Reflect.set(store, 'lastExecutionError', { node_id: undefined })
|
||||
|
||||
expect(store.allErrorExecutionIds).toEqual([])
|
||||
})
|
||||
|
||||
it('collects active graph node ids for validation and execution errors', () => {
|
||||
const store = useExecutionErrorStore()
|
||||
const activeGraph = {}
|
||||
mockCanvasStore.currentGraph = activeGraph
|
||||
mockGetNodeByExecutionId.mockImplementation((_rootGraph, id: string) => ({
|
||||
id: toNodeId(id),
|
||||
graph: activeGraph
|
||||
}))
|
||||
seedRequiredInputMissingNodeError(
|
||||
store,
|
||||
createNodeExecutionId([toNodeId(1)]),
|
||||
'x'
|
||||
)
|
||||
store.lastExecutionError = fromPartial<ExecutionErrorWsMessage>({
|
||||
node_id: '2'
|
||||
})
|
||||
|
||||
expect([...store.activeGraphErrorNodeIds].sort()).toEqual(['1', '2'])
|
||||
})
|
||||
|
||||
it('falls back to the root graph when there is no current canvas graph', () => {
|
||||
const store = useExecutionErrorStore()
|
||||
mockCanvasStore.currentGraph = undefined
|
||||
mockGetNodeByExecutionId.mockReturnValue({
|
||||
id: toNodeId(1),
|
||||
graph: mockApp.rootGraph
|
||||
})
|
||||
seedRequiredInputMissingNodeError(
|
||||
store,
|
||||
createNodeExecutionId([toNodeId(1)]),
|
||||
'x'
|
||||
)
|
||||
|
||||
expect([...store.activeGraphErrorNodeIds]).toEqual(['1'])
|
||||
})
|
||||
|
||||
it('ignores graph errors outside the active graph', () => {
|
||||
const store = useExecutionErrorStore()
|
||||
const activeGraph = {}
|
||||
mockCanvasStore.currentGraph = activeGraph
|
||||
mockGetNodeByExecutionId.mockReturnValue({
|
||||
id: toNodeId(1),
|
||||
graph: {}
|
||||
})
|
||||
seedRequiredInputMissingNodeError(
|
||||
store,
|
||||
createNodeExecutionId([toNodeId(1)]),
|
||||
'x'
|
||||
)
|
||||
store.lastExecutionError = fromPartial<ExecutionErrorWsMessage>({
|
||||
node_id: '1'
|
||||
})
|
||||
|
||||
expect(store.activeGraphErrorNodeIds.size).toBe(0)
|
||||
})
|
||||
|
||||
it('returns no active graph node ids before the graph is ready', () => {
|
||||
const store = useExecutionErrorStore()
|
||||
mockApp.isGraphReady = false
|
||||
store.lastExecutionError = fromPartial<ExecutionErrorWsMessage>({
|
||||
node_id: '2'
|
||||
})
|
||||
|
||||
expect(store.activeGraphErrorNodeIds.size).toBe(0)
|
||||
})
|
||||
|
||||
it('maps node errors by locator and checks slots', () => {
|
||||
const store = useExecutionErrorStore()
|
||||
const nodeError = createRequiredInputMissingNodeError('x')
|
||||
mockExecutionIdToNodeLocatorId.mockImplementation((_rootGraph, id) =>
|
||||
id === 'missing' ? undefined : (`locator:${id}` as NodeLocatorId)
|
||||
)
|
||||
store.lastNodeErrors = {
|
||||
'1': nodeError,
|
||||
missing: nodeError
|
||||
}
|
||||
|
||||
const locator = 'locator:1' as NodeLocatorId
|
||||
expect(store.getNodeErrors(locator)).toEqual(nodeError)
|
||||
expect(store.slotHasError(locator, 'x')).toBe(true)
|
||||
expect(store.slotHasError(locator, 'y')).toBe(false)
|
||||
expect(
|
||||
store.getNodeErrors('locator:missing' as NodeLocatorId)
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns no slot error when there are no node errors', () => {
|
||||
const store = useExecutionErrorStore()
|
||||
|
||||
expect(store.slotHasError('locator:1' as NodeLocatorId, 'x')).toBe(false)
|
||||
})
|
||||
|
||||
it('detects container nodes with internal errors', () => {
|
||||
const store = useExecutionErrorStore()
|
||||
const node = fromPartial<LGraphNode>({})
|
||||
mockGetExecutionIdByNode.mockReturnValueOnce(undefined)
|
||||
|
||||
expect(store.isContainerWithInternalError(node)).toBe(false)
|
||||
|
||||
seedRequiredInputMissingNodeError(
|
||||
store,
|
||||
createNodeExecutionId([toNodeId(1), toNodeId(2)]),
|
||||
'x'
|
||||
)
|
||||
mockGetExecutionIdByNode.mockReturnValue(
|
||||
createNodeExecutionId([toNodeId(1)])
|
||||
)
|
||||
|
||||
expect(store.isContainerWithInternalError(node)).toBe(true)
|
||||
})
|
||||
|
||||
it('does not report container errors before the graph is ready', () => {
|
||||
const store = useExecutionErrorStore()
|
||||
mockApp.isGraphReady = false
|
||||
|
||||
expect(
|
||||
store.isContainerWithInternalError(fromPartial<LGraphNode>({}))
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -804,7 +400,7 @@ describe('surfaceMissingModels — silent option', () => {
|
||||
it('opens error overlay when silent is not specified and setting is enabled', () => {
|
||||
const store = useExecutionErrorStore()
|
||||
store.surfaceMissingModels([
|
||||
fromPartial<MissingModelCandidate>({
|
||||
fromAny({
|
||||
name: 'model.safetensors',
|
||||
nodeId: toNodeId('1'),
|
||||
nodeType: 'Loader',
|
||||
@@ -821,7 +417,7 @@ describe('surfaceMissingModels — silent option', () => {
|
||||
const store = useExecutionErrorStore()
|
||||
store.surfaceMissingModels(
|
||||
[
|
||||
fromPartial<MissingModelCandidate>({
|
||||
fromAny({
|
||||
name: 'model.safetensors',
|
||||
nodeId: toNodeId('1'),
|
||||
nodeType: 'Loader',
|
||||
@@ -840,7 +436,7 @@ describe('surfaceMissingModels — silent option', () => {
|
||||
const store = useExecutionErrorStore()
|
||||
store.surfaceMissingModels(
|
||||
[
|
||||
fromPartial<MissingModelCandidate>({
|
||||
fromAny({
|
||||
name: 'model.safetensors',
|
||||
nodeId: toNodeId('1'),
|
||||
nodeType: 'Loader',
|
||||
@@ -861,23 +457,6 @@ describe('surfaceMissingModels — silent option', () => {
|
||||
|
||||
expect(store.isErrorOverlayOpen).toBe(false)
|
||||
})
|
||||
|
||||
it('does NOT open error overlay when the setting is disabled', () => {
|
||||
const store = useExecutionErrorStore()
|
||||
mockShowErrorsTab.value = false
|
||||
store.surfaceMissingModels([
|
||||
fromPartial<MissingModelCandidate>({
|
||||
name: 'model.safetensors',
|
||||
nodeId: toNodeId('1'),
|
||||
nodeType: 'Loader',
|
||||
widgetName: 'ckpt',
|
||||
isMissing: true,
|
||||
isAssetSupported: false
|
||||
})
|
||||
])
|
||||
|
||||
expect(store.isErrorOverlayOpen).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('surfaceMissingMedia — silent option', () => {
|
||||
@@ -889,7 +468,7 @@ describe('surfaceMissingMedia — silent option', () => {
|
||||
it('opens error overlay when silent is not specified and setting is enabled', () => {
|
||||
const store = useExecutionErrorStore()
|
||||
store.surfaceMissingMedia([
|
||||
fromPartial<MissingMediaCandidate>({
|
||||
fromAny({
|
||||
name: 'photo.png',
|
||||
nodeId: toNodeId('1'),
|
||||
nodeType: 'LoadImage',
|
||||
@@ -906,7 +485,7 @@ describe('surfaceMissingMedia — silent option', () => {
|
||||
const store = useExecutionErrorStore()
|
||||
store.surfaceMissingMedia(
|
||||
[
|
||||
fromPartial<MissingMediaCandidate>({
|
||||
fromAny({
|
||||
name: 'photo.png',
|
||||
nodeId: toNodeId('1'),
|
||||
nodeType: 'LoadImage',
|
||||
@@ -925,7 +504,7 @@ describe('surfaceMissingMedia — silent option', () => {
|
||||
const store = useExecutionErrorStore()
|
||||
store.surfaceMissingMedia(
|
||||
[
|
||||
fromPartial<MissingMediaCandidate>({
|
||||
fromAny({
|
||||
name: 'photo.png',
|
||||
nodeId: toNodeId('1'),
|
||||
nodeType: 'LoadImage',
|
||||
@@ -946,23 +525,6 @@ describe('surfaceMissingMedia — silent option', () => {
|
||||
|
||||
expect(store.isErrorOverlayOpen).toBe(false)
|
||||
})
|
||||
|
||||
it('does NOT open error overlay when the setting is disabled', () => {
|
||||
const store = useExecutionErrorStore()
|
||||
mockShowErrorsTab.value = false
|
||||
store.surfaceMissingMedia([
|
||||
fromPartial<MissingMediaCandidate>({
|
||||
name: 'photo.png',
|
||||
nodeId: toNodeId('1'),
|
||||
nodeType: 'LoadImage',
|
||||
widgetName: 'image',
|
||||
mediaType: 'image',
|
||||
isMissing: true
|
||||
})
|
||||
])
|
||||
|
||||
expect(store.isErrorOverlayOpen).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('clearAllErrors', () => {
|
||||
@@ -1006,9 +568,9 @@ describe('clearAllErrors', () => {
|
||||
class_type: 'Test'
|
||||
}
|
||||
}
|
||||
missingNodesStore.setMissingNodeTypes([
|
||||
{ type: 'MissingNode', hint: '' }
|
||||
] as MissingNodeType[])
|
||||
missingNodesStore.setMissingNodeTypes(
|
||||
fromAny<MissingNodeType[], unknown>([{ type: 'MissingNode', hint: '' }])
|
||||
)
|
||||
executionErrorStore.showErrorOverlay()
|
||||
|
||||
executionErrorStore.clearAllErrors()
|
||||
|
||||
@@ -3,7 +3,6 @@ import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick, ref } from 'vue'
|
||||
import { createMemoryHistory, createRouter } from 'vue-router'
|
||||
|
||||
import type * as VueRouter from 'vue-router'
|
||||
|
||||
@@ -103,24 +102,12 @@ vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
|
||||
function makeSubgraph(id: string): Subgraph {
|
||||
return fromPartial<Subgraph>({
|
||||
id,
|
||||
isRootGraph: false,
|
||||
rootGraph: app.rootGraph,
|
||||
_nodes: [],
|
||||
nodes: []
|
||||
})
|
||||
}
|
||||
|
||||
async function makeDuplicatedNavigationFailure(): Promise<Error> {
|
||||
const router = createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [{ path: '/', component: {} }]
|
||||
})
|
||||
await router.push('/')
|
||||
const failure = await router.push('/')
|
||||
if (!failure) throw new Error('Expected duplicated navigation failure')
|
||||
return failure
|
||||
}
|
||||
|
||||
async function flushHashWatcher() {
|
||||
await nextTick()
|
||||
await Promise.resolve()
|
||||
@@ -131,7 +118,6 @@ describe('useSubgraphNavigationStore - navigateToHash validation', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(app.canvas.setGraph).mockReset()
|
||||
app.rootGraph.id = ids.root
|
||||
app.rootGraph.subgraphs.clear()
|
||||
app.canvas.subgraph = undefined
|
||||
@@ -244,44 +230,6 @@ describe('useSubgraphNavigationStore - navigateToHash validation', () => {
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('does not warn when recovery redirect hits a duplicated navigation', async () => {
|
||||
routerMocks.replace.mockRejectedValueOnce(
|
||||
await makeDuplicatedNavigationFailure()
|
||||
)
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
app.canvas.graph = makeSubgraph(ids.deletedSubgraph)
|
||||
useSubgraphNavigationStore()
|
||||
|
||||
routeHashRef.value = `#${ids.deletedSubgraph}`
|
||||
await vi.waitFor(() =>
|
||||
expect(routerMocks.replace).toHaveBeenCalledWith(`#${app.rootGraph.id}`)
|
||||
)
|
||||
|
||||
expect(warnSpy).not.toHaveBeenCalledWith(
|
||||
'[subgraphNavigation] router.replace rejected during recovery',
|
||||
expect.any(Error)
|
||||
)
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('recovers to root when canvas is unavailable during redirect cleanup', async () => {
|
||||
const appWithOptionalCanvas = app as {
|
||||
canvas: typeof app.canvas | undefined
|
||||
}
|
||||
const canvas = appWithOptionalCanvas.canvas
|
||||
appWithOptionalCanvas.canvas = undefined
|
||||
useSubgraphNavigationStore()
|
||||
|
||||
try {
|
||||
routeHashRef.value = '#not-a-valid-uuid'
|
||||
await vi.waitFor(() =>
|
||||
expect(routerMocks.replace).toHaveBeenCalledWith(`#${app.rootGraph.id}`)
|
||||
)
|
||||
} finally {
|
||||
appWithOptionalCanvas.canvas = canvas
|
||||
}
|
||||
})
|
||||
|
||||
it('redirects when a workflow load resolves but the subgraph is still missing', async () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
workflowStoreState.openWorkflows = [
|
||||
@@ -356,196 +304,4 @@ describe('useSubgraphNavigationStore - navigateToHash validation', () => {
|
||||
expect(app.canvas.setGraph).toHaveBeenCalledWith(app.rootGraph)
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('updateHash does nothing on initial load with an empty hash', async () => {
|
||||
const store = useSubgraphNavigationStore()
|
||||
|
||||
await store.updateHash()
|
||||
|
||||
expect(routerMocks.replace).not.toHaveBeenCalled()
|
||||
expect(routerMocks.push).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('updateHash follows a non-empty initial subgraph hash', async () => {
|
||||
const subgraph = makeSubgraph(ids.validSubgraph)
|
||||
app.rootGraph.subgraphs.set(subgraph.id, subgraph)
|
||||
vi.mocked(app.canvas.setGraph).mockImplementation((graph) => {
|
||||
app.canvas.graph = graph
|
||||
})
|
||||
routeHashRef.value = `#${ids.validSubgraph}`
|
||||
const store = useSubgraphNavigationStore()
|
||||
|
||||
await store.updateHash()
|
||||
|
||||
expect(app.canvas.setGraph).toHaveBeenCalledWith(subgraph)
|
||||
})
|
||||
|
||||
it('updateHash does not treat the initial root hash as a subgraph', async () => {
|
||||
routeHashRef.value = `#${ids.root}`
|
||||
app.canvas.graph = app.rootGraph
|
||||
const store = useSubgraphNavigationStore()
|
||||
|
||||
await store.updateHash()
|
||||
|
||||
expect(workflowStoreState.activeSubgraph).toBeUndefined()
|
||||
})
|
||||
|
||||
it('updateHash replaces an empty hash and pushes the active graph id', async () => {
|
||||
const store = useSubgraphNavigationStore()
|
||||
await store.updateHash()
|
||||
app.canvas.graph = fromPartial<LGraph>({ id: ids.validSubgraph })
|
||||
|
||||
await store.updateHash()
|
||||
|
||||
expect(routerMocks.replace).toHaveBeenCalledWith(`#${app.rootGraph.id}`)
|
||||
expect(routerMocks.push).toHaveBeenCalledWith(`#${ids.validSubgraph}`)
|
||||
})
|
||||
|
||||
it('updateHash skips router push when hash already matches the active graph', async () => {
|
||||
const store = useSubgraphNavigationStore()
|
||||
await store.updateHash()
|
||||
routeHashRef.value = `#${ids.validSubgraph}`
|
||||
app.canvas.graph = fromPartial<LGraph>({ id: ids.validSubgraph })
|
||||
|
||||
await store.updateHash()
|
||||
|
||||
expect(routerMocks.push).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('updateHash skips router push when the active graph has no id', async () => {
|
||||
const store = useSubgraphNavigationStore()
|
||||
await store.updateHash()
|
||||
routeHashRef.value = '#old'
|
||||
app.canvas.graph = fromPartial<LGraph>({})
|
||||
|
||||
await store.updateHash()
|
||||
|
||||
expect(routerMocks.push).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('updateHash warns when router push rejects', async () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
routerMocks.push.mockRejectedValueOnce(new Error('push failed'))
|
||||
const store = useSubgraphNavigationStore()
|
||||
await store.updateHash()
|
||||
routeHashRef.value = '#old'
|
||||
app.canvas.graph = fromPartial<LGraph>({ id: ids.validSubgraph })
|
||||
|
||||
await store.updateHash()
|
||||
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
'[subgraphNavigation] router.push rejected',
|
||||
expect.any(Error)
|
||||
)
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('updateHash ignores duplicated router push failures', async () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
routerMocks.push.mockRejectedValueOnce(
|
||||
await makeDuplicatedNavigationFailure()
|
||||
)
|
||||
const store = useSubgraphNavigationStore()
|
||||
await store.updateHash()
|
||||
routeHashRef.value = `#${ids.root}`
|
||||
app.canvas.graph = fromPartial<LGraph>({ id: ids.validSubgraph })
|
||||
|
||||
await store.updateHash()
|
||||
|
||||
expect(warnSpy).not.toHaveBeenCalled()
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('skips workflows without active state during hash recovery', async () => {
|
||||
workflowStoreState.openWorkflows = [
|
||||
fromPartial<ComfyWorkflow>({ path: 'inactive.json' })
|
||||
]
|
||||
useSubgraphNavigationStore()
|
||||
|
||||
routeHashRef.value = `#${ids.deletedSubgraph}`
|
||||
await vi.waitFor(() =>
|
||||
expect(routerMocks.replace).toHaveBeenCalledWith(`#${app.rootGraph.id}`)
|
||||
)
|
||||
})
|
||||
|
||||
it('skips workflow states and subgraphs that do not match the hash', async () => {
|
||||
workflowStoreState.openWorkflows = [
|
||||
fromPartial<ComfyWorkflow>({
|
||||
path: 'other-workflow.json',
|
||||
activeState: {
|
||||
id: ids.validSubgraph,
|
||||
definitions: {
|
||||
subgraphs: [{ id: ids.validSubgraph }]
|
||||
}
|
||||
}
|
||||
})
|
||||
]
|
||||
useSubgraphNavigationStore()
|
||||
|
||||
routeHashRef.value = `#${ids.deletedSubgraph}`
|
||||
await vi.waitFor(() =>
|
||||
expect(routerMocks.replace).toHaveBeenCalledWith(`#${app.rootGraph.id}`)
|
||||
)
|
||||
})
|
||||
|
||||
it('handles workflow states with no subgraph definitions during recovery', async () => {
|
||||
workflowStoreState.openWorkflows = [
|
||||
fromPartial<ComfyWorkflow>({
|
||||
path: 'no-definitions.json',
|
||||
activeState: { id: ids.validSubgraph }
|
||||
})
|
||||
]
|
||||
useSubgraphNavigationStore()
|
||||
|
||||
routeHashRef.value = `#${ids.deletedSubgraph}`
|
||||
await vi.waitFor(() =>
|
||||
expect(routerMocks.replace).toHaveBeenCalledWith(`#${app.rootGraph.id}`)
|
||||
)
|
||||
})
|
||||
|
||||
it('opens a workflow and navigates to the loaded root graph', async () => {
|
||||
workflowStoreState.openWorkflows = [
|
||||
fromPartial<ComfyWorkflow>({
|
||||
path: 'root-workflow.json',
|
||||
activeState: {
|
||||
id: ids.deletedSubgraph,
|
||||
definitions: { subgraphs: [] }
|
||||
}
|
||||
})
|
||||
]
|
||||
workflowServiceMocks.openWorkflow.mockImplementation(async () => {
|
||||
app.rootGraph.id = ids.deletedSubgraph
|
||||
app.canvas.graph = fromPartial<LGraph>({ id: ids.root })
|
||||
})
|
||||
useSubgraphNavigationStore()
|
||||
|
||||
routeHashRef.value = `#${ids.deletedSubgraph}`
|
||||
await vi.waitFor(() =>
|
||||
expect(app.canvas.setGraph).toHaveBeenCalledWith(app.rootGraph)
|
||||
)
|
||||
})
|
||||
|
||||
it('does not reset the graph when loaded workflow is already active', async () => {
|
||||
workflowStoreState.openWorkflows = [
|
||||
fromPartial<ComfyWorkflow>({
|
||||
path: 'already-active.json',
|
||||
activeState: {
|
||||
id: ids.deletedSubgraph,
|
||||
definitions: { subgraphs: [] }
|
||||
}
|
||||
})
|
||||
]
|
||||
workflowServiceMocks.openWorkflow.mockImplementation(async () => {
|
||||
app.rootGraph.id = ids.deletedSubgraph
|
||||
app.canvas.graph = fromPartial<LGraph>({ id: ids.deletedSubgraph })
|
||||
})
|
||||
useSubgraphNavigationStore()
|
||||
|
||||
routeHashRef.value = `#${ids.deletedSubgraph}`
|
||||
await vi.waitFor(() =>
|
||||
expect(workflowServiceMocks.openWorkflow).toHaveBeenCalled()
|
||||
)
|
||||
|
||||
expect(app.canvas.setGraph).not.toHaveBeenCalledWith(app.rootGraph)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -74,7 +74,7 @@ describe('useSubgraphNavigationStore', () => {
|
||||
app.canvas.ds.offset = [0, 0]
|
||||
app.canvas.ds.state.scale = 1
|
||||
app.canvas.ds.state.offset = [0, 0]
|
||||
app.rootGraph.getNodeById = vi.fn()
|
||||
app.graph.getNodeById = vi.fn()
|
||||
})
|
||||
|
||||
it('should not clear navigation stack when workflow internal state changes', async () => {
|
||||
@@ -223,7 +223,7 @@ describe('useSubgraphNavigationStore', () => {
|
||||
|
||||
const unreachableSubgraph = createMockSubgraph('orphan-subgraph', app.graph)
|
||||
|
||||
app.rootGraph.subgraphs.set(unreachableSubgraph.id, unreachableSubgraph)
|
||||
app.graph.subgraphs.set(unreachableSubgraph.id, unreachableSubgraph)
|
||||
vi.mocked(findSubgraphPathById).mockReturnValue(null)
|
||||
|
||||
const mockWorkflow = fromPartial<ComfyWorkflow>({
|
||||
@@ -251,7 +251,7 @@ describe('useSubgraphNavigationStore', () => {
|
||||
const mockSubgraph = createMockSubgraph('subgraph-1', app.graph)
|
||||
|
||||
// Add the subgraph to the graph's subgraphs map
|
||||
app.rootGraph.subgraphs.set('subgraph-1', mockSubgraph)
|
||||
app.graph.subgraphs.set('subgraph-1', mockSubgraph)
|
||||
|
||||
// First set an active workflow
|
||||
const mockWorkflow = fromPartial<ComfyWorkflow>({
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
@@ -90,7 +89,7 @@ describe('useSubgraphNavigationStore - Viewport Persistence', () => {
|
||||
return rafCallbacks.length
|
||||
})
|
||||
mockCanvas.subgraph = undefined
|
||||
mockCanvas.graph = app.graph ?? null
|
||||
mockCanvas.graph = app.graph
|
||||
mockCanvas.ds.scale = 1
|
||||
mockCanvas.ds.offset = [0, 0]
|
||||
mockCanvas.ds.state.scale = 1
|
||||
@@ -137,23 +136,6 @@ describe('useSubgraphNavigationStore - Viewport Persistence', () => {
|
||||
})
|
||||
|
||||
describe('saveViewport', () => {
|
||||
it('does not save when canvas is unavailable', () => {
|
||||
const store = useSubgraphNavigationStore()
|
||||
const canvas = app.canvas
|
||||
const appWithOptionalCanvas = app as {
|
||||
canvas: typeof app.canvas | undefined
|
||||
}
|
||||
appWithOptionalCanvas.canvas = undefined
|
||||
|
||||
try {
|
||||
store.saveViewport('root')
|
||||
|
||||
expect(store.viewportCache.has(':root')).toBe(false)
|
||||
} finally {
|
||||
appWithOptionalCanvas.canvas = canvas
|
||||
}
|
||||
})
|
||||
|
||||
it('saves viewport state for root graph', () => {
|
||||
const store = useSubgraphNavigationStore()
|
||||
mockCanvas.ds.state.scale = 2
|
||||
@@ -182,42 +164,6 @@ describe('useSubgraphNavigationStore - Viewport Persistence', () => {
|
||||
})
|
||||
|
||||
describe('restoreViewport', () => {
|
||||
it('does nothing when canvas is unavailable', () => {
|
||||
const store = useSubgraphNavigationStore()
|
||||
const canvas = app.canvas
|
||||
const appWithOptionalCanvas = app as {
|
||||
canvas: typeof app.canvas | undefined
|
||||
}
|
||||
appWithOptionalCanvas.canvas = undefined
|
||||
|
||||
try {
|
||||
store.restoreViewport('root')
|
||||
|
||||
expect(mockSetDirty).not.toHaveBeenCalled()
|
||||
expect(rafCallbacks).toHaveLength(0)
|
||||
} finally {
|
||||
appWithOptionalCanvas.canvas = canvas
|
||||
}
|
||||
})
|
||||
|
||||
it('does not apply cached viewport when canvas disappears', () => {
|
||||
const store = useSubgraphNavigationStore()
|
||||
const canvas = app.canvas
|
||||
const appWithOptionalCanvas = app as {
|
||||
canvas: typeof app.canvas | undefined
|
||||
}
|
||||
store.viewportCache.set(':root', { scale: 2.5, offset: [150, 250] })
|
||||
appWithOptionalCanvas.canvas = undefined
|
||||
|
||||
try {
|
||||
store.restoreViewport('root')
|
||||
|
||||
expect(mockSetDirty).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
appWithOptionalCanvas.canvas = canvas
|
||||
}
|
||||
})
|
||||
|
||||
it('restores cached viewport', () => {
|
||||
const store = useSubgraphNavigationStore()
|
||||
store.viewportCache.set(':root', { scale: 2.5, offset: [150, 250] })
|
||||
@@ -320,7 +266,7 @@ describe('useSubgraphNavigationStore - Viewport Persistence', () => {
|
||||
expect(mockFitView).toHaveBeenCalledOnce()
|
||||
|
||||
// User navigated away before the inner RAF fired
|
||||
mockCanvas.subgraph = fromPartial<Subgraph>({ id: 'different-graph' })
|
||||
mockCanvas.subgraph = { id: 'different-graph' } as never
|
||||
rafCallbacks[1](performance.now())
|
||||
|
||||
expect(mockRequestSlotSyncAll).not.toHaveBeenCalled()
|
||||
@@ -337,7 +283,7 @@ describe('useSubgraphNavigationStore - Viewport Persistence', () => {
|
||||
expect(rafCallbacks).toHaveLength(1)
|
||||
|
||||
// Simulate graph switching away before rAF fires
|
||||
mockCanvas.subgraph = fromPartial<Subgraph>({ id: 'different-graph' })
|
||||
mockCanvas.subgraph = { id: 'different-graph' } as never
|
||||
|
||||
rafCallbacks[0](performance.now())
|
||||
|
||||
@@ -350,12 +296,12 @@ describe('useSubgraphNavigationStore - Viewport Persistence', () => {
|
||||
const store = useSubgraphNavigationStore()
|
||||
const workflowStore = useWorkflowStore()
|
||||
|
||||
const mockRootGraph = fromPartial<LGraph>({
|
||||
const mockRootGraph = {
|
||||
_nodes: [],
|
||||
nodes: [],
|
||||
subgraphs: new Map(),
|
||||
getNodeById: vi.fn()
|
||||
})
|
||||
} as Partial<LGraph> as LGraph
|
||||
const subgraph1 = {
|
||||
id: 'sub1',
|
||||
rootGraph: mockRootGraph,
|
||||
@@ -367,7 +313,7 @@ describe('useSubgraphNavigationStore - Viewport Persistence', () => {
|
||||
mockCanvas.ds.state.offset = [100, 100]
|
||||
|
||||
// Enter subgraph
|
||||
workflowStore.activeSubgraph = fromPartial<Subgraph>(subgraph1)
|
||||
workflowStore.activeSubgraph = subgraph1 as Partial<Subgraph> as Subgraph
|
||||
await nextTick()
|
||||
|
||||
// Root viewport saved
|
||||
@@ -395,23 +341,6 @@ describe('useSubgraphNavigationStore - Viewport Persistence', () => {
|
||||
expect(mockCanvas.ds.offset).toEqual([100, 100])
|
||||
})
|
||||
|
||||
it('does not save the outgoing viewport while a workflow switch is blocked', async () => {
|
||||
const store = useSubgraphNavigationStore()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const subgraph = fromPartial<Subgraph>({
|
||||
id: 'sub1',
|
||||
isRootGraph: false,
|
||||
rootGraph: app.rootGraph
|
||||
})
|
||||
|
||||
store.saveCurrentViewport()
|
||||
store.viewportCache.clear()
|
||||
workflowStore.activeSubgraph = subgraph
|
||||
await nextTick()
|
||||
|
||||
expect(store.viewportCache.has(':root')).toBe(false)
|
||||
})
|
||||
|
||||
it('preserves pre-existing cache entries across workflow switches', async () => {
|
||||
const store = useSubgraphNavigationStore()
|
||||
const workflowStore = useWorkflowStore()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { fromAny, fromPartial } from '@total-typescript/shoehorn'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
@@ -10,14 +10,9 @@ import {
|
||||
import type { ExportedSubgraph } from '@/lib/litegraph/src/types/serialisation'
|
||||
import { TemplateIncludeOnDistributionEnum } from '@/platform/workflow/templates/types/template'
|
||||
import type { ComfyNodeDef as ComfyNodeDefV1 } from '@/schemas/nodeDefSchema'
|
||||
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import type { GlobalSubgraphData } from '@/scripts/api'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app as comfyApp } from '@/scripts/app'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { useSubgraphStore } from '@/stores/subgraphStore'
|
||||
@@ -41,7 +36,6 @@ vi.mock('@/scripts/api', () => ({
|
||||
storeUserData: vi.fn(),
|
||||
listUserDataFullInfo: vi.fn(),
|
||||
getGlobalSubgraphs: vi.fn(),
|
||||
deleteUserData: vi.fn(),
|
||||
apiURL: vi.fn(),
|
||||
addEventListener: vi.fn()
|
||||
}
|
||||
@@ -104,12 +98,6 @@ describe('useSubgraphStore', () => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
store = useSubgraphStore()
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(useDialogService).mockReturnValue(
|
||||
fromPartial<ReturnType<typeof useDialogService>>({
|
||||
prompt: vi.fn(() => 'testname'),
|
||||
confirm: vi.fn(() => true)
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('should allow publishing of a subgraph', async () => {
|
||||
@@ -146,92 +134,6 @@ describe('useSubgraphStore', () => {
|
||||
await store.publishSubgraph()
|
||||
expect(api.storeUserData).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects publishing when a single subgraph node is not selected', async () => {
|
||||
vi.mocked(comfyApp.canvas).selectedItems = new Set()
|
||||
|
||||
await expect(store.publishSubgraph()).rejects.toThrow(
|
||||
'Must have single SubgraphNode selected to publish'
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects publishing when serialization produces multiple nodes', async () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
vi.mocked(comfyApp.canvas).selectedItems = new Set([subgraphNode])
|
||||
vi.mocked(comfyApp.canvas)._serializeItems = vi.fn(() => ({
|
||||
nodes: [subgraphNode.serialize(), subgraphNode.serialize()],
|
||||
subgraphs: []
|
||||
}))
|
||||
|
||||
await expect(store.publishSubgraph()).rejects.toThrow(
|
||||
'Must have single SubgraphNode selected to publish'
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects publishing when the serialized node is not a subgraph node', async () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
vi.mocked(comfyApp.canvas).selectedItems = new Set([subgraphNode])
|
||||
vi.mocked(comfyApp.canvas).draw = vi.fn()
|
||||
vi.mocked(comfyApp.canvas)._serializeItems = vi.fn(() => ({
|
||||
nodes: [{ ...subgraphNode.serialize(), type: 'missing' }],
|
||||
subgraphs: [
|
||||
Object.assign(fromPartial<ExportedSubgraph>({}), subgraph.serialize())
|
||||
]
|
||||
}))
|
||||
|
||||
await expect(store.publishSubgraph('invalid')).rejects.toThrow(
|
||||
'Loaded subgraph blueprint does not contain valid subgraph'
|
||||
)
|
||||
expect(api.storeUserData).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not publish when the name prompt is cancelled', async () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
vi.mocked(comfyApp.canvas).selectedItems = new Set([subgraphNode])
|
||||
vi.mocked(comfyApp.canvas)._serializeItems = vi.fn(() => ({
|
||||
nodes: [subgraphNode.serialize()],
|
||||
subgraphs: [
|
||||
Object.assign(fromPartial<ExportedSubgraph>({}), subgraph.serialize())
|
||||
]
|
||||
}))
|
||||
vi.mocked(useDialogService).mockReturnValue(
|
||||
fromPartial<ReturnType<typeof useDialogService>>({
|
||||
prompt: vi.fn(() => null),
|
||||
confirm: vi.fn(() => true)
|
||||
})
|
||||
)
|
||||
|
||||
await store.publishSubgraph()
|
||||
|
||||
expect(api.storeUserData).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not overwrite an existing blueprint when confirmation is cancelled', async () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
vi.mocked(comfyApp.canvas).selectedItems = new Set([subgraphNode])
|
||||
vi.mocked(comfyApp.canvas)._serializeItems = vi.fn(() => ({
|
||||
nodes: [subgraphNode.serialize()],
|
||||
subgraphs: [
|
||||
Object.assign(fromPartial<ExportedSubgraph>({}), subgraph.serialize())
|
||||
]
|
||||
}))
|
||||
vi.mocked(useDialogService).mockReturnValue(
|
||||
fromPartial<ReturnType<typeof useDialogService>>({
|
||||
prompt: vi.fn(() => 'test'),
|
||||
confirm: vi.fn(() => false)
|
||||
})
|
||||
)
|
||||
await mockFetch({ 'test.json': mockGraph })
|
||||
|
||||
await store.publishSubgraph('test')
|
||||
|
||||
expect(api.storeUserData).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should display published nodes in the node library', async () => {
|
||||
await mockFetch({ 'test.json': mockGraph })
|
||||
expect(
|
||||
@@ -246,28 +148,6 @@ describe('useSubgraphStore', () => {
|
||||
//check active graph
|
||||
expect(comfyApp.loadGraphData).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('switches into the nested subgraph when editing opens a wrapper graph', async () => {
|
||||
await mockFetch({ 'test.json': mockGraph })
|
||||
const setGraph = vi.fn()
|
||||
const nested = { id: 'nested' }
|
||||
const canvasGraph = fromPartial<NonNullable<typeof comfyApp.canvas.graph>>(
|
||||
{}
|
||||
)
|
||||
Reflect.set(canvasGraph, 'nodes', [{ subgraph: nested }])
|
||||
vi.mocked(comfyApp.canvas).graph = canvasGraph
|
||||
vi.mocked(comfyApp.canvas).setGraph = setGraph
|
||||
|
||||
await store.editBlueprint(BLUEPRINT_TYPE_PREFIX + 'test')
|
||||
|
||||
expect(setGraph).toHaveBeenCalledWith(nested)
|
||||
})
|
||||
|
||||
it('throws when editing an unloaded blueprint', async () => {
|
||||
await expect(
|
||||
store.editBlueprint(BLUEPRINT_TYPE_PREFIX + 'missing')
|
||||
).rejects.toThrow('not yet loaded')
|
||||
})
|
||||
it('should allow subgraphs to be added to graph', async () => {
|
||||
//mock
|
||||
await mockFetch({ 'test.json': mockGraph })
|
||||
@@ -286,12 +166,6 @@ describe('useSubgraphStore', () => {
|
||||
expect(second.nodes[0].id).not.toBe(-1)
|
||||
expect(second.definitions!.subgraphs![0].id).toBe('123')
|
||||
})
|
||||
|
||||
it('throws when getting an unloaded blueprint', () => {
|
||||
expect(() => store.getBlueprint(BLUEPRINT_TYPE_PREFIX + 'missing')).toThrow(
|
||||
'not yet loaded'
|
||||
)
|
||||
})
|
||||
it('should identify user blueprints as non-global', async () => {
|
||||
await mockFetch({ 'test.json': mockGraph })
|
||||
expect(store.isGlobalBlueprint('test')).toBe(false)
|
||||
@@ -314,57 +188,6 @@ describe('useSubgraphStore', () => {
|
||||
expect(store.isGlobalBlueprint('nonexistent')).toBe(false)
|
||||
})
|
||||
|
||||
describe('deleteBlueprint', () => {
|
||||
it('throws for unloaded blueprints', async () => {
|
||||
await expect(
|
||||
store.deleteBlueprint(BLUEPRINT_TYPE_PREFIX + 'missing')
|
||||
).rejects.toThrow('not yet loaded')
|
||||
})
|
||||
|
||||
it('does not delete global blueprints', async () => {
|
||||
await mockFetch(
|
||||
{},
|
||||
{
|
||||
global_bp: {
|
||||
name: 'Global Blueprint',
|
||||
info: { node_pack: 'comfy_essentials' },
|
||||
data: JSON.stringify(mockGraph)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
await store.deleteBlueprint(BLUEPRINT_TYPE_PREFIX + 'global_bp')
|
||||
|
||||
expect(api.deleteUserData).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not delete when confirmation is cancelled', async () => {
|
||||
await mockFetch({ 'test.json': mockGraph })
|
||||
vi.mocked(useDialogService).mockReturnValue(
|
||||
fromPartial<ReturnType<typeof useDialogService>>({
|
||||
prompt: vi.fn(() => 'testname'),
|
||||
confirm: vi.fn(() => false)
|
||||
})
|
||||
)
|
||||
|
||||
await store.deleteBlueprint(BLUEPRINT_TYPE_PREFIX + 'test')
|
||||
|
||||
expect(api.deleteUserData).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('deletes user blueprints after confirmation', async () => {
|
||||
await mockFetch({ 'test.json': mockGraph })
|
||||
vi.mocked(api.deleteUserData).mockResolvedValue({
|
||||
status: 204
|
||||
} as Response)
|
||||
|
||||
await store.deleteBlueprint(BLUEPRINT_TYPE_PREFIX + 'test')
|
||||
|
||||
expect(api.deleteUserData).toHaveBeenCalledWith('subgraphs/test.json')
|
||||
expect(store.isUserBlueprint(BLUEPRINT_TYPE_PREFIX + 'test')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isUserBlueprint', () => {
|
||||
it('should return true for user blueprints', async () => {
|
||||
await mockFetch({ 'test.json': mockGraph })
|
||||
@@ -462,206 +285,6 @@ describe('useSubgraphStore', () => {
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('continues when global blueprint discovery rejects', async () => {
|
||||
vi.mocked(api.listUserDataFullInfo).mockResolvedValue([])
|
||||
vi.mocked(api.getGlobalSubgraphs).mockRejectedValue(
|
||||
new Error('global down')
|
||||
)
|
||||
|
||||
await store.fetchSubgraphs()
|
||||
|
||||
expect(store.subgraphBlueprints).toEqual([])
|
||||
})
|
||||
|
||||
it('reports compact detail when more than three blueprints fail', async () => {
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
await mockFetch(
|
||||
{},
|
||||
{
|
||||
a: { name: 'A', info: { node_pack: 'test' }, data: '' },
|
||||
b: { name: 'B', info: { node_pack: 'test' }, data: '' },
|
||||
c: { name: 'C', info: { node_pack: 'test' }, data: '' },
|
||||
d: { name: 'D', info: { node_pack: 'test' }, data: '' }
|
||||
}
|
||||
)
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledTimes(4)
|
||||
expect(useToastStore().add).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ detail: 'x4' })
|
||||
)
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('ignores invalid user blueprint files during fetch', async () => {
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
await mockFetch({
|
||||
'invalid.json': {
|
||||
nodes: [],
|
||||
definitions: { subgraphs: [] }
|
||||
}
|
||||
})
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
'Failed to load subgraph blueprint',
|
||||
expect.any(Error)
|
||||
)
|
||||
expect(store.subgraphBlueprints).toHaveLength(0)
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('rejects loaded blueprints whose wrapper node does not reference a subgraph', async () => {
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
await mockFetch({
|
||||
'invalid-ref.json': {
|
||||
nodes: [{ id: 1, type: 'missing' }],
|
||||
definitions: { subgraphs: [{ id: 'present' }] }
|
||||
}
|
||||
})
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
'Failed to load subgraph blueprint',
|
||||
expect.any(Error)
|
||||
)
|
||||
expect(store.subgraphBlueprints).toHaveLength(0)
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('rejects loaded blueprints without subgraph definitions', async () => {
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
await mockFetch({
|
||||
'missing-definitions.json': {
|
||||
nodes: [{ id: 1, type: 'missing' }]
|
||||
}
|
||||
})
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
'Failed to load subgraph blueprint',
|
||||
expect.any(Error)
|
||||
)
|
||||
expect(store.subgraphBlueprints).toHaveLength(0)
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('rejects saving a blueprint whose active state has no subgraph definitions', async () => {
|
||||
await mockFetch({ 'test.json': mockGraph })
|
||||
const blueprint = useWorkflowStore().getWorkflowByPath(
|
||||
'subgraphs/test.json'
|
||||
)
|
||||
if (!blueprint?.changeTracker) throw new Error('Blueprint was not loaded')
|
||||
blueprint.changeTracker!.activeState = fromPartial<ComfyWorkflowJSON>({
|
||||
nodes: [{ id: 1, type: '123' }]
|
||||
})
|
||||
|
||||
await expect(blueprint.save()).rejects.toThrow(
|
||||
'The root graph of a subgraph blueprint must consist of only a single subgraph node'
|
||||
)
|
||||
})
|
||||
|
||||
it('marks non-blueprint root nodes when saving an invalid blueprint', async () => {
|
||||
vi.mocked(comfyApp.canvas).draw = vi.fn()
|
||||
await mockFetch({ 'test.json': mockGraph })
|
||||
const blueprint = useWorkflowStore().getWorkflowByPath(
|
||||
'subgraphs/test.json'
|
||||
)
|
||||
if (!blueprint?.changeTracker) throw new Error('Blueprint was not loaded')
|
||||
blueprint.changeTracker!.activeState = fromPartial<ComfyWorkflowJSON>({
|
||||
nodes: [
|
||||
{ id: 1, type: '123' },
|
||||
{ id: 2, type: 'OtherNode' }
|
||||
],
|
||||
definitions: { subgraphs: [{ id: '123' }] }
|
||||
})
|
||||
|
||||
await expect(blueprint.save()).rejects.toThrow(
|
||||
'The root graph of a subgraph blueprint must consist of only a single subgraph node'
|
||||
)
|
||||
expect(comfyApp.canvas.draw).toHaveBeenCalledWith(true, true)
|
||||
})
|
||||
|
||||
it('does not save a loaded blueprint when first-save confirmation is cancelled', async () => {
|
||||
const confirm = vi.fn(() => false)
|
||||
vi.mocked(useDialogService).mockReturnValue(
|
||||
fromPartial<ReturnType<typeof useDialogService>>({
|
||||
prompt: vi.fn(() => 'testname'),
|
||||
confirm
|
||||
})
|
||||
)
|
||||
useSettingStore().settingValues['Comfy.Workflow.WarnBlueprintOverwrite'] =
|
||||
true
|
||||
await mockFetch({ 'test.json': mockGraph })
|
||||
const blueprint = useWorkflowStore().getWorkflowByPath(
|
||||
'subgraphs/test.json'
|
||||
)
|
||||
if (!blueprint) throw new Error('Blueprint was not loaded')
|
||||
|
||||
const result = await blueprint.save()
|
||||
|
||||
expect(result).toBe(blueprint)
|
||||
expect(confirm).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'overwriteBlueprint',
|
||||
itemList: ['test']
|
||||
})
|
||||
)
|
||||
expect(api.storeUserData).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('saves a loaded blueprint after first-save confirmation', async () => {
|
||||
const confirm = vi.fn(() => true)
|
||||
vi.mocked(useDialogService).mockReturnValue(
|
||||
fromPartial<ReturnType<typeof useDialogService>>({
|
||||
prompt: vi.fn(() => 'testname'),
|
||||
confirm
|
||||
})
|
||||
)
|
||||
useSettingStore().settingValues['Comfy.Workflow.WarnBlueprintOverwrite'] =
|
||||
true
|
||||
vi.mocked(api.storeUserData).mockResolvedValue({
|
||||
status: 200,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
path: 'subgraphs/test.json',
|
||||
modified: Date.now(),
|
||||
size: 2
|
||||
})
|
||||
} as Response)
|
||||
await mockFetch({ 'test.json': mockGraph })
|
||||
const blueprint = useWorkflowStore().getWorkflowByPath(
|
||||
'subgraphs/test.json'
|
||||
)
|
||||
if (!blueprint) throw new Error('Blueprint was not loaded')
|
||||
|
||||
await blueprint.save()
|
||||
|
||||
const [path, data, options] = vi.mocked(api.storeUserData).mock.calls[0]
|
||||
if (typeof data !== 'string') throw new Error('Expected saved JSON')
|
||||
expect(path).toBe('subgraphs/test.json')
|
||||
expect(JSON.parse(data)).toMatchObject({
|
||||
nodes: [{ type: '123', title: 'test' }],
|
||||
definitions: { subgraphs: [{ id: '123', name: 'test' }] }
|
||||
})
|
||||
expect(options).toEqual({
|
||||
overwrite: true,
|
||||
throwOnError: true,
|
||||
full_info: true
|
||||
})
|
||||
})
|
||||
|
||||
it('returns an already-loaded blueprint when loading without force', async () => {
|
||||
await mockFetch({ 'test.json': mockGraph })
|
||||
const blueprint = useWorkflowStore().getWorkflowByPath(
|
||||
'subgraphs/test.json'
|
||||
)
|
||||
if (!blueprint) throw new Error('Blueprint was not loaded')
|
||||
|
||||
await blueprint.load()
|
||||
|
||||
expect(api.getUserData).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should handle global blueprint with rejected data promise gracefully', async () => {
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
await mockFetch(
|
||||
@@ -670,7 +293,9 @@ describe('useSubgraphStore', () => {
|
||||
failing_blueprint: {
|
||||
name: 'Failing Blueprint',
|
||||
info: { node_pack: 'test_pack' },
|
||||
data: Promise.reject(new Error('Network error'))
|
||||
data: fromAny<string, unknown>(
|
||||
Promise.reject(new Error('Network error'))
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -781,29 +406,6 @@ describe('useSubgraphStore', () => {
|
||||
expect(nodeDef?.description).toBe('This is a test blueprint')
|
||||
})
|
||||
|
||||
it('does not copy workflowRendererVersion into subgraph metadata on load', async () => {
|
||||
await mockFetch({
|
||||
'metadata-load.json': {
|
||||
nodes: [{ type: '123' }],
|
||||
definitions: {
|
||||
subgraphs: [{ id: '123', extra: {} }]
|
||||
},
|
||||
extra: {
|
||||
BlueprintDescription: 'Loaded description',
|
||||
workflowRendererVersion: 'Vue'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const blueprint = store.getBlueprint(
|
||||
BLUEPRINT_TYPE_PREFIX + 'metadata-load'
|
||||
)
|
||||
|
||||
expect(blueprint.definitions!.subgraphs![0].extra).toEqual({
|
||||
BlueprintDescription: 'Loaded description'
|
||||
})
|
||||
})
|
||||
|
||||
it('should not duplicate metadata in both workflow extra and subgraph extra when publishing', async () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
@@ -863,59 +465,6 @@ describe('useSubgraphStore', () => {
|
||||
expect(subgraphExtra?.BlueprintDescription).toBeUndefined()
|
||||
expect(subgraphExtra?.BlueprintSearchAliases).toBeUndefined()
|
||||
})
|
||||
|
||||
it('keeps workflowRendererVersion in subgraph extra when publishing', async () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
subgraphNode.graph!.add(subgraphNode)
|
||||
|
||||
subgraph.extra = {
|
||||
BlueprintDescription: 'Test description',
|
||||
workflowRendererVersion: 'Vue'
|
||||
}
|
||||
|
||||
vi.mocked(comfyApp.canvas).selectedItems = new Set([subgraphNode])
|
||||
vi.mocked(comfyApp.canvas)._serializeItems = vi.fn(() => {
|
||||
const serializedSubgraph = fromPartial<ExportedSubgraph>({
|
||||
...subgraph.serialize(),
|
||||
links: [],
|
||||
groups: [],
|
||||
version: 1
|
||||
})
|
||||
return {
|
||||
nodes: [subgraphNode.serialize()],
|
||||
subgraphs: [serializedSubgraph]
|
||||
}
|
||||
})
|
||||
|
||||
let savedWorkflowData: Record<string, unknown> | null = null
|
||||
vi.mocked(api.storeUserData).mockImplementation(async (_path, data) => {
|
||||
savedWorkflowData = JSON.parse(data as string)
|
||||
return {
|
||||
status: 200,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
path: 'subgraphs/testname.json',
|
||||
modified: Date.now(),
|
||||
size: 2
|
||||
})
|
||||
} as Response
|
||||
})
|
||||
|
||||
await mockFetch({ 'testname.json': mockGraph })
|
||||
await store.publishSubgraph()
|
||||
|
||||
expect(savedWorkflowData).not.toBeNull()
|
||||
expect(savedWorkflowData!.extra).toEqual({
|
||||
BlueprintDescription: 'Test description'
|
||||
})
|
||||
const definitions = savedWorkflowData!.definitions as {
|
||||
subgraphs: Array<{ extra?: Record<string, unknown> }>
|
||||
}
|
||||
expect(definitions.subgraphs[0]?.extra?.workflowRendererVersion).toBe(
|
||||
'Vue'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('subgraph definition category', () => {
|
||||
|
||||
@@ -4,9 +4,7 @@ import type { NodeExecutionId } from '@/types/nodeIdentification'
|
||||
|
||||
type ExecutionErrorStore = ReturnType<typeof useExecutionErrorStore>
|
||||
|
||||
export function createRequiredInputMissingNodeError(
|
||||
inputName: string
|
||||
): NodeError {
|
||||
function createRequiredInputMissingNodeError(inputName: string): NodeError {
|
||||
return {
|
||||
errors: [
|
||||
{
|
||||
|
||||
@@ -8,12 +8,10 @@ import {
|
||||
hexToRgb,
|
||||
hsbToRgb,
|
||||
hsvaToHex,
|
||||
isColorFormat,
|
||||
isTransparent,
|
||||
luminance,
|
||||
parseToRgb,
|
||||
readableTextColor,
|
||||
rgbToHsl,
|
||||
rgbToHex,
|
||||
textOnColor,
|
||||
toHexFromFormat
|
||||
@@ -238,24 +236,6 @@ describe('colorUtil conversions', () => {
|
||||
it('parses 8-digit hex and ignores the alpha channel in RGB output', () => {
|
||||
expect(parseToRgb('#ff000080')).toEqual({ r: 255, g: 0, b: 0 })
|
||||
})
|
||||
|
||||
it('covers HSL hue sectors outside primary colors', () => {
|
||||
expect(parseToRgb('hsl(60, 100%, 50%)')).toEqual({
|
||||
r: 255,
|
||||
g: 255,
|
||||
b: 0
|
||||
})
|
||||
expect(parseToRgb('hsl(180, 100%, 50%)')).toEqual({
|
||||
r: 0,
|
||||
g: 255,
|
||||
b: 255
|
||||
})
|
||||
expect(parseToRgb('hsl(300, 100%, 50%)')).toEqual({
|
||||
r: 255,
|
||||
g: 0,
|
||||
b: 255
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('hsbToRgb normalization', () => {
|
||||
@@ -267,24 +247,6 @@ describe('colorUtil conversions', () => {
|
||||
b: 255
|
||||
})
|
||||
})
|
||||
|
||||
it('covers all hue ranges', () => {
|
||||
expect(hsbToRgb({ h: 90, s: 100, b: 100 })).toEqual({
|
||||
r: 127,
|
||||
g: 255,
|
||||
b: 0
|
||||
})
|
||||
expect(hsbToRgb({ h: 180, s: 100, b: 100 })).toEqual({
|
||||
r: 0,
|
||||
g: 255,
|
||||
b: 255
|
||||
})
|
||||
expect(hsbToRgb({ h: 300, s: 100, b: 100 })).toEqual({
|
||||
r: 255,
|
||||
g: 0,
|
||||
b: 255
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('isTransparent', () => {
|
||||
@@ -301,27 +263,12 @@ describe('colorUtil conversions', () => {
|
||||
})
|
||||
|
||||
it('returns false for fully opaque hex colors', () => {
|
||||
expect(isTransparent('red')).toBe(false)
|
||||
expect(isTransparent('#ff0000')).toBe(false)
|
||||
expect(isTransparent('#ff0000ff')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('toHexFromFormat', () => {
|
||||
it('normalizes supported hex spellings', () => {
|
||||
expect(toHexFromFormat('', 'hex')).toBe('#000000')
|
||||
expect(toHexFromFormat('abc', 'hex')).toBe('#abc')
|
||||
expect(toHexFromFormat('#abc', 'hex')).toBe('#abc')
|
||||
expect(toHexFromFormat('#abcdef', 'hex')).toBe('#abcdef')
|
||||
expect(toHexFromFormat('abcdef12', 'hex')).toBe('#abcdef12')
|
||||
expect(toHexFromFormat('#abcdef12', 'hex')).toBe('#abcdef12')
|
||||
})
|
||||
|
||||
it('converts rgb strings and rejects non-string rgb input', () => {
|
||||
expect(toHexFromFormat('rgb(255, 128, 0)', 'rgb')).toBe('#ff8000')
|
||||
expect(toHexFromFormat({ r: 255, g: 128, b: 0 }, 'rgb')).toBe('#000000')
|
||||
})
|
||||
|
||||
it('treats an HSV object (with v field) the same as an HSB object', () => {
|
||||
const hsbObject = { h: 120, s: 100, b: 100 }
|
||||
const hsvObject = { h: 120, s: 100, v: 100 }
|
||||
@@ -334,7 +281,6 @@ describe('colorUtil conversions', () => {
|
||||
|
||||
it('returns #000000 for unparseable hsb input', () => {
|
||||
expect(toHexFromFormat({ h: 0 }, 'hsb')).toBe('#000000')
|
||||
expect(toHexFromFormat('hsb()', 'hsb')).toBe('#000000')
|
||||
})
|
||||
|
||||
it('prefixes a bare 6-digit hex with #', () => {
|
||||
@@ -349,22 +295,6 @@ describe('colorUtil conversions', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('rgbToHsl', () => {
|
||||
it('handles light colors and wrapped red hue', () => {
|
||||
expect(rgbToHsl({ r: 255, g: 128, b: 128 }).s).toBeCloseTo(1)
|
||||
expect(rgbToHsl({ r: 255, g: 0, b: 128 }).h).toBeCloseTo(0.916, 2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isColorFormat', () => {
|
||||
it('recognizes public color format ids', () => {
|
||||
expect(isColorFormat('hex')).toBe(true)
|
||||
expect(isColorFormat('rgb')).toBe(true)
|
||||
expect(isColorFormat('hsb')).toBe(true)
|
||||
expect(isColorFormat('hsl')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('readableTextColor / textOnColor', () => {
|
||||
it('lightens dark colors', () => {
|
||||
expect(readableTextColor('#000000')).not.toBe('rgb(0,0,0)')
|
||||
@@ -429,10 +359,6 @@ describe('colorUtil - adjustColor', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('returns the original value when no adjustments are requested', () => {
|
||||
expect(adjustColor('#123456', {})).toBe('#123456')
|
||||
})
|
||||
|
||||
it('treats 5-char hex as valid color with alpha', () => {
|
||||
const result = adjustColor('#f008', {
|
||||
lightness: targetLightness,
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { createAnnotatedPath } from './createAnnotatedPath'
|
||||
|
||||
describe('createAnnotatedPath', () => {
|
||||
it('returns bare input paths for string items by default', () => {
|
||||
expect(createAnnotatedPath('image.png')).toBe('image.png')
|
||||
})
|
||||
|
||||
it('prepends the supplied subfolder for string items', () => {
|
||||
expect(createAnnotatedPath('image.png', { subfolder: 'uploads' })).toBe(
|
||||
'uploads/image.png'
|
||||
)
|
||||
})
|
||||
|
||||
it('annotates string items when the root folder is not input', () => {
|
||||
expect(createAnnotatedPath('image.png', { rootFolder: 'output' })).toBe(
|
||||
'image.png [output]'
|
||||
)
|
||||
})
|
||||
|
||||
it('does not duplicate an existing annotation', () => {
|
||||
expect(
|
||||
createAnnotatedPath('image.png [temp]', { rootFolder: 'temp' })
|
||||
).toBe('image.png [temp]')
|
||||
})
|
||||
|
||||
it('formats result items with their own subfolder', () => {
|
||||
expect(
|
||||
createAnnotatedPath({
|
||||
filename: 'render.png',
|
||||
subfolder: 'final',
|
||||
type: 'output'
|
||||
})
|
||||
).toBe('final/render.png [output]')
|
||||
})
|
||||
|
||||
it('omits the result-item annotation when type matches the root folder', () => {
|
||||
expect(
|
||||
createAnnotatedPath(
|
||||
{
|
||||
filename: 'render.png',
|
||||
subfolder: '',
|
||||
type: 'output'
|
||||
},
|
||||
{ rootFolder: 'output' }
|
||||
)
|
||||
).toBe('render.png')
|
||||
})
|
||||
|
||||
it('handles missing result-item filenames', () => {
|
||||
expect(createAnnotatedPath({ subfolder: 'folder', type: 'temp' })).toBe(
|
||||
'folder/ [temp]'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,60 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const distributionState = vi.hoisted(() => ({
|
||||
isDesktop: false
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
get isDesktop() {
|
||||
return distributionState.isDesktop
|
||||
}
|
||||
}))
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
Reflect.deleteProperty(window, 'electronAPI')
|
||||
Object.defineProperty(window.navigator, 'windowControlsOverlay', {
|
||||
configurable: true,
|
||||
value: undefined
|
||||
})
|
||||
})
|
||||
|
||||
async function importEnvUtil(isDesktop: boolean) {
|
||||
distributionState.isDesktop = isDesktop
|
||||
return await import('@/utils/envUtil')
|
||||
}
|
||||
|
||||
describe('envUtil', () => {
|
||||
it('returns and uses the Electron API when present', async () => {
|
||||
const showContextMenu = vi.fn()
|
||||
Object.defineProperty(window, 'electronAPI', {
|
||||
configurable: true,
|
||||
value: { showContextMenu }
|
||||
})
|
||||
const envUtil = await importEnvUtil(true)
|
||||
|
||||
expect(envUtil.electronAPI()).toEqual({ showContextMenu })
|
||||
|
||||
envUtil.showNativeSystemMenu()
|
||||
|
||||
expect(showContextMenu).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('detects native windows only in desktop window-control overlays', async () => {
|
||||
Object.defineProperty(window.navigator, 'windowControlsOverlay', {
|
||||
configurable: true,
|
||||
value: { visible: true }
|
||||
})
|
||||
|
||||
expect((await importEnvUtil(true)).isNativeWindow()).toBe(true)
|
||||
|
||||
vi.resetModules()
|
||||
expect((await importEnvUtil(false)).isNativeWindow()).toBe(false)
|
||||
})
|
||||
|
||||
it('tolerates a missing Electron API for native menu calls', async () => {
|
||||
const envUtil = await importEnvUtil(true)
|
||||
|
||||
expect(() => envUtil.showNativeSystemMenu()).not.toThrow()
|
||||
})
|
||||
})
|
||||
@@ -1,202 +0,0 @@
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { LGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { LGraphEventMode } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
import { graphToPrompt } from './executionUtil'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
compressWidgetInputSlots: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./litegraphUtil', () => ({
|
||||
compressWidgetInputSlots: mocks.compressWidgetInputSlots
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/litegraph/src/litegraph', () => ({
|
||||
LGraphEventMode: {
|
||||
NEVER: 1,
|
||||
BYPASS: 2
|
||||
},
|
||||
ExecutableNodeDTO: vi.fn(function (node: {
|
||||
id: number | string
|
||||
executionId?: number | string
|
||||
isVirtualNode?: boolean
|
||||
mode?: number
|
||||
widgets?: unknown[]
|
||||
inputs?: unknown[]
|
||||
resolveInput?: (index: number) => unknown
|
||||
comfyClass?: string
|
||||
title?: string
|
||||
dtoInnerNodes?: unknown[]
|
||||
}) {
|
||||
return {
|
||||
id: node.executionId ?? node.id,
|
||||
isVirtualNode: node.isVirtualNode ?? false,
|
||||
mode: node.mode ?? 0,
|
||||
widgets: node.widgets,
|
||||
inputs: node.inputs ?? [],
|
||||
resolveInput: node.resolveInput ?? (() => null),
|
||||
comfyClass: node.comfyClass,
|
||||
title: node.title,
|
||||
getInnerNodes: () => node.dtoInnerNodes ?? []
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
function graphWith(
|
||||
nodes: unknown[],
|
||||
workflowExtra?: Record<string, unknown>,
|
||||
workflowNodes: Array<Record<string, unknown>> = [
|
||||
{
|
||||
inputs: [{ name: 'in', localized_name: 'Input' }],
|
||||
outputs: [{ name: 'out', localized_name: 'Output' }]
|
||||
}
|
||||
]
|
||||
): LGraph {
|
||||
return fromPartial<LGraph>({
|
||||
computeExecutionOrder: vi.fn(() => nodes),
|
||||
serialize: vi.fn(() => ({
|
||||
nodes: workflowNodes,
|
||||
extra: workflowExtra
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
describe('graphToPrompt', () => {
|
||||
it('serializes widget values, links, virtual setup, and workflow metadata', async () => {
|
||||
const virtualApply = vi.fn()
|
||||
const virtualInner = {
|
||||
id: 'virtual-inner',
|
||||
isVirtualNode: true,
|
||||
applyToGraph: virtualApply
|
||||
}
|
||||
const innerOutputNode = {
|
||||
id: 'inner-output',
|
||||
inputs: [],
|
||||
widgets: [],
|
||||
comfyClass: 'InnerClass',
|
||||
title: 'Inner'
|
||||
}
|
||||
const node = {
|
||||
id: 1,
|
||||
getInnerNodes: vi.fn(() => [virtualInner]),
|
||||
dtoInnerNodes: [innerOutputNode],
|
||||
inputs: [
|
||||
{ name: 'missing' },
|
||||
{ name: 'widget-array' },
|
||||
{ name: 'link' },
|
||||
{ name: 'removed' }
|
||||
],
|
||||
widgets: [
|
||||
{ name: '', value: 'ignored' },
|
||||
{ name: 'skipped', value: 'ignored', options: { serialize: false } },
|
||||
{
|
||||
name: 'curve',
|
||||
type: 'curve',
|
||||
serializeValue: vi.fn(async () => [1, 2])
|
||||
},
|
||||
{ name: 'array', value: [3, 4] },
|
||||
{ name: 'plain', value: 'value' }
|
||||
],
|
||||
resolveInput: vi.fn((index: number) => {
|
||||
if (index === 1) return { widgetInfo: { value: [5, 6] } }
|
||||
if (index === 2) return { origin_id: 'inner-output', origin_slot: '7' }
|
||||
if (index === 3) return { origin_id: 'removed-node', origin_slot: '1' }
|
||||
return null
|
||||
}),
|
||||
comfyClass: 'TestClass',
|
||||
title: 'Test'
|
||||
}
|
||||
const graph = graphWith([node])
|
||||
|
||||
const { workflow, output } = await graphToPrompt(graph, { sortNodes: true })
|
||||
|
||||
expect(virtualApply).toHaveBeenCalledTimes(1)
|
||||
expect(graph.serialize).toHaveBeenCalledWith({ sortNodes: true })
|
||||
expect(mocks.compressWidgetInputSlots).toHaveBeenCalledWith(workflow)
|
||||
expect(workflow.nodes[0].inputs?.[0]).toEqual({ name: 'in' })
|
||||
expect(workflow.nodes[0].outputs?.[0]).toEqual({ name: 'out' })
|
||||
expect(workflow.extra?.frontendVersion).toBeDefined()
|
||||
expect(output['1']).toEqual({
|
||||
inputs: {
|
||||
curve: { __type__: 'CURVE', __value__: [1, 2] },
|
||||
array: { __value__: [3, 4] },
|
||||
plain: 'value',
|
||||
'widget-array': { __value__: [5, 6] },
|
||||
link: ['inner-output', 7]
|
||||
},
|
||||
class_type: 'TestClass',
|
||||
_meta: { title: 'Test' }
|
||||
})
|
||||
expect(output['inner-output']).toEqual({
|
||||
inputs: {},
|
||||
class_type: 'InnerClass',
|
||||
_meta: { title: 'Inner' }
|
||||
})
|
||||
})
|
||||
|
||||
it('skips muted and virtual executable nodes', async () => {
|
||||
const normalNode = {
|
||||
id: 'normal',
|
||||
inputs: [],
|
||||
comfyClass: 'Normal',
|
||||
title: 'Normal'
|
||||
}
|
||||
const mutedNode = {
|
||||
id: 'muted',
|
||||
mode: LGraphEventMode.NEVER,
|
||||
inputs: [],
|
||||
widgets: [{ name: 'value', value: 'ignored' }],
|
||||
comfyClass: 'Muted',
|
||||
title: 'Muted',
|
||||
dtoInnerNodes: [
|
||||
{
|
||||
id: 'muted-inner',
|
||||
inputs: [],
|
||||
comfyClass: 'MutedInner',
|
||||
title: 'MutedInner'
|
||||
}
|
||||
]
|
||||
}
|
||||
const bypassedNode = {
|
||||
id: 'bypassed',
|
||||
mode: LGraphEventMode.BYPASS,
|
||||
inputs: [],
|
||||
comfyClass: 'Bypassed',
|
||||
title: 'Bypassed'
|
||||
}
|
||||
const virtualNode = {
|
||||
id: 'virtual',
|
||||
isVirtualNode: true,
|
||||
inputs: [],
|
||||
comfyClass: 'Virtual',
|
||||
title: 'Virtual'
|
||||
}
|
||||
const graph = graphWith(
|
||||
[normalNode, mutedNode, bypassedNode, virtualNode],
|
||||
{}
|
||||
)
|
||||
|
||||
const { workflow, output } = await graphToPrompt(graph)
|
||||
|
||||
expect(graph.serialize).toHaveBeenCalledWith({ sortNodes: false })
|
||||
expect(workflow.extra?.frontendVersion).toBeDefined()
|
||||
expect(Object.keys(output)).toEqual(['normal'])
|
||||
})
|
||||
|
||||
it('preserves serialized workflow nodes without slot arrays', async () => {
|
||||
const node = {
|
||||
id: 1,
|
||||
inputs: [],
|
||||
comfyClass: 'NodeClass',
|
||||
title: 'Node'
|
||||
}
|
||||
const graph = graphWith([node], {}, [{ id: 1 }])
|
||||
|
||||
const { workflow } = await graphToPrompt(graph)
|
||||
|
||||
expect(workflow.nodes[0]).toEqual({ id: 1 })
|
||||
})
|
||||
})
|
||||
@@ -11,8 +11,8 @@ interface FilterItem {
|
||||
options: string[]
|
||||
}
|
||||
|
||||
function makeSearch<T>(data: T[] = []) {
|
||||
return new FuseSearch<T>(data, {
|
||||
const makeSearch = <T>(data: T[] = []) =>
|
||||
new FuseSearch<T>(data, {
|
||||
fuseOptions: {
|
||||
keys: ['name'],
|
||||
includeScore: true,
|
||||
@@ -21,7 +21,6 @@ function makeSearch<T>(data: T[] = []) {
|
||||
},
|
||||
advancedScoring: true
|
||||
})
|
||||
}
|
||||
|
||||
describe('FuseSearch', () => {
|
||||
it('assigns stable ranking tiers for exact, prefix, word, substring, and multi-part matches', () => {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
// oxlint-disable no-misused-spread
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type {
|
||||
@@ -10,27 +9,21 @@ import type {
|
||||
import {
|
||||
collectAllNodes,
|
||||
collectFromNodes,
|
||||
executionIdToNodeLocatorId,
|
||||
findNodeInHierarchy,
|
||||
findSubgraphByUuid,
|
||||
findSubgraphPathById,
|
||||
getActiveGraphNodeIds,
|
||||
forEachNode,
|
||||
forEachSubgraphNode,
|
||||
getAllNonIoNodesInSubgraph,
|
||||
getExecutionIdsForSelectedNodes,
|
||||
getLocalNodeIdFromExecutionId,
|
||||
getLocatorIdFromNodeData,
|
||||
getNodeByExecutionId,
|
||||
getNodeByLocatorId,
|
||||
getRootGraph,
|
||||
getRootParentNode,
|
||||
getSubgraphPathFromExecutionId,
|
||||
getExecutionIdFromNodeData,
|
||||
mapAllNodes,
|
||||
mapSubgraphNodes,
|
||||
parseExecutionId,
|
||||
reduceAllNodes,
|
||||
traverseNodesDepthFirst,
|
||||
traverseSubgraphPath,
|
||||
triggerCallbackOnAllNodes,
|
||||
@@ -43,7 +36,6 @@ import {
|
||||
isMissingCandidateActive
|
||||
} from '@/utils/graphTraversalUtil'
|
||||
import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
|
||||
import { createNodeExecutionId } from '@/types/nodeIdentification'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
|
||||
import { createMockLGraphNode } from './__tests__/litegraphTestUtils'
|
||||
@@ -105,8 +97,6 @@ describe('graphTraversalUtil', () => {
|
||||
expect(findNodeInHierarchy(graph, '')).toBeNull()
|
||||
expect(getExecutionIdForNodeInGraph(graph, graph, '')).toBeNull()
|
||||
expect(getExecutionIdFromNodeData(graph, { id: '' })).toBeNull()
|
||||
expect(getLocatorIdFromNodeData({ id: '' })).toBeNull()
|
||||
expect(executionIdToNodeLocatorId(graph, '')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -225,12 +215,6 @@ describe('graphTraversalUtil', () => {
|
||||
const result = traverseSubgraphPath(graph, ['999'])
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for an unparseable path segment', () => {
|
||||
const graph = createMockGraph([])
|
||||
|
||||
expect(traverseSubgraphPath(graph, [''])).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -517,17 +501,6 @@ describe('graphTraversalUtil', () => {
|
||||
})
|
||||
|
||||
describe('findSubgraphByUuid', () => {
|
||||
it('uses the root graph subgraph registry when available', () => {
|
||||
const subgraph = createMockSubgraph('registered-uuid', [])
|
||||
const graph = {
|
||||
...createMockGraph([]),
|
||||
subgraphs: new Map([[subgraph.id, subgraph]])
|
||||
} satisfies Partial<LGraph> as LGraph
|
||||
|
||||
expect(findSubgraphByUuid(graph, subgraph.id)).toBe(subgraph)
|
||||
expect(findSubgraphByUuid(graph, 'missing-uuid')).toBeNull()
|
||||
})
|
||||
|
||||
it('should find subgraph by UUID', () => {
|
||||
const targetUuid = 'target-uuid'
|
||||
const subgraph = createMockSubgraph(targetUuid, [])
|
||||
@@ -573,60 +546,6 @@ describe('graphTraversalUtil', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('findSubgraphPathById', () => {
|
||||
it('returns the path to a nested subgraph', () => {
|
||||
const targetSubgraph = createMockSubgraph('target-uuid', [])
|
||||
const middleNode = createMockNode('20', {
|
||||
isSubgraph: true,
|
||||
subgraph: targetSubgraph
|
||||
})
|
||||
const middleSubgraph = createMockSubgraph('middle-uuid', [middleNode])
|
||||
const rootNode = createMockNode('10', {
|
||||
isSubgraph: true,
|
||||
subgraph: middleSubgraph
|
||||
})
|
||||
const graph = createMockGraph([rootNode])
|
||||
|
||||
expect(findSubgraphPathById(graph, 'target-uuid')).toEqual([
|
||||
'middle-uuid',
|
||||
'target-uuid'
|
||||
])
|
||||
})
|
||||
|
||||
it('skips malformed graph entries while searching', () => {
|
||||
const malformedSubgraph = fromPartial<Subgraph>({
|
||||
id: 'malformed',
|
||||
nodes: []
|
||||
})
|
||||
const graph = createMockGraph([
|
||||
createMockNode('10', {
|
||||
isSubgraph: true,
|
||||
subgraph: malformedSubgraph
|
||||
})
|
||||
])
|
||||
|
||||
expect(findSubgraphPathById(graph, 'missing-uuid')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('getRootParentNode', () => {
|
||||
it('returns null for root-level or invalid execution IDs', () => {
|
||||
const graph = createMockGraph([createMockNode('10')])
|
||||
|
||||
expect(getRootParentNode(graph, '10')).toBeNull()
|
||||
expect(getRootParentNode(graph, '')).toBeNull()
|
||||
expect(getRootParentNode(graph, 'invalid:20')).toBeNull()
|
||||
})
|
||||
|
||||
it('returns the root parent for a nested execution ID', () => {
|
||||
const parent = createMockNode('10')
|
||||
const graph = createMockGraph([parent])
|
||||
|
||||
expect(getRootParentNode(graph, '10:20:30')).toBe(parent)
|
||||
expect(getRootParentNode(graph, '10:20:30')?.id).toBe('10')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getNodeByExecutionId', () => {
|
||||
it('should find node in root graph', () => {
|
||||
const nodes = [createMockNode('123'), createMockNode('456')]
|
||||
@@ -697,14 +616,6 @@ describe('graphTraversalUtil', () => {
|
||||
})
|
||||
|
||||
describe('getExecutionIdByNode', () => {
|
||||
it('returns null when the node has no graph', () => {
|
||||
const node = createMockNode('123')
|
||||
node.graph = null
|
||||
const graph = createMockGraph([])
|
||||
|
||||
expect(getExecutionIdByNode(graph, node)).toBeNull()
|
||||
})
|
||||
|
||||
it('should return node id if graph is rootGraph', () => {
|
||||
const node = createMockNode('123')
|
||||
const graph = createMockGraph([node])
|
||||
@@ -809,7 +720,7 @@ describe('graphTraversalUtil', () => {
|
||||
subgraph
|
||||
})
|
||||
const rootGraph = createMockGraph([subgraphNode])
|
||||
interior.graph = null
|
||||
interior.graph = null as unknown as LGraph
|
||||
|
||||
expect(
|
||||
getExecutionIdForNodeInGraph(rootGraph, subgraph, interior.id)
|
||||
@@ -825,21 +736,6 @@ describe('graphTraversalUtil', () => {
|
||||
getExecutionIdForNodeInGraph(rootGraph, orphanSubgraph, '63')
|
||||
).toBe('63')
|
||||
})
|
||||
|
||||
it('falls back to local id when the parent path is not parseable', () => {
|
||||
const interior = createMockNode('63')
|
||||
const subgraph = createMockSubgraph('sub-uuid', [interior])
|
||||
const subgraphNode = createMockLGraphNode({
|
||||
id: toNodeId(''),
|
||||
isSubgraphNode: () => true,
|
||||
subgraph
|
||||
}) satisfies Partial<LGraphNode> as LGraphNode
|
||||
const rootGraph = createMockGraph([subgraphNode])
|
||||
|
||||
expect(getExecutionIdForNodeInGraph(rootGraph, subgraph, '63')).toBe(
|
||||
'63'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isAncestorPathActive', () => {
|
||||
@@ -912,22 +808,6 @@ describe('graphTraversalUtil', () => {
|
||||
})
|
||||
|
||||
describe('isExecutionPathActive', () => {
|
||||
it('returns true when the graph is unavailable', () => {
|
||||
expect(isExecutionPathActive(null, '42')).toBe(true)
|
||||
expect(isExecutionPathActive(undefined, '42')).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false when the target node cannot be resolved', () => {
|
||||
expect(isExecutionPathActive(createMockGraph([]), '42')).toBe(false)
|
||||
})
|
||||
|
||||
it('returns true for an active target with active ancestors', () => {
|
||||
const node = createMockNode('42')
|
||||
const rootGraph = createMockGraph([node])
|
||||
|
||||
expect(isExecutionPathActive(rootGraph, '42')).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false when the target node itself is bypassed', () => {
|
||||
const node = createMockLGraphNode({
|
||||
id: 42,
|
||||
@@ -1034,10 +914,6 @@ describe('graphTraversalUtil', () => {
|
||||
})
|
||||
|
||||
describe('isCandidateScopeActive', () => {
|
||||
it('treats candidates without a scoped node as active', () => {
|
||||
expect(isCandidateScopeActive(createMockGraph([]), {})).toBe(true)
|
||||
})
|
||||
|
||||
it('uses sourceExecutionId before nodeId', () => {
|
||||
const rootNode = createMockNode('65')
|
||||
const sourceNode = createMockLGraphNode({
|
||||
@@ -1142,51 +1018,6 @@ describe('graphTraversalUtil', () => {
|
||||
const found = getNodeByLocatorId(graph, locatorId)
|
||||
expect(found).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when the root graph is unavailable', () => {
|
||||
expect(getNodeByLocatorId(null, '123')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('executionIdToNodeLocatorId', () => {
|
||||
it('returns a root locator for root execution IDs', () => {
|
||||
const graph = createMockGraph([])
|
||||
|
||||
expect(executionIdToNodeLocatorId(graph, '123')).toBe('123')
|
||||
})
|
||||
|
||||
it('returns a subgraph locator for nested execution IDs', () => {
|
||||
const targetNode = createMockNode('789')
|
||||
const subgraph = createMockSubgraph(
|
||||
'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
|
||||
[targetNode]
|
||||
)
|
||||
const graph = createMockGraph([
|
||||
createMockNode('456', { isSubgraph: true, subgraph })
|
||||
])
|
||||
|
||||
expect(executionIdToNodeLocatorId(graph, '456:789')).toBe(
|
||||
'a1b2c3d4-e5f6-7890-abcd-ef1234567890:789'
|
||||
)
|
||||
})
|
||||
|
||||
it('returns undefined when the nested path cannot be resolved', () => {
|
||||
const graph = createMockGraph([createMockNode('456')])
|
||||
|
||||
expect(executionIdToNodeLocatorId(graph, '456:789')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns undefined when the nested local id is invalid', () => {
|
||||
const subgraph = createMockSubgraph(
|
||||
'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
|
||||
[]
|
||||
)
|
||||
const graph = createMockGraph([
|
||||
createMockNode('456', { isSubgraph: true, subgraph })
|
||||
])
|
||||
|
||||
expect(executionIdToNodeLocatorId(graph, '456:')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('getRootGraph', () => {
|
||||
@@ -1376,27 +1207,7 @@ describe('graphTraversalUtil', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('reduceAllNodes', () => {
|
||||
it('reduces nodes across nested subgraphs', () => {
|
||||
const subgraph = createMockSubgraph('sub-uuid', [createMockNode('10')])
|
||||
const graph = createMockGraph([
|
||||
createMockNode('1'),
|
||||
createMockNode('2', { isSubgraph: true, subgraph })
|
||||
])
|
||||
|
||||
expect(
|
||||
reduceAllNodes(graph, (sum, node) => sum + Number(node.id), 0)
|
||||
).toBe(13)
|
||||
})
|
||||
})
|
||||
|
||||
describe('traverseNodesDepthFirst', () => {
|
||||
it('handles missing traversal options', () => {
|
||||
const node = createMockNode('1')
|
||||
|
||||
expect(() => traverseNodesDepthFirst([node])).not.toThrow()
|
||||
})
|
||||
|
||||
it('should traverse nodes in depth-first order', () => {
|
||||
const visited: string[] = []
|
||||
const nodes = [
|
||||
@@ -1488,15 +1299,6 @@ describe('graphTraversalUtil', () => {
|
||||
})
|
||||
|
||||
describe('collectFromNodes', () => {
|
||||
it('collects nodes with default options', () => {
|
||||
const nodes = [createMockNode('1'), createMockNode('2')]
|
||||
|
||||
expect(collectFromNodes(nodes).map((node) => String(node.id))).toEqual([
|
||||
'2',
|
||||
'1'
|
||||
])
|
||||
})
|
||||
|
||||
it('should collect data from all nodes', () => {
|
||||
const nodes = [
|
||||
createMockNode('1'),
|
||||
@@ -1781,44 +1583,6 @@ describe('graphTraversalUtil', () => {
|
||||
|
||||
expect(executionIds).toEqual(['2:10', '2:11'])
|
||||
})
|
||||
|
||||
it('returns an empty list when the starting subgraph is unreachable', () => {
|
||||
const rootGraph = createMockGraph([])
|
||||
const orphanSubgraph = createMockSubgraph('orphan', [], rootGraph)
|
||||
createMockNode('10', { graph: orphanSubgraph })
|
||||
|
||||
expect(
|
||||
getExecutionIdsForSelectedNodes(orphanSubgraph.nodes, orphanSubgraph)
|
||||
).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('getActiveGraphNodeIds', () => {
|
||||
it('returns local ids for execution ids in the active graph', () => {
|
||||
const rootNode = createMockNode('1')
|
||||
const subNode = createMockNode('10')
|
||||
const subgraph = createMockSubgraph('sub-uuid', [subNode])
|
||||
const subgraphNode = createMockNode('2', {
|
||||
isSubgraph: true,
|
||||
subgraph
|
||||
})
|
||||
const graph = createMockGraph([rootNode, subgraphNode])
|
||||
rootNode.graph = graph
|
||||
subgraphNode.graph = graph
|
||||
subNode.graph = subgraph
|
||||
const executionIds = new Set([
|
||||
createNodeExecutionId([toNodeId(1)]),
|
||||
createNodeExecutionId([toNodeId(2), toNodeId(10)]),
|
||||
createNodeExecutionId([toNodeId(999)])
|
||||
])
|
||||
|
||||
expect(getActiveGraphNodeIds(graph, graph, executionIds)).toEqual(
|
||||
new Set(['1'])
|
||||
)
|
||||
expect(getActiveGraphNodeIds(graph, subgraph, executionIds)).toEqual(
|
||||
new Set(['10'])
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -549,7 +549,7 @@ export function getExecutionIdFromNodeData(
|
||||
* @returns The node if found, null otherwise
|
||||
*/
|
||||
export function getNodeByLocatorId(
|
||||
rootGraph: LGraph | null,
|
||||
rootGraph: LGraph,
|
||||
locatorId: string
|
||||
): LGraphNode | null {
|
||||
if (!rootGraph) return null
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { createGridStyle } from '@/utils/gridUtil'
|
||||
|
||||
describe('createGridStyle', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('uses auto-fill columns by default', () => {
|
||||
expect(createGridStyle()).toEqual({
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(15rem, 1fr))',
|
||||
padding: '0',
|
||||
gap: '1rem'
|
||||
})
|
||||
})
|
||||
|
||||
it('uses fixed columns when provided', () => {
|
||||
expect(
|
||||
createGridStyle({
|
||||
columns: 3,
|
||||
padding: '8px',
|
||||
gap: '4px'
|
||||
})
|
||||
).toEqual({
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(3, 1fr)',
|
||||
padding: '8px',
|
||||
gap: '4px'
|
||||
})
|
||||
})
|
||||
|
||||
it('warns and clamps invalid fixed columns', () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
|
||||
expect(createGridStyle({ columns: -1 }).gridTemplateColumns).toBe(
|
||||
'repeat(1, 1fr)'
|
||||
)
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
'createGridStyle: columns must be >= 1, defaulting to 1'
|
||||
)
|
||||
})
|
||||
})
|
||||