mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-18 17:58:23 +00:00
Compare commits
24 Commits
fix/be-186
...
drjkl/subg
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba7a45239a | ||
|
|
b8bb67bcc7 | ||
|
|
9e7ea6a9d9 | ||
|
|
83b2104983 | ||
|
|
66c89c8e52 | ||
|
|
676a392187 | ||
|
|
4b2ae3fd84 | ||
|
|
6a33fd4a05 | ||
|
|
e8272776aa | ||
|
|
0c0c3d9f2e | ||
|
|
7dd1b7da4b | ||
|
|
a9213760c8 | ||
|
|
ae506465c7 | ||
|
|
c27921f172 | ||
|
|
7f80f72a62 | ||
|
|
a0470f5116 | ||
|
|
519191b5f6 | ||
|
|
dbabf2de3c | ||
|
|
d6c9565622 | ||
|
|
d1fb94dc7d | ||
|
|
71cb98f7b3 | ||
|
|
559bc10b94 | ||
|
|
07c3b324ca | ||
|
|
2ab95698cf |
@@ -9,18 +9,13 @@ Cherry-pick backport management for Comfy-Org/ComfyUI_frontend stable release br
|
||||
|
||||
## Quick Start
|
||||
|
||||
1. **Discover** — Collect candidates from Slack bot + git log gap, then **reconcile both lists** (`reference/discovery.md`)
|
||||
2. **Pre-filter by path** — Auto-skip PRs whose changed files are entirely under `apps/website/`, `browser_tests/`, `.github/`, `packages/design-system/`, `packages/{cloud,registry}-types/`, `.claude/`, `docs/`. Don't read PR bodies for these — they don't ship to core ComfyUI users (`reference/analysis.md`)
|
||||
3. **Verify target file existence** — For each surviving candidate, run `git cat-file -e origin/$TARGET:$path` for primary changed files. If they don't exist on the target, auto-mark SKIP with reason `feature-not-on-branch`
|
||||
4. **Tiered triage** — Bucket into **Tier 1 (core editor must-haves)**, **Tier 2 (cloud-distribution only)**, **Tier 3 (skip)** before reviewing individually (`reference/analysis.md`)
|
||||
5. **Analyze** — Categorize remaining MUST/SHOULD, check deps (`reference/analysis.md`)
|
||||
6. **Human Review** — Present candidates in batches for interactive approval, with tier context attached (see Interactive Approval Flow)
|
||||
7. **Plan** — Order by dependency (leaf fixes first), group into waves per branch
|
||||
8. **Test-then-resolve dry-run** — Classify clean vs conflict before committing time (`reference/execution.md`)
|
||||
9. **Execute** — Label-driven automation for clean PRs → worktree fallback for conflicts (`reference/execution.md`)
|
||||
10. **Public-API conflict review** — If conflict resolution touches a public LiteGraph callback, extension API, or `node.*` method, consult oracle for compat-regression review BEFORE pushing (`reference/execution.md`)
|
||||
11. **Verify** — Per-PR validation (typecheck + targeted tests + lint on changed files) AND per-wave verification (full typecheck + test:unit on branch HEAD)
|
||||
12. **Log & Report** — Generate session report + author accountability report + Slack status update (`reference/logging.md`)
|
||||
1. **Discover** — Collect candidates from Slack bot + git log gap (`reference/discovery.md`)
|
||||
2. **Analyze** — Categorize MUST/SHOULD/SKIP, check deps (`reference/analysis.md`)
|
||||
3. **Human Review** — Present candidates in batches for interactive approval (see Interactive Approval Flow)
|
||||
4. **Plan** — Order by dependency (leaf fixes first), group into waves per branch
|
||||
5. **Execute** — Label-driven automation → worktree fallback for conflicts (`reference/execution.md`)
|
||||
6. **Verify** — After each wave, verify branch integrity before proceeding
|
||||
7. **Log & Report** — Generate session report (`reference/logging.md`)
|
||||
|
||||
## System Context
|
||||
|
||||
@@ -112,35 +107,6 @@ Husky hooks fail in worktrees (can't find lint-staged config). Always use `git p
|
||||
|
||||
In the 2026-04-06 session: core/1.42 got 18/26 auto-PRs, cloud/1.42 got only 1/25. The cloud branch has more divergence. **Always plan for manual fallback** — don't assume automation will handle most PRs.
|
||||
|
||||
### Cherry-Picked Tests Can Reference Files Added By Earlier Unbackported PRs
|
||||
|
||||
A common conflict: PR A on main modifies a test file that was _added_ on main by an earlier PR B (not backported to the target). The cherry-pick of A reports "modify/delete" on B's test file because the file doesn't exist on the target. Adding the new file would smuggle in B's test scaffolding without B's runtime changes.
|
||||
|
||||
**Detection:** Conflict says `deleted in HEAD and modified in <PR>`. Verify with:
|
||||
|
||||
```bash
|
||||
git log --diff-filter=A --oneline origin/main -- path/to/test.ts
|
||||
```
|
||||
|
||||
If the introducing commit is **not** on the target branch, the test file isn't a real prerequisite for the runtime fix.
|
||||
|
||||
**Fix:** `git rm` the test file (drop it from the backport). Document in the commit body which PR introduced it on main and why dropping it is safe. The runtime fix itself usually doesn't depend on these tests — coverage exists at the integration layer.
|
||||
|
||||
### Backport-Only Compatibility Shims
|
||||
|
||||
When a PR's _mechanism_ relies on changes upstream that aren't on the older branch, a literal cherry-pick can recreate the original bug for any consumer still using the old contract. This is most dangerous for **public LiteGraph callbacks, extension APIs, and `node.*` methods** that custom-node packages depend on.
|
||||
|
||||
**Real example (#11541, core/1.43 backport):** The PR removed `LGraphNode.vue`'s legacy `handled === true` sync-return check from `handleDrop`, replacing it with `await node.onDragDrop(event, true)`. Safe on `main` because all in-repo `onDragDrop` handlers had migrated to participate in the new `claimEvent` flag. On `core/1.43`, `onDragDrop` is a public callback — custom-node packages with synchronous `onDragDrop` returning `true` would no longer have their event claimed, recreating the duplicate-node-creation bug the PR was fixing.
|
||||
|
||||
**Detection:** The PR's diff modifies a file that is part of a public extension API surface. Look for:
|
||||
|
||||
- `node.onXxx` callback assignments
|
||||
- Methods on `LGraphNode`, `LGraphCanvas`, `LGraph`, `Subgraph`
|
||||
- Public exports from `src/lib/litegraph/`
|
||||
- Type changes affecting `litegraph-augmentation.d.ts`
|
||||
|
||||
**Fix:** Add a backport-only compatibility shim that preserves the old contract while keeping the new fix. Document it explicitly in the commit body under a `## Backport-only compatibility fix` heading. Consult oracle for review before pushing — a bad shim is worse than no fix.
|
||||
|
||||
## Conflict Triage
|
||||
|
||||
**Always categorize before deciding to skip. High conflict count ≠ hard conflicts.**
|
||||
@@ -181,26 +147,6 @@ Skip these without discussion:
|
||||
- **Features not on target branch** — e.g., Painter, GLSLShader, appModeStore on core/1.40
|
||||
- **Cloud-only PRs on core/\* branches** — Team workspaces, cloud queue, cloud-only login. (Note: app mode and Firebase auth are NOT cloud-only — see Branch Scope Rules)
|
||||
|
||||
### Path Pre-Filter (run BEFORE reading PR bodies)
|
||||
|
||||
For 50+ candidate PRs, classify by changed paths first to skip the unproductive ones without spending time on triage. Run `git show --stat $SHA` (or `gh pr view --json files`) and bucket:
|
||||
|
||||
| Path prefix | Bucket | Reason |
|
||||
| ---------------------------------------------- | ---------------------- | ------------------------------------------------ |
|
||||
| `apps/website/` | SKIP | Marketing/platform site, not core ComfyUI bundle |
|
||||
| `apps/desktop-ui/` | SKIP for `core/*` | Desktop app, separate release cadence |
|
||||
| `browser_tests/` only (no `src/`) | SKIP | Test-only |
|
||||
| `.github/workflows/` only | SKIP | CI/release infra |
|
||||
| `packages/design-system/` only | SKIP | Design tokens, not core |
|
||||
| `packages/{cloud,registry,ingest}-types/` only | SKIP | Generated types |
|
||||
| `.claude/`, `.agents/`, `docs/` | SKIP | Agent / documentation |
|
||||
| `*.stories.ts` only | SKIP | Storybook only |
|
||||
| `src/` (core editor) | KEEP — analyze further | Runtime/editor code that requires full triage |
|
||||
|
||||
A PR touches multiple paths? Keep it if **any** changed file is under `src/` (or other core paths) and run normal analysis. Auto-skip is conservative — only skip when _all_ paths match the SKIP buckets.
|
||||
|
||||
This filter alone removes ~30-50% of candidates in a typical session, leaving only the PRs that need real triage.
|
||||
|
||||
## Wave Verification
|
||||
|
||||
After merging each wave of PRs to a target branch, verify branch integrity before proceeding:
|
||||
|
||||
@@ -39,89 +39,6 @@ Check before backporting — these don't exist on older branches:
|
||||
- **App builder** — check per branch
|
||||
- **appModeStore.ts** — not on core/1.40
|
||||
|
||||
### Verify Target File Existence (Run Before Cherry-Pick)
|
||||
|
||||
Before cherry-picking any PR, confirm the files it modifies actually exist on the target branch. If they don't, the PR's runtime fix is for a feature that hasn't been added yet — skip cleanly without attempting cherry-pick:
|
||||
|
||||
```bash
|
||||
# For each file the PR changes
|
||||
for f in $(gh pr view $PR --json files --jq '.files[].path' | grep -v "^browser_tests/\|\.test\." ); do
|
||||
if ! git cat-file -e origin/$TARGET:$f 2>/dev/null; then
|
||||
echo "MISSING on $TARGET: $f"
|
||||
fi
|
||||
done
|
||||
```
|
||||
|
||||
If the _primary_ changed files (the runtime ones, not tests) are missing, mark the PR `SKIP / feature-not-on-branch`. This is faster than letting cherry-pick fail with modify/delete conflicts and gives a clean signal.
|
||||
|
||||
This check is the first thing that runs after the path pre-filter and BEFORE you spend time reading PR descriptions.
|
||||
|
||||
## Tiered Triage (Recommended for 30+ Candidates)
|
||||
|
||||
Before the interactive Y/N approval flow, bucket all surviving candidates into three tiers. This surfaces release-engineering decisions that a flat MUST/SHOULD list obscures:
|
||||
|
||||
### Tier 1 — Core Editor Must-Haves
|
||||
|
||||
User-facing bugs, crashes, data corruption, or security issues in code paths that exist on the target branch. These are the strongest backport candidates.
|
||||
|
||||
Indicators:
|
||||
|
||||
- `fix:` prefix and the bug is reproducible on the target branch
|
||||
- Crash guards, runtime null checks, race-condition fixes
|
||||
- Data-loss bugs (state not persisted, duplicates, drops)
|
||||
- Security hardening (CSRF, XSS, auth)
|
||||
- Vue Nodes 2.0 regression cluster (if the target ships Vue Nodes 2.0)
|
||||
- Subgraph correctness fixes
|
||||
- Public-API extension callback fixes
|
||||
|
||||
Recommend `Y` to user.
|
||||
|
||||
### Tier 2 — Cloud-Distribution Only
|
||||
|
||||
Bugs that only manifest on cloud-hosted distributions (Secrets panel, subscription flows, cloud signup, workspace tracking, etc.). Whether to backport depends on whether cloud ships from the target `core/*` branch in your release matrix.
|
||||
|
||||
Indicators:
|
||||
|
||||
- Files under `src/platform/secrets/`, `src/platform/subscription/`, signup flows
|
||||
- PR description mentions cloud staging issues
|
||||
- Fix gated behind cloud feature flags
|
||||
|
||||
Default: ask the cloud release rotation owner. If unsure, defer.
|
||||
|
||||
### Tier 3 — Skip
|
||||
|
||||
Path pre-filter caught most of these. The rest are PRs where the diff _touches_ `src/` but the practical impact is non-user-facing or scoped to features the target doesn't ship.
|
||||
|
||||
Indicators:
|
||||
|
||||
- All changes in test files even if the PR touched `src/` test files
|
||||
- Storybook stories only
|
||||
- Lint config / lint rule additions
|
||||
- Documentation comments
|
||||
- Internal refactors with no behavior change
|
||||
|
||||
### Presentation Format
|
||||
|
||||
When showing tier results to the user, format as:
|
||||
|
||||
```text
|
||||
Tier 1 (N PRs) — strong backport candidates
|
||||
- #11541 fix: stop duplicate node creation when dropping image on Vue nodes
|
||||
Why: Vue Nodes 2.0 regression — async onDragDrop bypassed handled-check, drops bubble to document, spawns extra LoadImage nodes
|
||||
- #10849 fix: store promoted widget values per SubgraphNode instance
|
||||
Why: Multiple instances overwriting each other's promoted widget values — data loss
|
||||
|
||||
Tier 2 (N PRs) — cloud-distribution release rotation should decide
|
||||
- #11636 fix: enable Chrome password autofill on signup form
|
||||
- ...
|
||||
|
||||
Tier 3 (N PRs) — skip recommended
|
||||
- #11586 fix: website polish (apps/website/ only)
|
||||
- ...
|
||||
```
|
||||
|
||||
Then run interactive Y/N over Tier 1 and Tier 2; Tier 3 gets confirmed-skip without per-PR review.
|
||||
|
||||
## Dep Refresh PRs
|
||||
|
||||
Always SKIP on stable branches. Risk of transitive dependency regressions outweighs audit cleanup benefit. If a specific CVE fix is needed, cherry-pick that individual fix instead.
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
# Discovery — Candidate Collection
|
||||
|
||||
**Run all sources, then reconcile.** No single source is authoritative:
|
||||
|
||||
- Slack bot may flag PRs that have already been backported (false positive)
|
||||
- Git gap may include PRs that don't need backport (test-only, design-system, website)
|
||||
- Bot can also miss PRs that landed without the right labels
|
||||
|
||||
## Source 1: Slack Backport-Checker Bot
|
||||
|
||||
Use `slackdump` skill to export `#frontend-releases` channel (C09K9TPU2G7):
|
||||
@@ -42,43 +36,7 @@ gh pr view $PR --json mergeCommit,title --jq '"Title: \(.title)\nMerge: \(.merge
|
||||
gh pr view $PR --json files --jq '.files[].path'
|
||||
```
|
||||
|
||||
## Source 4: Already-Backported PRs (cross-reference)
|
||||
|
||||
When the target branch already has some cherry-picks on it (e.g., partway through a release window), extract the originals to avoid re-backporting:
|
||||
|
||||
```bash
|
||||
# Get all original PR numbers already backported to TARGET since the last release tag
|
||||
git log --format="%H%n%B" $LAST_TAG..origin/$TARGET \
|
||||
| grep -oiE "(backport of|cherry.picked) #?[0-9]+" \
|
||||
| grep -oE "[0-9]+" \
|
||||
| sort -un > /tmp/already-backported.txt
|
||||
```
|
||||
|
||||
Subtract this list from your candidates.
|
||||
|
||||
## Reconciliation Workflow
|
||||
|
||||
```bash
|
||||
# 1. Slack bot list (parse from export)
|
||||
# /tmp/bot-flagged.txt — one PR# per line, sorted
|
||||
|
||||
# 2. Git gap fix/perf only
|
||||
MB=$(git merge-base origin/main origin/$TARGET)
|
||||
git log --format="%h|%s" $MB..origin/main \
|
||||
| grep -iE "^[a-f0-9]+\|(fix|perf)" \
|
||||
| grep -oE "#[0-9]+\)" | grep -oE "[0-9]+" \
|
||||
| sort -un > /tmp/gap-fixes.txt
|
||||
|
||||
# 3. Already backported (Source 4 above)
|
||||
|
||||
# 4. Candidates = (gap-fixes ∪ bot-flagged) − already-backported
|
||||
sort -u /tmp/gap-fixes.txt /tmp/bot-flagged.txt > /tmp/union.txt
|
||||
comm -23 /tmp/union.txt /tmp/already-backported.txt > /tmp/candidates.txt
|
||||
```
|
||||
|
||||
The result is the input to the path pre-filter (`SKILL.md` Quick Start step 2).
|
||||
|
||||
## Output: candidate_list.md
|
||||
|
||||
Table per target branch:
|
||||
| PR# | Title | Source (bot/gap/both) | Path bucket | Tier | Decision |
|
||||
| PR# | Title | Category | Flagged by Bot? | Decision |
|
||||
|
||||
@@ -6,43 +6,6 @@
|
||||
2. Medium gap next (quick win)
|
||||
3. Largest gap last (main effort)
|
||||
|
||||
## Step 0: Test-Then-Resolve Pre-Pass (Recommended)
|
||||
|
||||
Before triggering label-driven automation, run a dry-run cherry-pick loop to classify candidates. This is much faster than discovering conflicts after-the-fact across automation, manual cherry-picks, and CI failures.
|
||||
|
||||
```bash
|
||||
git fetch origin TARGET_BRANCH
|
||||
git worktree add /tmp/dryrun-TARGET origin/TARGET_BRANCH
|
||||
cd /tmp/dryrun-TARGET
|
||||
|
||||
CLEAN=()
|
||||
CONFLICT=()
|
||||
for pr in "${CANDIDATES[@]}"; do
|
||||
SHA=$(gh pr view $pr --json mergeCommit --jq '.mergeCommit.oid')
|
||||
git checkout -b dryrun-$pr origin/TARGET_BRANCH 2>/dev/null
|
||||
if git cherry-pick -m 1 $SHA 2>/dev/null; then
|
||||
CLEAN+=($pr)
|
||||
else
|
||||
CONFLICT+=($pr)
|
||||
git cherry-pick --abort
|
||||
fi
|
||||
git checkout --detach HEAD 2>/dev/null
|
||||
git branch -D dryrun-$pr 2>/dev/null
|
||||
done
|
||||
|
||||
echo "CLEAN (${#CLEAN[@]}): ${CLEAN[*]}"
|
||||
echo "CONFLICT (${#CONFLICT[@]}): ${CONFLICT[*]}"
|
||||
|
||||
cd -
|
||||
git worktree remove /tmp/dryrun-TARGET --force
|
||||
```
|
||||
|
||||
Use the result to:
|
||||
|
||||
- Send CLEAN PRs through label-driven automation (Step 1) — they'll typically self-merge
|
||||
- Reserve manual worktree time (Step 3) for CONFLICT PRs only
|
||||
- Surface PRs likely to need backport-only compat shims (CONFLICT files in `src/lib/litegraph/` or `src/scripts/app.ts`)
|
||||
|
||||
## Step 1: Label-Driven Automation (Batch)
|
||||
|
||||
```bash
|
||||
@@ -125,39 +88,6 @@ for PR in ${CONFLICT_PRS[@]}; do
|
||||
git add .
|
||||
GIT_EDITOR=true git cherry-pick --continue
|
||||
|
||||
# ── Public-API conflict review (REQUIRED for extension-API surfaces) ──
|
||||
# If the conflict resolution touched any of these surfaces, consult oracle
|
||||
# BEFORE pushing. A bad shim is worse than no fix:
|
||||
# - node.onXxx callback assignments (onDragDrop, onConnectionsChange, onRemoved, onConfigure, etc.)
|
||||
# - Methods on LGraphNode, LGraphCanvas, LGraph, Subgraph
|
||||
# - Public exports from src/lib/litegraph/
|
||||
# - Type changes in litegraph-augmentation.d.ts
|
||||
# If a public callback's signature/contract changed: add a backport-only
|
||||
# compatibility shim that preserves the OLD contract while keeping the
|
||||
# new fix. Document it in the commit body under
|
||||
# "## Backport-only compatibility fix". See SKILL.md gotcha section.
|
||||
# ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
# Per-PR validation BEFORE push (catches issues earlier than wave verification).
|
||||
# Guard each targeted command against empty file lists — running `pnpm test:unit -- run`
|
||||
# with no arg matchers would run the full suite, and `pnpm exec eslint` with no args errors.
|
||||
pnpm typecheck
|
||||
|
||||
mapfile -t TEST_FILES < <(git diff --name-only HEAD~1 | grep -E '\.test\.ts$' || true)
|
||||
if [ ${#TEST_FILES[@]} -gt 0 ]; then
|
||||
pnpm test:unit -- run "${TEST_FILES[@]}"
|
||||
else
|
||||
echo "No changed test files — skipping targeted unit tests"
|
||||
fi
|
||||
|
||||
mapfile -t CODE_FILES < <(git diff --name-only HEAD~1 | grep -E '\.(ts|vue)$' || true)
|
||||
if [ ${#CODE_FILES[@]} -gt 0 ]; then
|
||||
pnpm exec eslint "${CODE_FILES[@]}"
|
||||
pnpm exec oxfmt --check "${CODE_FILES[@]}"
|
||||
else
|
||||
echo "No changed ts/vue files — skipping targeted lint/format"
|
||||
fi
|
||||
|
||||
git push origin backport-$PR-to-TARGET --no-verify
|
||||
NEW_PR=$(gh pr create --base TARGET_BRANCH --head backport-$PR-to-TARGET \
|
||||
--title "[backport TARGET] TITLE (#$PR)" \
|
||||
@@ -313,9 +243,6 @@ gh pr checks $PR --watch --fail-fast && gh pr merge $PR --squash --admin
|
||||
16. **Use `--no-verify` in worktrees** — husky hooks fail in `/tmp/` worktrees. Always push/commit with `--no-verify`.
|
||||
17. **Automation success varies by branch** — core/1.42 got 18/26 auto-PRs (69%), cloud/1.42 got 1/25 (4%). Cloud branches diverge more. Plan for manual fallback.
|
||||
18. **Test-then-resolve pattern** — for branches with low automation success, run a dry-run loop to classify clean vs conflict PRs before processing. This is much faster than resolving conflicts serially.
|
||||
19. **Public-API conflict resolutions need oracle review** — when a conflict touches `node.onXxx` callbacks, `LGraphNode`/`LGraphCanvas`/`LGraph`/`Subgraph` methods, or types in `litegraph-augmentation.d.ts`, consult oracle BEFORE pushing. Custom-node packages depend on these contracts. A literal cherry-pick of a refactor-style fix can silently break extensions still using the old contract — sometimes recreating the very bug the PR was fixing. Document any backport-only compatibility shim explicitly in the commit body.
|
||||
20. **Cherry-picked tests can require unbackported test scaffolding** — when a PR modifies a test file that was _added_ on main by an earlier unbackported PR, the cherry-pick reports modify/delete on that file. Drop it from the backport (`git rm`) and document which PR introduced it. Don't smuggle in test infrastructure without its runtime prerequisites.
|
||||
21. **Per-PR validation catches issues earlier than wave verification** — for high-stakes branches, run `pnpm typecheck && pnpm exec eslint <changed files> && pnpm exec oxfmt --check` per PR before pushing. Wave verification still matters (it catches cross-PR interactions), but per-PR makes attribution trivial when something fails.
|
||||
|
||||
## CI Failure Triage
|
||||
|
||||
@@ -341,40 +268,3 @@ Common failure categories:
|
||||
| Type error | Interface changed on main but not branch | May need manual adaptation |
|
||||
|
||||
**Never assume a failure is safe to skip.** Present all failures to the user with analysis.
|
||||
|
||||
## PR Body Template (Manual Cherry-Picks)
|
||||
|
||||
Manual cherry-pick PRs need detail beyond the automation's terse default. Use this template — reviewers will look here before re-deriving conflict-resolution logic from the diff.
|
||||
|
||||
```markdown
|
||||
Manual backport of #ORIG to `TARGET` for inclusion in `vX.Y.Z`.
|
||||
|
||||
Cherry-picked from upstream merge commit `SHORT_SHA`.
|
||||
|
||||
## Why
|
||||
|
||||
[1-2 sentences from the original PR's "Summary" — what bug, what fix mechanism]
|
||||
|
||||
## Conflict resolution
|
||||
|
||||
- **`path/to/file`** — [what conflicted on this branch] → [resolution chosen + why]
|
||||
- **`path/to/dropped-test.test.ts`** — added on main by unrelated PR #XXXX (not backported). Dropped from this backport; runtime fix intact.
|
||||
- [...]
|
||||
|
||||
## Backport-only compatibility fix (if applicable)
|
||||
|
||||
[If you added a shim that wasn't in the upstream PR, document it here — what extension surface, what contract, what the shim preserves, why the upstream version would have regressed it]
|
||||
|
||||
## Validation
|
||||
|
||||
- `pnpm typecheck` ✅
|
||||
- `pnpm test:unit -- run <targeted suites>` ✅ (N/N passing)
|
||||
- `pnpm exec eslint <changed files>` ✅ (0 errors)
|
||||
- `pnpm exec oxfmt --check` ✅ (clean)
|
||||
|
||||
[If manual e2e was skipped, explain why — e.g., requires live backend, headless not feasible. State that source is byte-identical to upstream + how long it's been baking on main.]
|
||||
|
||||
Original PR: #ORIG / Original commit: `FULL_SHA`
|
||||
```
|
||||
|
||||
The conflict-resolution section is non-negotiable — every conflict you resolved by hand needs a one-liner. This makes archaeology trivial six months later when someone asks "why does this look slightly different from main?"
|
||||
|
||||
@@ -3,23 +3,6 @@ import sitemap from '@astrojs/sitemap'
|
||||
import vue from '@astrojs/vue'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
|
||||
const LOCALES = ['en', 'zh-CN'] as const
|
||||
const DEFAULT_LOCALE = 'en'
|
||||
const PAYMENT_STATUSES = ['success', 'failed'] as const
|
||||
const LOCALE_PREFIXES = LOCALES.map((locale) =>
|
||||
locale === DEFAULT_LOCALE ? '' : `/${locale}`
|
||||
)
|
||||
const SITEMAP_EXCLUDED_PATHNAMES = new Set(
|
||||
LOCALE_PREFIXES.flatMap((prefix) =>
|
||||
PAYMENT_STATUSES.map((status) => `${prefix}/payment/${status}`)
|
||||
)
|
||||
)
|
||||
|
||||
function isExcludedFromSitemap(page: string): boolean {
|
||||
const pathname = new URL(page).pathname.replace(/\/$/, '')
|
||||
return SITEMAP_EXCLUDED_PATHNAMES.has(pathname)
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
site: 'https://comfy.org',
|
||||
output: 'static',
|
||||
@@ -34,12 +17,7 @@ export default defineConfig({
|
||||
assets: '_website'
|
||||
},
|
||||
devToolbar: { enabled: !process.env.NO_TOOLBAR },
|
||||
integrations: [
|
||||
vue(),
|
||||
sitemap({
|
||||
filter: (page) => !isExcludedFromSitemap(page)
|
||||
})
|
||||
],
|
||||
integrations: [vue(), sitemap()],
|
||||
vite: {
|
||||
plugins: [tailwindcss()],
|
||||
server: {
|
||||
@@ -49,8 +27,8 @@ export default defineConfig({
|
||||
}
|
||||
},
|
||||
i18n: {
|
||||
locales: [...LOCALES],
|
||||
defaultLocale: DEFAULT_LOCALE,
|
||||
locales: ['en', 'zh-CN'],
|
||||
defaultLocale: 'en',
|
||||
routing: {
|
||||
prefixDefaultLocale: false
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ test.describe('Download page @smoke', () => {
|
||||
await expect(githubBtn).toBeVisible()
|
||||
await expect(githubBtn).toHaveAttribute(
|
||||
'href',
|
||||
'https://github.com/Comfy-Org/ComfyUI#installing'
|
||||
'https://github.com/Comfy-Org/ComfyUI'
|
||||
)
|
||||
|
||||
await context.close()
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { externalLinks } from '../src/config/routes'
|
||||
import { test } from './fixtures/blockExternalMedia'
|
||||
|
||||
const CLOUD_URL = externalLinks.cloud
|
||||
const PLATFORM_USAGE_URL = externalLinks.platformUsage
|
||||
const SUPPORT_URL = externalLinks.support
|
||||
const DOCS_SUBSCRIPTION_URL = externalLinks.docsSubscription
|
||||
|
||||
async function expectNoIndex(page: Page) {
|
||||
await expect(page.locator('meta[name="robots"]')).toHaveAttribute(
|
||||
'content',
|
||||
'noindex, nofollow'
|
||||
)
|
||||
}
|
||||
|
||||
test.describe('Payment success page @smoke', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/payment/success')
|
||||
})
|
||||
|
||||
test('has correct title and is noindex', async ({ page }) => {
|
||||
await expect(page).toHaveTitle('Payment Successful — Comfy')
|
||||
await expectNoIndex(page)
|
||||
})
|
||||
|
||||
test('shows success heading and subtitle', async ({ page }) => {
|
||||
await expect(
|
||||
page.getByRole('heading', { name: /Payment successful/i, level: 1 })
|
||||
).toBeVisible()
|
||||
await expect(page.getByText(/Thanks for your purchase/i)).toBeVisible()
|
||||
})
|
||||
|
||||
test('primary CTA links to Comfy Cloud', async ({ page }) => {
|
||||
const cta = page.getByRole('link', { name: /CONTINUE TO COMFY CLOUD/i })
|
||||
await expect(cta).toBeVisible()
|
||||
await expect(cta).toHaveAttribute('href', CLOUD_URL)
|
||||
})
|
||||
|
||||
test('secondary CTA links to platform usage & payments page', async ({
|
||||
page
|
||||
}) => {
|
||||
const cta = page.getByRole('link', { name: /VIEW USAGE & PAYMENTS/i })
|
||||
await expect(cta).toBeVisible()
|
||||
await expect(cta).toHaveAttribute('href', PLATFORM_USAGE_URL)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Payment failed page @smoke', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/payment/failed')
|
||||
})
|
||||
|
||||
test('has correct title and is noindex', async ({ page }) => {
|
||||
await expect(page).toHaveTitle('Payment Failed — Comfy')
|
||||
await expectNoIndex(page)
|
||||
})
|
||||
|
||||
test('shows failure heading and subtitle', async ({ page }) => {
|
||||
await expect(
|
||||
page.getByRole('heading', {
|
||||
name: /Payment was not completed/i,
|
||||
level: 1
|
||||
})
|
||||
).toBeVisible()
|
||||
await expect(page.getByText(/payment didn't go through/i)).toBeVisible()
|
||||
})
|
||||
|
||||
test('primary CTA links to support help center', async ({ page }) => {
|
||||
const cta = page.getByRole('link', { name: /CONTACT SUPPORT/i })
|
||||
await expect(cta).toBeVisible()
|
||||
await expect(cta).toHaveAttribute('href', SUPPORT_URL)
|
||||
})
|
||||
|
||||
test('secondary CTA links to subscription docs', async ({ page }) => {
|
||||
const cta = page.getByRole('link', { name: /READ SUBSCRIPTION DOCS/i })
|
||||
await expect(cta).toBeVisible()
|
||||
await expect(cta).toHaveAttribute('href', DOCS_SUBSCRIPTION_URL)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Payment pages zh-CN @smoke', () => {
|
||||
test('zh-CN success page renders and links correctly', async ({ page }) => {
|
||||
await page.goto('/zh-CN/payment/success')
|
||||
await expect(page).toHaveTitle('支付成功 — Comfy')
|
||||
await expectNoIndex(page)
|
||||
await expect(
|
||||
page.getByRole('heading', { name: '支付成功', level: 1 })
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
page.getByRole('link', { name: '前往 COMFY CLOUD' })
|
||||
).toHaveAttribute('href', CLOUD_URL)
|
||||
await expect(
|
||||
page.getByRole('link', { name: '查看用量与支付' })
|
||||
).toHaveAttribute('href', PLATFORM_USAGE_URL)
|
||||
})
|
||||
|
||||
test('zh-CN failed page renders and links correctly', async ({ page }) => {
|
||||
await page.goto('/zh-CN/payment/failed')
|
||||
await expect(page).toHaveTitle('支付失败 — Comfy')
|
||||
await expectNoIndex(page)
|
||||
await expect(
|
||||
page.getByRole('heading', { name: '支付未完成', level: 1 })
|
||||
).toBeVisible()
|
||||
await expect(page.getByRole('link', { name: '联系支持' })).toHaveAttribute(
|
||||
'href',
|
||||
SUPPORT_URL
|
||||
)
|
||||
await expect(
|
||||
page.getByRole('link', { name: '查看订阅文档' })
|
||||
).toHaveAttribute('href', DOCS_SUBSCRIPTION_URL)
|
||||
})
|
||||
})
|
||||
@@ -29,6 +29,5 @@ Allow: /
|
||||
Disallow: /_astro/
|
||||
Disallow: /_website/
|
||||
Disallow: /_vercel/
|
||||
Disallow: /payment/
|
||||
|
||||
Sitemap: https://comfy.org/sitemap-index.xml
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import { externalLinks } from '../../config/routes'
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
import { t } from '../../i18n/translations'
|
||||
import BrandButton from '../common/BrandButton.vue'
|
||||
import SectionLabel from '../common/SectionLabel.vue'
|
||||
|
||||
// Display-only thank-you / failure pages: payment state is verified
|
||||
// server-side via Stripe webhooks (see comfy-api). These pages exist
|
||||
// solely as the redirect target for Stripe Checkout.
|
||||
|
||||
type Status = 'success' | 'failed'
|
||||
|
||||
const { status, locale = 'en' } = defineProps<{
|
||||
status: Status
|
||||
locale?: Locale
|
||||
}>()
|
||||
|
||||
const primaryHref =
|
||||
status === 'success' ? externalLinks.cloud : externalLinks.support
|
||||
const secondaryHref =
|
||||
status === 'success'
|
||||
? externalLinks.platformUsage
|
||||
: externalLinks.docsSubscription
|
||||
|
||||
const iconRingClass =
|
||||
status === 'success'
|
||||
? 'border-primary-comfy-yellow text-primary-comfy-yellow'
|
||||
: 'border-secondary-mauve text-secondary-mauve'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="flex min-h-[calc(100dvh-12rem)] items-center justify-center px-6 py-16 lg:py-24"
|
||||
>
|
||||
<div class="flex max-w-2xl flex-col items-center gap-6 text-center">
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'flex size-20 items-center justify-center rounded-full border-2',
|
||||
iconRingClass
|
||||
)
|
||||
"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<svg
|
||||
v-if="status === 'success'"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="size-10"
|
||||
>
|
||||
<path d="M5 12.5l4.5 4.5L19 7.5" />
|
||||
</svg>
|
||||
<svg
|
||||
v-else
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="size-10"
|
||||
>
|
||||
<path d="M6 6l12 12" />
|
||||
<path d="M18 6L6 18" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<SectionLabel>{{ t(`payment.${status}.label`, locale) }}</SectionLabel>
|
||||
|
||||
<h1
|
||||
class="text-primary-comfy-canvas text-4xl/tight font-light md:text-5xl/tight lg:text-6xl/tight"
|
||||
>
|
||||
{{ t(`payment.${status}.title`, locale) }}
|
||||
</h1>
|
||||
|
||||
<p
|
||||
class="text-primary-comfy-canvas/80 max-w-xl text-base font-light lg:text-lg"
|
||||
>
|
||||
{{ t(`payment.${status}.subtitle`, locale) }}
|
||||
</p>
|
||||
|
||||
<div
|
||||
class="mt-2 flex flex-col items-stretch gap-3 sm:flex-row sm:items-center sm:justify-center"
|
||||
>
|
||||
<BrandButton :href="primaryHref" variant="solid" size="nav">
|
||||
{{ t(`payment.${status}.primaryCta`, locale) }}
|
||||
</BrandButton>
|
||||
<BrandButton :href="secondaryHref" variant="outline" size="nav">
|
||||
{{ t(`payment.${status}.secondaryCta`, locale) }}
|
||||
</BrandButton>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -28,11 +28,7 @@ const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
<!-- CTA buttons -->
|
||||
<div class="mt-10 flex flex-col gap-4 lg:flex-row">
|
||||
<DownloadLocalButton :locale />
|
||||
<BrandButton
|
||||
:href="externalLinks.githubInstall"
|
||||
variant="outline"
|
||||
size="lg"
|
||||
>
|
||||
<BrandButton :href="externalLinks.github" variant="outline" size="lg">
|
||||
<span class="inline-flex items-center gap-2">
|
||||
<i
|
||||
class="icon-mask size-5 -translate-y-px mask-[url('/icons/social/github.svg')]"
|
||||
|
||||
@@ -323,7 +323,7 @@ onUnmounted(() => {
|
||||
<div class="mt-8 flex flex-col gap-4 lg:flex-row">
|
||||
<DownloadLocalButton :locale class="lg:min-w-60 lg:p-4" />
|
||||
<BrandButton
|
||||
:href="externalLinks.githubInstall"
|
||||
:href="externalLinks.github"
|
||||
variant="outline"
|
||||
size="lg"
|
||||
class="lg:min-w-60 lg:p-4"
|
||||
|
||||
@@ -33,11 +33,8 @@ export const externalLinks = {
|
||||
discord: 'https://discord.com/invite/comfyorg',
|
||||
docs: 'https://docs.comfy.org/',
|
||||
docsApi: 'https://docs.comfy.org/api-reference/cloud',
|
||||
docsSubscription: 'https://docs.comfy.org/support/subscription/subscribing',
|
||||
github: 'https://github.com/Comfy-Org/ComfyUI',
|
||||
githubInstall: 'https://github.com/Comfy-Org/ComfyUI#installing',
|
||||
platform: 'https://platform.comfy.org',
|
||||
platformUsage: 'https://platform.comfy.org/profile/usage',
|
||||
support: 'https://support.comfy.org/hc/en-us',
|
||||
workflows: 'https://comfy.org/workflows',
|
||||
youtube: 'https://www.youtube.com/@ComfyOrg'
|
||||
|
||||
@@ -3592,49 +3592,6 @@ const translations = {
|
||||
'customers.feedback.role3': {
|
||||
en: 'Head of AI at Creative Studios',
|
||||
'zh-CN': 'Creative Studios AI 负责人'
|
||||
},
|
||||
|
||||
// Payment status pages
|
||||
'payment.success.label': {
|
||||
en: 'PAYMENT',
|
||||
'zh-CN': '支付'
|
||||
},
|
||||
'payment.success.title': {
|
||||
en: 'Payment successful',
|
||||
'zh-CN': '支付成功'
|
||||
},
|
||||
'payment.success.subtitle': {
|
||||
en: "Thanks for your purchase. Your account has been credited and you're ready to keep building.",
|
||||
'zh-CN': '感谢您的购买。您的账户已充值完成,可以继续创作了。'
|
||||
},
|
||||
'payment.success.primaryCta': {
|
||||
en: 'CONTINUE TO COMFY CLOUD',
|
||||
'zh-CN': '前往 COMFY CLOUD'
|
||||
},
|
||||
'payment.success.secondaryCta': {
|
||||
en: 'VIEW USAGE & PAYMENTS',
|
||||
'zh-CN': '查看用量与支付'
|
||||
},
|
||||
'payment.failed.label': {
|
||||
en: 'PAYMENT',
|
||||
'zh-CN': '支付'
|
||||
},
|
||||
'payment.failed.title': {
|
||||
en: 'Payment was not completed',
|
||||
'zh-CN': '支付未完成'
|
||||
},
|
||||
'payment.failed.subtitle': {
|
||||
en: "Your payment didn't go through and you have not been charged. Reach out to support or read the subscription docs if you need help.",
|
||||
'zh-CN':
|
||||
'您的支付未能完成,未发生扣款。如需帮助,请联系支持或查阅订阅文档。'
|
||||
},
|
||||
'payment.failed.primaryCta': {
|
||||
en: 'CONTACT SUPPORT',
|
||||
'zh-CN': '联系支持'
|
||||
},
|
||||
'payment.failed.secondaryCta': {
|
||||
en: 'READ SUBSCRIPTION DOCS',
|
||||
'zh-CN': '查看订阅文档'
|
||||
}
|
||||
} as const satisfies Record<string, Record<Locale, string>>
|
||||
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
---
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro'
|
||||
import PaymentStatusSection from '../../components/payment/PaymentStatusSection.vue'
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="Payment Failed — Comfy"
|
||||
description="Your payment was not completed."
|
||||
noindex
|
||||
>
|
||||
<PaymentStatusSection status="failed" />
|
||||
</BaseLayout>
|
||||
@@ -1,12 +0,0 @@
|
||||
---
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro'
|
||||
import PaymentStatusSection from '../../components/payment/PaymentStatusSection.vue'
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="Payment Successful — Comfy"
|
||||
description="Your payment was processed successfully."
|
||||
noindex
|
||||
>
|
||||
<PaymentStatusSection status="success" />
|
||||
</BaseLayout>
|
||||
@@ -1,8 +0,0 @@
|
||||
---
|
||||
import BaseLayout from '../../../layouts/BaseLayout.astro'
|
||||
import PaymentStatusSection from '../../../components/payment/PaymentStatusSection.vue'
|
||||
---
|
||||
|
||||
<BaseLayout title="支付失败 — Comfy" description="您的支付未能完成。" noindex>
|
||||
<PaymentStatusSection status="failed" locale="zh-CN" />
|
||||
</BaseLayout>
|
||||
@@ -1,8 +0,0 @@
|
||||
---
|
||||
import BaseLayout from '../../../layouts/BaseLayout.astro'
|
||||
import PaymentStatusSection from '../../../components/payment/PaymentStatusSection.vue'
|
||||
---
|
||||
|
||||
<BaseLayout title="支付成功 — Comfy" description="您的支付已成功完成。" noindex>
|
||||
<PaymentStatusSection status="success" locale="zh-CN" />
|
||||
</BaseLayout>
|
||||
@@ -0,0 +1,178 @@
|
||||
{
|
||||
"id": "6f7b11c1-410b-4754-8703-eb1bcc9aaf83",
|
||||
"revision": 0,
|
||||
"last_node_id": 4,
|
||||
"last_link_id": 3,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 3,
|
||||
"type": "PreviewAny",
|
||||
"pos": [552.8850000000002, 657.0019999999998],
|
||||
"size": [225, 176],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "source",
|
||||
"type": "*",
|
||||
"link": 3
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "STRING",
|
||||
"type": "STRING",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "PreviewAny"
|
||||
},
|
||||
"widgets_values": [null, null, false]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"type": "cac0d4c9-c8a8-497b-aed7-ae10c4bbd11f",
|
||||
"pos": [270.74314453125, 662],
|
||||
"size": [225, 168],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "STRING",
|
||||
"type": "STRING",
|
||||
"links": [3]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"proxyWidgets": [["2", "value"]]
|
||||
},
|
||||
"widgets_values": ["exterior"]
|
||||
}
|
||||
],
|
||||
"links": [[3, 4, 0, 3, 0, "STRING"]],
|
||||
"groups": [],
|
||||
"definitions": {
|
||||
"subgraphs": [
|
||||
{
|
||||
"id": "cac0d4c9-c8a8-497b-aed7-ae10c4bbd11f",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
"lastNodeId": 4,
|
||||
"lastLinkId": 3,
|
||||
"lastRerouteId": 0
|
||||
},
|
||||
"revision": 0,
|
||||
"config": {},
|
||||
"name": "New Subgraph",
|
||||
"inputNode": {
|
||||
"id": -10,
|
||||
"bounding": [-183.5137109375, 636, 128, 48]
|
||||
},
|
||||
"outputNode": {
|
||||
"id": -20,
|
||||
"bounding": [737, 626, 128, 68]
|
||||
},
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"id": "510b65b6-6cd7-4787-aa36-845c2f702b53",
|
||||
"name": "STRING",
|
||||
"type": "STRING",
|
||||
"linkIds": [1],
|
||||
"localized_name": "STRING",
|
||||
"pos": [761, 650]
|
||||
}
|
||||
],
|
||||
"widgets": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "PrimitiveStringMultiline",
|
||||
"pos": [277, 575],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "value",
|
||||
"name": "value",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "value"
|
||||
},
|
||||
"link": 2
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "STRING",
|
||||
"name": "STRING",
|
||||
"type": "STRING",
|
||||
"links": [1]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "PrimitiveStringMultiline"
|
||||
},
|
||||
"widgets_values": ["interior"]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "PrimitiveNode",
|
||||
"pos": [4.486289062499992, 584.655],
|
||||
"size": [225, 140],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "STRING",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "value"
|
||||
},
|
||||
"links": [2]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Run widget replace on values": false
|
||||
},
|
||||
"widgets_values": ["interior"]
|
||||
}
|
||||
],
|
||||
"groups": [],
|
||||
"links": [
|
||||
{
|
||||
"id": 2,
|
||||
"origin_id": 2,
|
||||
"origin_slot": 0,
|
||||
"target_id": 1,
|
||||
"target_slot": 0,
|
||||
"type": "STRING"
|
||||
},
|
||||
{
|
||||
"id": 1,
|
||||
"origin_id": 1,
|
||||
"origin_slot": 0,
|
||||
"target_id": -20,
|
||||
"target_slot": 0,
|
||||
"type": "STRING"
|
||||
}
|
||||
],
|
||||
"extra": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
"config": {},
|
||||
"extra": {
|
||||
"frontendVersion": "1.44.15"
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
{
|
||||
"id": "2f54e2f0-6db4-4bdf-84a8-9c3ea3ec0123",
|
||||
"revision": 0,
|
||||
"last_node_id": 13,
|
||||
"last_link_id": 9,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 11,
|
||||
"type": "422723e8-4bf6-438c-823f-881ca81acead",
|
||||
"pos": [120, 180],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{ "name": "clip", "type": "CLIP", "link": null },
|
||||
{ "name": "model", "type": "MODEL", "link": null },
|
||||
{ "name": "positive", "type": "CONDITIONING", "link": null },
|
||||
{ "name": "negative", "type": "CONDITIONING", "link": null },
|
||||
{ "name": "latent_image", "type": "LATENT", "link": null }
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {},
|
||||
"widgets_values": ["Alpha\n"]
|
||||
},
|
||||
{
|
||||
"id": 12,
|
||||
"type": "422723e8-4bf6-438c-823f-881ca81acead",
|
||||
"pos": [420, 180],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{ "name": "clip", "type": "CLIP", "link": null },
|
||||
{ "name": "model", "type": "MODEL", "link": null },
|
||||
{ "name": "positive", "type": "CONDITIONING", "link": null },
|
||||
{ "name": "negative", "type": "CONDITIONING", "link": null },
|
||||
{ "name": "latent_image", "type": "LATENT", "link": null }
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {},
|
||||
"widgets_values": ["Beta\n"]
|
||||
},
|
||||
{
|
||||
"id": 13,
|
||||
"type": "422723e8-4bf6-438c-823f-881ca81acead",
|
||||
"pos": [720, 180],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{ "name": "clip", "type": "CLIP", "link": null },
|
||||
{ "name": "model", "type": "MODEL", "link": null },
|
||||
{ "name": "positive", "type": "CONDITIONING", "link": null },
|
||||
{ "name": "negative", "type": "CONDITIONING", "link": null },
|
||||
{ "name": "latent_image", "type": "LATENT", "link": null }
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {},
|
||||
"widgets_values": ["Gamma\n"]
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"definitions": {
|
||||
"subgraphs": [
|
||||
{
|
||||
"id": "422723e8-4bf6-438c-823f-881ca81acead",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
"lastNodeId": 11,
|
||||
"lastLinkId": 15,
|
||||
"lastRerouteId": 0
|
||||
},
|
||||
"revision": 0,
|
||||
"config": {},
|
||||
"name": "New Subgraph",
|
||||
"inputNode": {
|
||||
"id": -10,
|
||||
"bounding": [481.59912109375, 379.13336181640625, 120, 160]
|
||||
},
|
||||
"outputNode": {
|
||||
"id": -20,
|
||||
"bounding": [1121.59912109375, 379.13336181640625, 120, 40]
|
||||
},
|
||||
"inputs": [
|
||||
{
|
||||
"id": "0f07c10e-5705-4764-9b24-b69606c6dbcc",
|
||||
"name": "text",
|
||||
"type": "STRING",
|
||||
"linkIds": [10],
|
||||
"pos": { "0": 581.59912109375, "1": 399.13336181640625 }
|
||||
},
|
||||
{
|
||||
"id": "214a5060-24dd-4299-ab78-8027dc5b9c59",
|
||||
"name": "clip",
|
||||
"type": "CLIP",
|
||||
"linkIds": [11],
|
||||
"pos": { "0": 581.59912109375, "1": 419.13336181640625 }
|
||||
},
|
||||
{
|
||||
"id": "8ab94c5d-e7df-433c-9177-482a32340552",
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"linkIds": [12],
|
||||
"pos": { "0": 581.59912109375, "1": 439.13336181640625 }
|
||||
},
|
||||
{
|
||||
"id": "8a4cd719-8c67-473b-9b44-ac0582d02641",
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"linkIds": [13],
|
||||
"pos": { "0": 581.59912109375, "1": 459.13336181640625 }
|
||||
},
|
||||
{
|
||||
"id": "a78d6b3a-ad40-4300-b0a5-2cdbdb8dc135",
|
||||
"name": "negative",
|
||||
"type": "CONDITIONING",
|
||||
"linkIds": [14],
|
||||
"pos": { "0": 581.59912109375, "1": 479.13336181640625 }
|
||||
},
|
||||
{
|
||||
"id": "4c7abe0c-902d-49ef-a5b0-cbf02b50b693",
|
||||
"name": "latent_image",
|
||||
"type": "LATENT",
|
||||
"linkIds": [15],
|
||||
"pos": { "0": 581.59912109375, "1": 499.13336181640625 }
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"widgets": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": 10,
|
||||
"type": "CLIPTextEncode",
|
||||
"pos": [661.59912109375, 314.13336181640625],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "clip",
|
||||
"name": "clip",
|
||||
"type": "CLIP",
|
||||
"link": 11
|
||||
},
|
||||
{
|
||||
"localized_name": "text",
|
||||
"name": "text",
|
||||
"type": "STRING",
|
||||
"widget": { "name": "text" },
|
||||
"link": 10
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "CONDITIONING",
|
||||
"name": "CONDITIONING",
|
||||
"type": "CONDITIONING",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CLIPTextEncode"
|
||||
},
|
||||
"widgets_values": [""]
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"type": "KSampler",
|
||||
"pos": [674.1234741210938, 570.5839233398438],
|
||||
"size": [270, 262],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "model",
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"link": 12
|
||||
},
|
||||
{
|
||||
"localized_name": "positive",
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"link": 13
|
||||
},
|
||||
{
|
||||
"localized_name": "negative",
|
||||
"name": "negative",
|
||||
"type": "CONDITIONING",
|
||||
"link": 14
|
||||
},
|
||||
{
|
||||
"localized_name": "latent_image",
|
||||
"name": "latent_image",
|
||||
"type": "LATENT",
|
||||
"link": 15
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "LATENT",
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "KSampler"
|
||||
},
|
||||
"widgets_values": [0, "randomize", 20, 8, "euler", "simple", 1]
|
||||
}
|
||||
],
|
||||
"groups": [],
|
||||
"links": [
|
||||
{
|
||||
"id": 10,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 0,
|
||||
"target_id": 10,
|
||||
"target_slot": 1,
|
||||
"type": "STRING"
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 1,
|
||||
"target_id": 10,
|
||||
"target_slot": 0,
|
||||
"type": "CLIP"
|
||||
},
|
||||
{
|
||||
"id": 12,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 2,
|
||||
"target_id": 11,
|
||||
"target_slot": 0,
|
||||
"type": "MODEL"
|
||||
},
|
||||
{
|
||||
"id": 13,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 3,
|
||||
"target_id": 11,
|
||||
"target_slot": 1,
|
||||
"type": "CONDITIONING"
|
||||
},
|
||||
{
|
||||
"id": 14,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 4,
|
||||
"target_id": 11,
|
||||
"target_slot": 2,
|
||||
"type": "CONDITIONING"
|
||||
},
|
||||
{
|
||||
"id": 15,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 5,
|
||||
"target_id": 11,
|
||||
"target_slot": 3,
|
||||
"type": "LATENT"
|
||||
}
|
||||
],
|
||||
"extra": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 1,
|
||||
"offset": [0, 0]
|
||||
},
|
||||
"frontendVersion": "1.24.1"
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -7,6 +7,10 @@ import type {
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/comfyWorkflow'
|
||||
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import type {
|
||||
ProxyWidgetTuple,
|
||||
SerializedProxyWidgetTuple
|
||||
} from '@/core/schemas/promotionSchema'
|
||||
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
@@ -386,7 +390,7 @@ export class SubgraphHelper {
|
||||
}
|
||||
|
||||
async getHostPromotedTupleSnapshot(): Promise<
|
||||
{ hostNodeId: string; promotedWidgets: [string, string][] }[]
|
||||
{ hostNodeId: string; promotedWidgets: SerializedProxyWidgetTuple[] }[]
|
||||
> {
|
||||
return this.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph!
|
||||
@@ -401,15 +405,18 @@ export class SubgraphHelper {
|
||||
: []
|
||||
const promotedWidgets = proxyWidgets
|
||||
.filter(
|
||||
(entry): entry is [string, string] =>
|
||||
(entry): entry is ProxyWidgetTuple =>
|
||||
Array.isArray(entry) &&
|
||||
entry.length >= 2 &&
|
||||
typeof entry[0] === 'string' &&
|
||||
typeof entry[1] === 'string'
|
||||
)
|
||||
.map(
|
||||
([interiorNodeId, widgetName]) =>
|
||||
[interiorNodeId, widgetName] as [string, string]
|
||||
([sourceNodeId, serializedSourceWidgetName]) =>
|
||||
[
|
||||
sourceNodeId,
|
||||
serializedSourceWidgetName
|
||||
] satisfies SerializedProxyWidgetTuple
|
||||
)
|
||||
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { ComfyApiWorkflow } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
const SPARSE_OVERRIDE_WORKFLOW =
|
||||
'subgraphs/promoted-primitive-node-sparse-override'
|
||||
|
||||
const HOST_NODE_ID = '4'
|
||||
const PROMOTED_WIDGET_NAME = 'value'
|
||||
const EXTERIOR_VALUE = 'exterior'
|
||||
const INTERIOR_VALUE = 'interior'
|
||||
|
||||
function findPrimitiveStringMultilineValue(
|
||||
apiPrompt: ComfyApiWorkflow
|
||||
): unknown {
|
||||
const entry = Object.values(apiPrompt).find(
|
||||
(node) => node.class_type === 'PrimitiveStringMultiline'
|
||||
)
|
||||
return entry?.inputs.value
|
||||
}
|
||||
|
||||
/**
|
||||
* Regression test for PR #11811 — promoted-widget sparse-override fix
|
||||
* (commit 66c89c8e5).
|
||||
*
|
||||
* Workflow shape:
|
||||
* - SubgraphNode id=4, widgets_values=["exterior"],
|
||||
* properties.proxyWidgets=[["2", "value"]]
|
||||
* - Interior PrimitiveNode id=2 (lazy widget creation in onAfterGraphConfigured)
|
||||
* widgets_values=["interior"]
|
||||
* - Interior PrimitiveStringMultiline id=1 receives PrimitiveNode value
|
||||
* - Root PreviewAny id=3 consumes the subgraph output
|
||||
*
|
||||
* The bug: PrimitiveNode's lazy widget creation in onAfterGraphConfigured
|
||||
* re-applied widgets_values=["interior"] *after* the SubgraphNode applied its
|
||||
* exterior widgets_values, clobbering the per-instance override. The fix
|
||||
* defers a replay of promoted-view values from SubgraphNode.onAfterGraphConfigured
|
||||
* so the exterior override wins the materialization race, and graphToPrompt's
|
||||
* getExecutableWidgetValue walks the ancestor SubgraphNode chain to pick up
|
||||
* the per-instance override during prompt build.
|
||||
*/
|
||||
test.describe(
|
||||
'Promoted widget sparse-override (PrimitiveNode lazy widget)',
|
||||
{ tag: ['@subgraph', '@vue-nodes', '@widget'] },
|
||||
() => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
})
|
||||
|
||||
test('Promoted widget renders the exterior override after load, not the interior default', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(SPARSE_OVERRIDE_WORKFLOW)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const hostNode = comfyPage.vueNodes.getNodeLocator(HOST_NODE_ID)
|
||||
await expect(hostNode).toBeVisible()
|
||||
|
||||
const promotedTextbox = hostNode.getByRole('textbox', {
|
||||
name: PROMOTED_WIDGET_NAME,
|
||||
exact: true
|
||||
})
|
||||
await expect(promotedTextbox).toHaveCount(1)
|
||||
await expect(promotedTextbox).toHaveValue(EXTERIOR_VALUE)
|
||||
await expect(promotedTextbox).not.toHaveValue(INTERIOR_VALUE)
|
||||
})
|
||||
|
||||
test('Prompt-build resolves the exterior override through the ancestor SubgraphNode chain', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(SPARSE_OVERRIDE_WORKFLOW)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const apiPrompt = await comfyPage.workflow.getExportedWorkflow({
|
||||
api: true
|
||||
})
|
||||
|
||||
expect(findPrimitiveStringMultilineValue(apiPrompt)).toBe(EXTERIOR_VALUE)
|
||||
})
|
||||
|
||||
test('Editing the promoted widget writes through to the interior and is reflected in prompt-build', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const editedValue = 'edited'
|
||||
|
||||
await comfyPage.workflow.loadWorkflow(SPARSE_OVERRIDE_WORKFLOW)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const hostNode = comfyPage.vueNodes.getNodeLocator(HOST_NODE_ID)
|
||||
const promotedTextbox = hostNode.getByRole('textbox', {
|
||||
name: PROMOTED_WIDGET_NAME,
|
||||
exact: true
|
||||
})
|
||||
await expect(promotedTextbox).toHaveValue(EXTERIOR_VALUE)
|
||||
|
||||
await promotedTextbox.fill(editedValue)
|
||||
await expect(promotedTextbox).toHaveValue(editedValue)
|
||||
|
||||
const apiPrompt = await comfyPage.workflow.getExportedWorkflow({
|
||||
api: true
|
||||
})
|
||||
expect(findPrimitiveStringMultilineValue(apiPrompt)).toBe(editedValue)
|
||||
})
|
||||
|
||||
test('Reloading the workflow without saving resets the promoted widget to the exterior override', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const editedValue = 'edited'
|
||||
|
||||
await comfyPage.workflow.loadWorkflow(SPARSE_OVERRIDE_WORKFLOW)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const hostNode = comfyPage.vueNodes.getNodeLocator(HOST_NODE_ID)
|
||||
const promotedTextbox = hostNode.getByRole('textbox', {
|
||||
name: PROMOTED_WIDGET_NAME,
|
||||
exact: true
|
||||
})
|
||||
|
||||
await promotedTextbox.fill(editedValue)
|
||||
await expect(promotedTextbox).toHaveValue(editedValue)
|
||||
|
||||
await comfyPage.workflow.loadWorkflow(SPARSE_OVERRIDE_WORKFLOW)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const reloadedHost = comfyPage.vueNodes.getNodeLocator(HOST_NODE_ID)
|
||||
const reloadedTextbox = reloadedHost.getByRole('textbox', {
|
||||
name: PROMOTED_WIDGET_NAME,
|
||||
exact: true
|
||||
})
|
||||
await expect(reloadedTextbox).toHaveValue(EXTERIOR_VALUE)
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,40 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
const MULTI_INSTANCE_WORKFLOW =
|
||||
'subgraphs/subgraph-multi-instance-promoted-text-values'
|
||||
|
||||
test.describe(
|
||||
'Multi-instance subgraph promoted widget rendering in Vue mode',
|
||||
{ tag: ['@subgraph', '@vue-nodes', '@widget'] },
|
||||
() => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
})
|
||||
|
||||
test('Each subgraph instance renders its own promoted widget value, not the interior default', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const expectedByNodeId: Record<string, string> = {
|
||||
'11': 'Alpha\n',
|
||||
'12': 'Beta\n',
|
||||
'13': 'Gamma\n'
|
||||
}
|
||||
|
||||
await comfyPage.workflow.loadWorkflow(MULTI_INSTANCE_WORKFLOW)
|
||||
await comfyPage.vueNodes.waitForNodes(3)
|
||||
|
||||
for (const [nodeId, expectedValue] of Object.entries(expectedByNodeId)) {
|
||||
const subgraphNode = comfyPage.vueNodes.getNodeLocator(nodeId)
|
||||
await expect(subgraphNode).toBeVisible()
|
||||
|
||||
const textarea = subgraphNode.getByRole('textbox', {
|
||||
name: 'text',
|
||||
exact: true
|
||||
})
|
||||
await expect(textarea).toHaveValue(expectedValue)
|
||||
}
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -3,7 +3,6 @@ import { expect } from '@playwright/test'
|
||||
import { comfyPageFixture as test, comfyExpect } from '@e2e/fixtures/ComfyPage'
|
||||
import { SubgraphHelper } from '@e2e/fixtures/helpers/SubgraphHelper'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import { getPromotedWidgets } from '@e2e/fixtures/utils/promotedWidgets'
|
||||
|
||||
test.describe('Nested Subgraphs', { tag: ['@subgraph'] }, () => {
|
||||
test.describe('Nested subgraph configure order', () => {
|
||||
@@ -191,106 +190,4 @@ test.describe('Nested Subgraphs', { tag: ['@subgraph'] }, () => {
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
test.describe(
|
||||
'Nested subgraph input target resolution',
|
||||
{ tag: ['@widget', '@vue-nodes'] },
|
||||
() => {
|
||||
const WORKFLOW = 'subgraphs/subgraph-nested-promotion'
|
||||
const OUTER_NODE_ID = '5'
|
||||
const INNER_SUBGRAPH_NODE_ID = '6'
|
||||
|
||||
test('Nested SubgraphNode promoted widgets render without resolution failures', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { warnings, dispose } = SubgraphHelper.collectConsoleWarnings(
|
||||
comfyPage.page,
|
||||
['No link found', 'Failed to resolve legacy -1']
|
||||
)
|
||||
|
||||
try {
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const outerNode = comfyPage.vueNodes.getNodeLocator(OUTER_NODE_ID)
|
||||
await comfyExpect(outerNode).toBeVisible()
|
||||
|
||||
const widgets = outerNode.getByTestId(TestIds.widgets.widget)
|
||||
await comfyExpect(
|
||||
widgets,
|
||||
'asset has 4 promoted widgets on outer subgraph node'
|
||||
).toHaveCount(4)
|
||||
|
||||
expect(warnings).toEqual([])
|
||||
} finally {
|
||||
dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test('Promoted widgets from inner SubgraphNode are visible with correct values', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const outerNode = comfyPage.vueNodes.getNodeLocator(OUTER_NODE_ID)
|
||||
await comfyExpect(outerNode).toBeVisible()
|
||||
|
||||
const widgets = outerNode.getByTestId(TestIds.widgets.widget)
|
||||
await comfyExpect(widgets).toHaveCount(4)
|
||||
|
||||
const valueWidget = outerNode
|
||||
.getByRole('textbox', { name: 'value' })
|
||||
.first()
|
||||
await comfyExpect(valueWidget).toBeVisible()
|
||||
await comfyExpect(valueWidget).toHaveValue(/Inner 1/)
|
||||
})
|
||||
|
||||
test('Promoted widgets from inner SubgraphNode carry correct source identity', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const widgets = await getPromotedWidgets(comfyPage, OUTER_NODE_ID)
|
||||
return widgets
|
||||
.filter(
|
||||
([sourceNodeId]) => sourceNodeId === INNER_SUBGRAPH_NODE_ID
|
||||
)
|
||||
.map(([, sourceWidgetName]) => sourceWidgetName)
|
||||
})
|
||||
.toContain('value')
|
||||
})
|
||||
|
||||
test('Serialize and reload preserves nested promoted widget visibility', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const outerNode = comfyPage.vueNodes.getNodeLocator(OUTER_NODE_ID)
|
||||
const widgets = outerNode.getByTestId(TestIds.widgets.widget)
|
||||
await comfyExpect(
|
||||
widgets,
|
||||
'asset has 4 promoted widgets on outer subgraph node'
|
||||
).toHaveCount(4)
|
||||
const initialCount = await widgets.count()
|
||||
|
||||
await comfyPage.subgraph.serializeAndReload()
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const outerNodeAfter = comfyPage.vueNodes.getNodeLocator(OUTER_NODE_ID)
|
||||
const widgetsAfter = outerNodeAfter.getByTestId(TestIds.widgets.widget)
|
||||
await comfyExpect(widgetsAfter).toHaveCount(initialCount)
|
||||
|
||||
const valueWidget = outerNodeAfter
|
||||
.getByRole('textbox', { name: 'value' })
|
||||
.first()
|
||||
await comfyExpect(valueWidget).toBeVisible()
|
||||
await comfyExpect(valueWidget).toHaveValue(/Inner 1/)
|
||||
})
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -14,6 +14,30 @@ import {
|
||||
const DUPLICATE_IDS_WORKFLOW = 'subgraphs/subgraph-nested-duplicate-ids'
|
||||
const LEGACY_PREFIXED_WORKFLOW =
|
||||
'subgraphs/nested-subgraph-legacy-prefixed-proxy-widgets'
|
||||
const LEGACY_THREE_TUPLE_WORKFLOW = 'subgraphs/nested-duplicate-widget-names'
|
||||
const MULTI_INSTANCE_WORKFLOW =
|
||||
'subgraphs/subgraph-multi-instance-promoted-text-values'
|
||||
|
||||
async function getPromotedHostWidgetValues(
|
||||
comfyPage: ComfyPage,
|
||||
nodeIds: string[]
|
||||
) {
|
||||
return comfyPage.page.evaluate((ids) => {
|
||||
const graph = window.app!.canvas.graph!
|
||||
|
||||
return ids.map((id) => {
|
||||
const node = graph.getNodeById(id)
|
||||
if (!node?.isSubgraphNode()) {
|
||||
return { id, values: [] as unknown[] }
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
values: (node.widgets ?? []).map((widget) => widget.value)
|
||||
}
|
||||
})
|
||||
}, nodeIds)
|
||||
}
|
||||
|
||||
async function expectPromotedWidgetsToResolveToInteriorNodes(
|
||||
comfyPage: ComfyPage,
|
||||
@@ -498,4 +522,63 @@ test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
test(
|
||||
'Legacy 3-tuple proxyWidgets entries serialize back to 2-tuples after load',
|
||||
{ tag: '@vue-nodes' },
|
||||
async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow(LEGACY_THREE_TUPLE_WORKFLOW)
|
||||
|
||||
const hostNode = comfyPage.vueNodes.getNodeLocator('4')
|
||||
await expect(hostNode).toBeVisible()
|
||||
|
||||
const promotedTextbox = hostNode.getByRole('textbox', {
|
||||
name: 'text',
|
||||
exact: true
|
||||
})
|
||||
await expect(promotedTextbox).toHaveCount(1)
|
||||
await expect(promotedTextbox).toHaveValue('22222222222')
|
||||
|
||||
await expect(hostNode.getByText('text', { exact: true })).toBeVisible()
|
||||
|
||||
const serializedProxyWidgets = await comfyPage.page.evaluate(() => {
|
||||
const serialized = window.app!.graph!.serialize()
|
||||
const hostNode = serialized.nodes.find((node) => node.id === 4)
|
||||
const proxyWidgets = hostNode?.properties?.proxyWidgets
|
||||
return Array.isArray(proxyWidgets) ? proxyWidgets : []
|
||||
})
|
||||
|
||||
expect(serializedProxyWidgets).toEqual([['3', '3: 2: text']])
|
||||
expect(
|
||||
serializedProxyWidgets.every(
|
||||
(entry) => Array.isArray(entry) && entry.length === 2
|
||||
)
|
||||
).toBe(true)
|
||||
}
|
||||
)
|
||||
|
||||
test('Multiple instances of the same subgraph keep distinct promoted widget values after load and reload', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const hostNodeIds = ['11', '12', '13']
|
||||
const expectedValues = ['Alpha\n', 'Beta\n', 'Gamma\n']
|
||||
|
||||
await comfyPage.workflow.loadWorkflow(MULTI_INSTANCE_WORKFLOW)
|
||||
|
||||
const initialValues = await getPromotedHostWidgetValues(
|
||||
comfyPage,
|
||||
hostNodeIds
|
||||
)
|
||||
expect(initialValues.map(({ values }) => values[0])).toEqual(expectedValues)
|
||||
|
||||
await comfyPage.subgraph.serializeAndReload()
|
||||
|
||||
const reloadedValues = await getPromotedHostWidgetValues(
|
||||
comfyPage,
|
||||
hostNodeIds
|
||||
)
|
||||
expect(reloadedValues.map(({ values }) => values[0])).toEqual(
|
||||
expectedValues
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -250,13 +250,7 @@ export default defineConfig([
|
||||
// @/utils/errorUtil instead — see issue #11429.
|
||||
selector: "TSAsExpression TSTypeReference[typeName.name='Error']",
|
||||
message:
|
||||
'Do not use Error type assertions. Use `instanceof Error` narrowing or `toError()` from @/utils/errorUtil instead. See issue #11429.'
|
||||
},
|
||||
{
|
||||
// Bans `<Error>value` and `<Error & { ... }>value`.
|
||||
selector: "TSTypeAssertion TSTypeReference[typeName.name='Error']",
|
||||
message:
|
||||
'Do not use Error type assertions. Use `instanceof Error` narrowing or `toError()` from @/utils/errorUtil instead. See issue #11429.'
|
||||
'Do not use `as Error` assertions. Use `instanceof Error` narrowing or `toError()` from @/utils/errorUtil instead. See issue #11429.'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { isEmbeddedWebView } from '@/base/webviewDetection'
|
||||
|
||||
describe('isEmbeddedWebView', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('Android WebView', () => {
|
||||
it('detects Android WebView with wv token', () => {
|
||||
const ua =
|
||||
'Mozilla/5.0 (Linux; Android 13; SM-G991B; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/120.0.0.0 Mobile Safari/537.36'
|
||||
expect(isEmbeddedWebView(ua)).toBe(true)
|
||||
})
|
||||
|
||||
it('does not flag regular Chrome on Android', () => {
|
||||
const ua =
|
||||
'Mozilla/5.0 (Linux; Android 13; SM-G991B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36'
|
||||
expect(isEmbeddedWebView(ua)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('iOS WKWebView', () => {
|
||||
it('detects iOS WKWebView (AppleWebKit without Safari/)', () => {
|
||||
const ua =
|
||||
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148'
|
||||
expect(isEmbeddedWebView(ua)).toBe(true)
|
||||
})
|
||||
|
||||
it('does not flag regular Safari on iOS', () => {
|
||||
const ua =
|
||||
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1'
|
||||
expect(isEmbeddedWebView(ua)).toBe(false)
|
||||
})
|
||||
|
||||
it('does not flag Chrome on iOS (CriOS)', () => {
|
||||
const ua =
|
||||
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/120.0.0.0 Mobile/15E148'
|
||||
expect(isEmbeddedWebView(ua)).toBe(false)
|
||||
})
|
||||
|
||||
it('does not flag Firefox on iOS (FxiOS)', () => {
|
||||
const ua =
|
||||
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) FxiOS/120.0 Mobile/15E148'
|
||||
expect(isEmbeddedWebView(ua)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('social app in-app browsers', () => {
|
||||
it('detects Facebook (FBAN)', () => {
|
||||
const ua =
|
||||
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 [FBAN/FBIOS;FBAV/400.0]'
|
||||
expect(isEmbeddedWebView(ua)).toBe(true)
|
||||
})
|
||||
|
||||
it('detects Instagram', () => {
|
||||
const ua =
|
||||
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 Instagram 300.0'
|
||||
expect(isEmbeddedWebView(ua)).toBe(true)
|
||||
})
|
||||
|
||||
it('detects TikTok', () => {
|
||||
const ua =
|
||||
'Mozilla/5.0 (Linux; Android 13) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36 TikTok/30.0'
|
||||
expect(isEmbeddedWebView(ua)).toBe(true)
|
||||
})
|
||||
|
||||
it('detects Line', () => {
|
||||
const ua =
|
||||
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 Line/13.0'
|
||||
expect(isEmbeddedWebView(ua)).toBe(true)
|
||||
})
|
||||
|
||||
it('detects Snapchat', () => {
|
||||
const ua =
|
||||
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 Snapchat/12.0'
|
||||
expect(isEmbeddedWebView(ua)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('regular desktop browsers', () => {
|
||||
it('does not flag Chrome desktop', () => {
|
||||
const ua =
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
|
||||
expect(isEmbeddedWebView(ua)).toBe(false)
|
||||
})
|
||||
|
||||
it('does not flag Firefox desktop', () => {
|
||||
const ua =
|
||||
'Mozilla/5.0 (X11; Linux x86_64; rv:120.0) Gecko/20100101 Firefox/120.0'
|
||||
expect(isEmbeddedWebView(ua)).toBe(false)
|
||||
})
|
||||
|
||||
it('does not flag Safari desktop', () => {
|
||||
const ua =
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15'
|
||||
expect(isEmbeddedWebView(ua)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('handles empty string', () => {
|
||||
expect(isEmbeddedWebView('')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('JS bridge detection', () => {
|
||||
it('detects webkit.messageHandlers bridge', () => {
|
||||
vi.stubGlobal('webkit', { messageHandlers: {} })
|
||||
expect(isEmbeddedWebView('')).toBe(true)
|
||||
})
|
||||
|
||||
it('detects ReactNativeWebView bridge', () => {
|
||||
vi.stubGlobal('ReactNativeWebView', { postMessage: vi.fn() })
|
||||
expect(isEmbeddedWebView('')).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,72 +0,0 @@
|
||||
/**
|
||||
* Detects whether the app is running inside an embedded webview.
|
||||
*
|
||||
* Google blocks OAuth via `signInWithPopup` in embedded webviews,
|
||||
* returning a 403 `disallowed_useragent` error (policy since 2021).
|
||||
* This utility is used to hide the Google SSO button in those contexts.
|
||||
*
|
||||
* Detection covers:
|
||||
* • Android WebView (`wv` token in UA)
|
||||
* • iOS WKWebView (has `AppleWebKit` but lacks `Safari/`)
|
||||
* • Social app in-app browsers (Facebook, Instagram, TikTok, etc.)
|
||||
* • JS bridge objects (`window.webkit.messageHandlers`, `ReactNativeWebView`)
|
||||
*/
|
||||
|
||||
const SOCIAL_APP_PATTERNS =
|
||||
/FBAN|FBAV|Instagram|Line\/|Snapchat|TikTok|musical_ly/i
|
||||
|
||||
function isAndroidWebView(ua: string): boolean {
|
||||
return /\bwv\b/.test(ua) && /Android/.test(ua)
|
||||
}
|
||||
|
||||
function isIOSWebView(ua: string): boolean {
|
||||
if (!/AppleWebKit/i.test(ua)) return false
|
||||
if (/Safari\//i.test(ua)) return false
|
||||
if (/CriOS|FxiOS|OPiOS|EdgiOS/i.test(ua)) return false
|
||||
return true
|
||||
}
|
||||
|
||||
function isSocialAppBrowser(ua: string): boolean {
|
||||
return SOCIAL_APP_PATTERNS.test(ua)
|
||||
}
|
||||
|
||||
function hasWebViewBridge(): boolean {
|
||||
try {
|
||||
const win = globalThis as Record<string, unknown>
|
||||
if (
|
||||
typeof win.webkit === 'object' &&
|
||||
win.webkit !== null &&
|
||||
typeof (win.webkit as Record<string, unknown>).messageHandlers ===
|
||||
'object'
|
||||
) {
|
||||
return true
|
||||
}
|
||||
if (win.ReactNativeWebView != null) return true
|
||||
} catch {
|
||||
// Access to bridge objects may throw in sandboxed contexts
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export function isEmbeddedWebView(ua: string = navigator.userAgent): boolean {
|
||||
if (isSocialAppBrowser(ua)) return true
|
||||
if (isAndroidWebView(ua)) return true
|
||||
if (isIOSWebView(ua)) return true
|
||||
if (hasWebViewBridge()) return true
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Reason why Google SSO is blocked in the current environment, or `null` if it
|
||||
* is available. Modeled as a discriminated string so call sites read as
|
||||
* "if blocked, here's why" rather than an opaque boolean. Extend this union
|
||||
* (e.g. `'unauthorized-host'`) as new blocking conditions are detected.
|
||||
*/
|
||||
type GoogleSsoBlockedReason = 'embedded-webview' | null
|
||||
|
||||
export function getGoogleSsoBlockedReason(
|
||||
ua: string = navigator.userAgent
|
||||
): GoogleSsoBlockedReason {
|
||||
if (isEmbeddedWebView(ua)) return 'embedded-webview'
|
||||
return null
|
||||
}
|
||||
@@ -8,7 +8,6 @@ import AppModeWidgetList from '@/components/builder/AppModeWidgetList.vue'
|
||||
import DraggableList from '@/components/common/DraggableList.vue'
|
||||
import IoItem from '@/components/builder/IoItem.vue'
|
||||
import PropertiesAccordionItem from '@/components/rightSidePanel/layout/PropertiesAccordionItem.vue'
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
|
||||
@@ -28,7 +27,10 @@ import { DOMWidgetImpl } from '@/scripts/domWidget'
|
||||
import { renameWidget } from '@/utils/widgetUtil'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import { nodeTypeValidForApp, useAppModeStore } from '@/stores/appModeStore'
|
||||
import { resolveNodeWidget } from '@/utils/litegraphUtil'
|
||||
import {
|
||||
getSelectedWidgetIdentity,
|
||||
resolveNodeWidget
|
||||
} from '@/utils/litegraphUtil'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
type BoundStyle = { top: string; left: string; width: string; height: string }
|
||||
@@ -157,10 +159,7 @@ function handleClick(e: MouseEvent) {
|
||||
}
|
||||
if (!isSelectInputsMode.value || widget.options.canvasOnly) return
|
||||
|
||||
const storeId = isPromotedWidgetView(widget) ? widget.sourceNodeId : node.id
|
||||
const storeName = isPromotedWidgetView(widget)
|
||||
? widget.sourceWidgetName
|
||||
: widget.name
|
||||
const [storeId, storeName] = getSelectedWidgetIdentity(node, widget)
|
||||
const index = appModeStore.selectedInputs.findIndex(
|
||||
([nodeId, widgetName]) => storeId == nodeId && storeName === widgetName
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
import { computed, provide, shallowRef } from 'vue'
|
||||
import { computed, provide, ref, shallowRef } from 'vue'
|
||||
|
||||
import { useAppModeWidgetResizing } from '@/components/builder/useAppModeWidgetResizing'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
@@ -64,8 +64,20 @@ useEventListener(
|
||||
() => (graphNodes.value = app.rootGraph.nodes)
|
||||
)
|
||||
|
||||
// Widget renames mutate `widget.label` on the live LiteGraph object and fire
|
||||
// `node:slot-label:changed` on `graph.events`. That object is non-reactive,
|
||||
// so the rendered label (line ~203) goes stale. Bump a version counter on the
|
||||
// event and read it inside `mappedSelections` to force re-evaluation.
|
||||
const labelVersion = ref(0)
|
||||
useEventListener(
|
||||
app.rootGraph.events,
|
||||
'node:slot-label:changed',
|
||||
() => labelVersion.value++
|
||||
)
|
||||
|
||||
const mappedSelections = computed((): WidgetEntry[] => {
|
||||
void graphNodes.value
|
||||
void labelVersion.value
|
||||
const nodeDataByNode = new Map<
|
||||
LGraphNode,
|
||||
ReturnType<typeof nodeToNodeData>
|
||||
@@ -85,17 +97,14 @@ const mappedSelections = computed((): WidgetEntry[] => {
|
||||
|
||||
if (!node.isSubgraphNode()) return vueWidget.name === widget.name
|
||||
|
||||
const storeNodeId = vueWidget.storeNodeId?.split(':')?.[1] ?? ''
|
||||
return (
|
||||
isPromotedWidgetView(widget) &&
|
||||
widget.sourceNodeId == storeNodeId &&
|
||||
widget.sourceWidgetName === vueWidget.storeName
|
||||
vueWidget.instanceWidgetName === widget.storeName
|
||||
)
|
||||
})
|
||||
if (!matchingWidget) return []
|
||||
|
||||
matchingWidget.slotMetadata = undefined
|
||||
matchingWidget.nodeId = String(node.id)
|
||||
|
||||
return [
|
||||
{
|
||||
|
||||
@@ -49,7 +49,6 @@
|
||||
<div class="flex flex-col gap-6">
|
||||
<template v-if="ssoAllowed">
|
||||
<Button
|
||||
v-if="!googleSsoBlockedReason"
|
||||
type="button"
|
||||
class="h-10"
|
||||
variant="secondary"
|
||||
@@ -158,7 +157,6 @@ import type { SignInData, SignUpData } from '@/schemas/signInSchema'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { isHostWhitelisted, normalizeHost } from '@/utils/hostWhitelist'
|
||||
import { isInChina } from '@/utils/networkUtil'
|
||||
import { getGoogleSsoBlockedReason } from '@/base/webviewDetection'
|
||||
|
||||
import ApiKeyForm from './signin/ApiKeyForm.vue'
|
||||
import SignInForm from './signin/SignInForm.vue'
|
||||
@@ -174,7 +172,6 @@ const isSecureContext = window.isSecureContext
|
||||
const isSignIn = ref(true)
|
||||
const showApiKeyForm = ref(false)
|
||||
const ssoAllowed = isHostWhitelisted(normalizeHost(window.location.hostname))
|
||||
const googleSsoBlockedReason = getGoogleSsoBlockedReason()
|
||||
const comfyPlatformBaseUrl = computed(() =>
|
||||
configValueOrDefault(
|
||||
remoteConfig.value,
|
||||
|
||||
@@ -5,6 +5,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { computed, nextTick, watch } from 'vue'
|
||||
|
||||
import { useGraphNodeManager } from '@/composables/graph/useGraphNodeManager'
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { createPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetView'
|
||||
import { BaseWidget, LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import {
|
||||
@@ -187,7 +188,7 @@ describe('Widget slotMetadata reactivity on link disconnect', () => {
|
||||
expect(onChange).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('updates slotMetadata for promoted widgets where SafeWidgetData.name differs from input.widget.name', async () => {
|
||||
it('updates slotMetadata for promoted widgets where SafeWidgetData.displayName differs from input.widget.name', async () => {
|
||||
// Set up a subgraph with an interior node that has a "prompt" widget.
|
||||
// createPromotedWidgetView resolves against this interior node.
|
||||
const subgraph = createTestSubgraph()
|
||||
@@ -201,7 +202,7 @@ describe('Widget slotMetadata reactivity on link disconnect', () => {
|
||||
// Create a PromotedWidgetView with identityName="value" (subgraph input
|
||||
// slot name) and sourceWidgetName="prompt" (interior widget name).
|
||||
// PromotedWidgetView.name returns "value" (identity), safeWidgetMapper
|
||||
// sets SafeWidgetData.name to sourceWidgetName ("prompt").
|
||||
// sets SafeWidgetData.displayName to sourceWidgetName ("prompt").
|
||||
const promotedView = createPromotedWidgetView(
|
||||
subgraphNode,
|
||||
'10',
|
||||
@@ -224,7 +225,7 @@ describe('Widget slotMetadata reactivity on link disconnect', () => {
|
||||
const { vueNodeData } = useGraphNodeManager(graph)
|
||||
const nodeData = vueNodeData.get(String(hostNode.id))
|
||||
|
||||
// SafeWidgetData.name is "prompt" (sourceWidgetName), but the
|
||||
// SafeWidgetData.displayName is "prompt" (sourceWidgetName), but the
|
||||
// input slot widget name is "value" — slotName bridges this gap.
|
||||
const widgetData = nodeData?.widgets?.find((w) => w.name === 'prompt')
|
||||
expect(widgetData).toBeDefined()
|
||||
@@ -471,10 +472,15 @@ describe('Nested promoted widget mapping', () => {
|
||||
|
||||
expect(mappedWidget).toBeDefined()
|
||||
expect(mappedWidget?.type).toBe('combo')
|
||||
expect(mappedWidget?.storeName).toBe('picker')
|
||||
expect(mappedWidget?.storeNodeId).toBe(
|
||||
expect(mappedWidget?.name).toBe('picker')
|
||||
expect(mappedWidget?.sourceNodeLocatorId).toBe(
|
||||
`${subgraphNodeB.subgraph.id}:${innerNode.id}`
|
||||
)
|
||||
expect(mappedWidget?.source).toEqual({
|
||||
sourceNodeId: String(innerNode.id),
|
||||
sourceWidgetName: 'picker',
|
||||
disambiguatingSourceNodeId: undefined
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps linked and independent same-name promotions as distinct sources', () => {
|
||||
@@ -516,7 +522,7 @@ describe('Nested promoted widget mapping', () => {
|
||||
|
||||
expect(promotedWidgets).toHaveLength(2)
|
||||
expect(
|
||||
new Set(promotedWidgets?.map((widget) => widget.storeNodeId))
|
||||
new Set(promotedWidgets?.map((widget) => widget.sourceNodeLocatorId))
|
||||
).toEqual(
|
||||
new Set([
|
||||
`${subgraph.id}:${linkedNode.id}`,
|
||||
@@ -580,7 +586,7 @@ describe('Nested promoted widget mapping', () => {
|
||||
|
||||
expect(promotedWidgets).toHaveLength(2)
|
||||
expect(
|
||||
new Set(promotedWidgets?.map((widget) => widget.storeNodeId))
|
||||
new Set(promotedWidgets?.map((widget) => widget.sourceNodeLocatorId))
|
||||
).toEqual(
|
||||
new Set([
|
||||
`${outerSubgraphNode.subgraph.id}:${firstTextNode.id}`,
|
||||
@@ -590,6 +596,98 @@ describe('Nested promoted widget mapping', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('safeWidgetMapper per-instance widget identity', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
it('populates nodeId with the SubgraphNode instance id and instanceWidgetName with the view storeName for promoted widgets', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'text', type: 'STRING' }]
|
||||
})
|
||||
const interiorNode = new LGraphNode('CLIPTextEncode')
|
||||
const interiorInput = interiorNode.addInput('text', 'STRING')
|
||||
interiorNode.addWidget('text', 'text', '', () => undefined, {})
|
||||
interiorInput.widget = { name: 'text' }
|
||||
subgraph.add(interiorNode)
|
||||
subgraph.inputNode.slots[0].connect(interiorInput, interiorNode)
|
||||
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { id: 100 })
|
||||
subgraphNode._internalConfigureAfterSlots()
|
||||
const graph = subgraphNode.graph as LGraph
|
||||
graph.add(subgraphNode)
|
||||
|
||||
const promotedView = subgraphNode.widgets[0]
|
||||
if (!promotedView || !isPromotedWidgetView(promotedView)) {
|
||||
throw new Error('Expected first widget to be a promoted view')
|
||||
}
|
||||
const expectedStoreName = promotedView.storeName
|
||||
|
||||
const { vueNodeData } = useGraphNodeManager(graph)
|
||||
const widgetData = vueNodeData
|
||||
.get(String(subgraphNode.id))
|
||||
?.widgets?.find((w) => w.name === 'text')
|
||||
|
||||
expect(widgetData).toBeDefined()
|
||||
expect(widgetData?.nodeId).toBe(String(subgraphNode.id))
|
||||
expect(widgetData?.instanceWidgetName).toBe(expectedStoreName)
|
||||
})
|
||||
|
||||
it('does not set nodeId or instanceWidgetName for non-promoted widgets', () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('test')
|
||||
node.addWidget('number', 'steps', 20, () => undefined, {})
|
||||
graph.add(node)
|
||||
|
||||
const { vueNodeData } = useGraphNodeManager(graph)
|
||||
const widgetData = vueNodeData
|
||||
.get(String(node.id))
|
||||
?.widgets?.find((w) => w.name === 'steps')
|
||||
|
||||
expect(widgetData).toBeDefined()
|
||||
expect(widgetData?.nodeId).toBeUndefined()
|
||||
expect(widgetData?.instanceWidgetName).toBeUndefined()
|
||||
})
|
||||
|
||||
it('produces distinct nodeId values for two SubgraphNode instances of one definition', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'text', type: 'STRING' }]
|
||||
})
|
||||
const interiorNode = new LGraphNode('CLIPTextEncode')
|
||||
const interiorInput = interiorNode.addInput('text', 'STRING')
|
||||
interiorNode.addWidget('text', 'text', '', () => undefined, {})
|
||||
interiorInput.widget = { name: 'text' }
|
||||
subgraph.add(interiorNode)
|
||||
subgraph.inputNode.slots[0].connect(interiorInput, interiorNode)
|
||||
|
||||
const instanceA = createTestSubgraphNode(subgraph, { id: 100 })
|
||||
instanceA._internalConfigureAfterSlots()
|
||||
const graph = instanceA.graph as LGraph
|
||||
graph.add(instanceA)
|
||||
|
||||
const instanceB = createTestSubgraphNode(subgraph, {
|
||||
id: 200,
|
||||
parentGraph: graph
|
||||
})
|
||||
instanceB._internalConfigureAfterSlots()
|
||||
graph.add(instanceB)
|
||||
|
||||
const { vueNodeData } = useGraphNodeManager(graph)
|
||||
const widgetA = vueNodeData
|
||||
.get(String(instanceA.id))
|
||||
?.widgets?.find((w) => w.name === 'text')
|
||||
const widgetB = vueNodeData
|
||||
.get(String(instanceB.id))
|
||||
?.widgets?.find((w) => w.name === 'text')
|
||||
|
||||
expect(widgetA?.nodeId).toBe('100')
|
||||
expect(widgetB?.nodeId).toBe('200')
|
||||
// Both share the same definition so instanceWidgetName matches —
|
||||
// only nodeId distinguishes them.
|
||||
expect(widgetA?.instanceWidgetName).toBe(widgetB?.instanceWidgetName)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Promoted widget sourceExecutionId', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
|
||||
@@ -50,13 +50,19 @@ export interface WidgetSlotMetadata {
|
||||
|
||||
/**
|
||||
* Minimal render-specific widget data extracted from LiteGraph widgets.
|
||||
* Value and metadata (label, hidden, disabled, etc.) are accessed via widgetValueStore.
|
||||
* Per-instance promoted-widget state still lives in widgetValueStore.
|
||||
*/
|
||||
export interface SafeWidgetData {
|
||||
/** For promoted widgets: host SubgraphNode instance used for store lookup. */
|
||||
nodeId?: NodeId
|
||||
storeNodeId?: NodeId
|
||||
/** Display-facing widget name; not a stable per-instance key. */
|
||||
name: string
|
||||
storeName?: string
|
||||
/** Opaque widget-store name paired with nodeId in `widgetValueStore`—do not parse. */
|
||||
instanceWidgetName?: string
|
||||
/** Promoted source identity for grouping and lookups. */
|
||||
source?: PromotedWidgetSource
|
||||
/** Locator ID of the resolved source node. */
|
||||
sourceNodeLocatorId?: string
|
||||
type: string
|
||||
/** Callback to invoke when widget value changes (wraps LiteGraph callback + triggerDraw) */
|
||||
callback?: ((value: unknown) => void) | undefined
|
||||
@@ -80,22 +86,13 @@ export interface SafeWidgetData {
|
||||
spec?: InputSpec
|
||||
/** Input slot metadata (index and link status) */
|
||||
slotMetadata?: WidgetSlotMetadata
|
||||
/**
|
||||
* Original LiteGraph widget name used for slot metadata matching.
|
||||
* For promoted widgets, `name` is `sourceWidgetName` (interior widget name)
|
||||
* which differs from the subgraph node's input slot widget name.
|
||||
*/
|
||||
/** Subgraph input slot name used when displayName comes from the source widget. */
|
||||
slotName?: string
|
||||
/**
|
||||
* Execution ID of the interior node that owns the source widget.
|
||||
* Only set for promoted widgets where the source node differs from the
|
||||
* host subgraph node. Used for missing-model lookups that key by
|
||||
* execution ID (e.g. `"65:42"` vs the host node's `"65"`).
|
||||
*/
|
||||
/** Execution ID of the resolved source node for promoted-widget lookups. */
|
||||
sourceExecutionId?: string
|
||||
/** Tooltip text from the resolved widget. */
|
||||
tooltip?: string
|
||||
/** For promoted widgets, the display label from the subgraph input slot. */
|
||||
/** Current promoted-view label, if any. */
|
||||
promotedLabel?: string
|
||||
}
|
||||
|
||||
@@ -277,7 +274,7 @@ function safeWidgetMapper(
|
||||
const { displayName, promotedSource } =
|
||||
resolvePromotedWidgetIdentity(widget)
|
||||
|
||||
// Get shared enhancements (controlWidget, spec, nodeType)
|
||||
// Get shared widget enhancements.
|
||||
const sharedEnhancements = getSharedWidgetEnhancements(node, widget)
|
||||
const slotInfo =
|
||||
slotMetadata.get(displayName) ?? slotMetadata.get(widget.name)
|
||||
@@ -297,7 +294,7 @@ function safeWidgetMapper(
|
||||
const isPromotedPseudoWidget =
|
||||
isPromotedWidgetView(widget) && widget.sourceWidgetName.startsWith('$$')
|
||||
|
||||
// Extract only render-critical options (canvasOnly, advanced, read_only)
|
||||
// Extract render-facing widget options.
|
||||
const options = extractWidgetDisplayOptions(widget)
|
||||
const subgraphId = node.isSubgraphNode() && node.subgraph.id
|
||||
|
||||
@@ -319,25 +316,47 @@ function safeWidgetMapper(
|
||||
|
||||
const effectiveWidget = sourceWidget ?? widget
|
||||
|
||||
const localId = isPromotedWidgetView(widget)
|
||||
? String(
|
||||
sourceNode?.id ??
|
||||
promotedSource?.disambiguatingSourceNodeId ??
|
||||
promotedSource?.sourceNodeId
|
||||
)
|
||||
: undefined
|
||||
const nodeId =
|
||||
subgraphId && localId ? `${subgraphId}:${localId}` : undefined
|
||||
const storeName = isPromotedWidgetView(widget)
|
||||
// Host node id: for promoted views, the SubgraphNode instance id;
|
||||
// for non-promoted widgets, undefined (consumers fall back to the
|
||||
// host node id from rendering context).
|
||||
const nodeId = isPromotedWidgetView(widget) ? String(node.id) : undefined
|
||||
|
||||
const sourceWidgetName = isPromotedWidgetView(widget)
|
||||
? (sourceWidget?.name ?? promotedSource?.sourceWidgetName)
|
||||
: undefined
|
||||
const name = storeName ?? displayName
|
||||
const rawSourceLocalId = isPromotedWidgetView(widget)
|
||||
? (sourceNode?.id ??
|
||||
promotedSource?.disambiguatingSourceNodeId ??
|
||||
promotedSource?.sourceNodeId)
|
||||
: undefined
|
||||
const sourceLocalId =
|
||||
rawSourceLocalId != null ? String(rawSourceLocalId) : undefined
|
||||
const source: PromotedWidgetSource | undefined =
|
||||
isPromotedWidgetView(widget) && sourceLocalId && sourceWidgetName
|
||||
? {
|
||||
sourceNodeId: sourceLocalId,
|
||||
sourceWidgetName,
|
||||
disambiguatingSourceNodeId:
|
||||
promotedSource?.disambiguatingSourceNodeId
|
||||
}
|
||||
: undefined
|
||||
const sourceNodeLocatorId =
|
||||
source && subgraphId
|
||||
? `${subgraphId}:${source.sourceNodeId}`
|
||||
: undefined
|
||||
|
||||
const instanceWidgetName = isPromotedWidgetView(widget)
|
||||
? widget.storeName
|
||||
: undefined
|
||||
|
||||
const widgetDisplayName = sourceWidgetName ?? displayName
|
||||
|
||||
return {
|
||||
nodeId,
|
||||
storeNodeId: nodeId,
|
||||
name,
|
||||
storeName,
|
||||
name: widgetDisplayName,
|
||||
instanceWidgetName,
|
||||
source,
|
||||
sourceNodeLocatorId,
|
||||
type: effectiveWidget.type,
|
||||
...sharedEnhancements,
|
||||
callback,
|
||||
@@ -350,9 +369,10 @@ function safeWidgetMapper(
|
||||
}
|
||||
: (extractWidgetDisplayOptions(effectiveWidget) ?? options),
|
||||
slotMetadata: slotInfo,
|
||||
// For promoted widgets, name is sourceWidgetName while widget.name
|
||||
// is the subgraph input slot name — store the slot name for lookups.
|
||||
slotName: name !== widget.name ? widget.name : undefined,
|
||||
// For promoted widgets, displayName is sourceWidgetName while
|
||||
// widget.name is the subgraph input slot name — store the slot
|
||||
// name for lookups.
|
||||
slotName: widgetDisplayName !== widget.name ? widget.name : undefined,
|
||||
sourceExecutionId:
|
||||
sourceNode && app.rootGraph
|
||||
? (getExecutionIdByNode(app.rootGraph, sourceNode) ?? undefined)
|
||||
|
||||
@@ -2,6 +2,8 @@ import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetT
|
||||
import { resolveConcretePromotedWidget } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
|
||||
// Collision: a widget literally named `"<digits>: rest"` is ambiguous;
|
||||
// `normalizeLegacyProxyWidgetEntry` resolves the literal name first.
|
||||
const LEGACY_PROXY_WIDGET_PREFIX_PATTERN = /^\s*(\d+)\s*:\s*(.+)$/
|
||||
|
||||
type PromotedWidgetPatch = Omit<PromotedWidgetSource, 'sourceNodeId'>
|
||||
|
||||
@@ -24,6 +24,12 @@ export interface PromotedWidgetView extends IBaseWidget {
|
||||
* origin.
|
||||
*/
|
||||
readonly disambiguatingSourceNodeId?: string
|
||||
/**
|
||||
* Opaque widget-store key paired with the host SubgraphNode. Built via
|
||||
* `makeCompositeKey` over `(sourceNodeId, sourceWidgetName,
|
||||
* disambiguatingSourceNodeId ?? '')`. Treat as opaque: do not parse.
|
||||
*/
|
||||
readonly storeName: string
|
||||
}
|
||||
|
||||
export function isPromotedWidgetView(
|
||||
|
||||
113
src/core/graph/subgraph/promotedWidgetView.lazyInterior.test.ts
Normal file
113
src/core/graph/subgraph/promotedWidgetView.lazyInterior.test.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest'
|
||||
|
||||
// Barrel import for SubgraphNode/LGraph circular dep
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
import {
|
||||
createTestRootGraph,
|
||||
createTestSubgraph,
|
||||
createTestSubgraphNode,
|
||||
resetSubgraphFixtureState
|
||||
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => ({})
|
||||
}))
|
||||
vi.mock('@/stores/domWidgetStore', () => ({
|
||||
useDomWidgetStore: () => ({
|
||||
widgetStates: new Map(),
|
||||
setPositionOverride: vi.fn(),
|
||||
clearPositionOverride: vi.fn()
|
||||
})
|
||||
}))
|
||||
vi.mock('@/services/litegraphService', () => ({
|
||||
useLitegraphService: () => ({ updatePreviews: () => ({}) })
|
||||
}))
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
resetSubgraphFixtureState()
|
||||
})
|
||||
|
||||
/**
|
||||
* Mimics PrimitiveNode (src/extensions/core/widgetInputs.ts:32+) — empty
|
||||
* widgets until onAfterGraphConfigured creates them and re-applies
|
||||
* widgets_values. Reproduces the load-time race against
|
||||
* SubgraphNode._replayPromotedWidgetValues.
|
||||
*/
|
||||
class LazyPrimitiveLikeNode extends LGraphNode {
|
||||
constructor() {
|
||||
super('LazyPrimitiveLike')
|
||||
this.serialize_widgets = true
|
||||
}
|
||||
|
||||
override onAfterGraphConfigured(): void {
|
||||
if (this.widgets?.length) return
|
||||
const widget = this.addWidget('text', 'value', '', () => {})
|
||||
const stored = this.widgets_values
|
||||
if (stored?.length) {
|
||||
widget.value = stored[0] as string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('PromotedWidgetView with lazy-creation interior widget', () => {
|
||||
test('per-instance "exterior" override survives interior lazy widget materialization', () => {
|
||||
const rootGraph = createTestRootGraph()
|
||||
const subgraph = createTestSubgraph({ rootGraph })
|
||||
|
||||
const interior = new LazyPrimitiveLikeNode()
|
||||
interior.widgets_values = ['interior']
|
||||
subgraph.add(interior)
|
||||
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, {
|
||||
parentGraph: rootGraph
|
||||
})
|
||||
rootGraph.add(subgraphNode)
|
||||
|
||||
// Drive the load path: configure with proxyWidgets and exterior value.
|
||||
subgraphNode.configure({
|
||||
id: subgraphNode.id,
|
||||
type: subgraph.id,
|
||||
pos: [100, 100],
|
||||
size: [200, 100],
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
mode: 0,
|
||||
order: 0,
|
||||
flags: {},
|
||||
properties: {
|
||||
proxyWidgets: [[String(interior.id), 'value']]
|
||||
},
|
||||
widgets_values: ['exterior']
|
||||
})
|
||||
|
||||
// _replayPromotedWidgetValues has run, but interior.widgets is empty —
|
||||
// write-through no-ops; only the per-instance override holds "exterior".
|
||||
expect(interior.widgets?.length ?? 0).toBe(0)
|
||||
|
||||
// Lazy materialization clobbers any prior interior write with the
|
||||
// serialized widgets_values=["interior"].
|
||||
interior.onAfterGraphConfigured()
|
||||
expect(interior.widgets?.[0].value).toBe('interior')
|
||||
|
||||
// SubgraphNode.onAfterGraphConfigured (called child-first by
|
||||
// triggerCallbackOnAllNodes in production) re-projects the per-instance
|
||||
// override onto the now-materialized interior widget.
|
||||
subgraphNode.onAfterGraphConfigured?.()
|
||||
|
||||
const widgetStore = useWidgetValueStore()
|
||||
const view = subgraphNode.widgets[0]
|
||||
expect(view.value).toBe('exterior')
|
||||
expect(interior.widgets?.[0].value).toBe('exterior')
|
||||
const interiorCell = widgetStore.getWidget(
|
||||
rootGraph.id,
|
||||
interior.id,
|
||||
'value'
|
||||
)
|
||||
expect(interiorCell?.value).toBe('exterior')
|
||||
})
|
||||
})
|
||||
@@ -21,11 +21,9 @@ import { createPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetVi
|
||||
import type { PromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetView'
|
||||
import { resolveConcretePromotedWidget } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
|
||||
import { resolvePromotedWidgetSource } from '@/core/graph/subgraph/resolvePromotedWidgetSource'
|
||||
import type { SerializedProxyWidgetTuple } from '@/core/schemas/promotionSchema'
|
||||
import { usePromotionStore } from '@/stores/promotionStore'
|
||||
import {
|
||||
stripGraphPrefix,
|
||||
useWidgetValueStore
|
||||
} from '@/stores/widgetValueStore'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
|
||||
import {
|
||||
createTestRootGraph,
|
||||
@@ -75,7 +73,7 @@ function setupSubgraph(
|
||||
|
||||
function setPromotions(
|
||||
subgraphNode: SubgraphNode,
|
||||
entries: [string, string][]
|
||||
entries: SerializedProxyWidgetTuple[]
|
||||
) {
|
||||
usePromotionStore().setPromotions(
|
||||
subgraphNode.rootGraph.id,
|
||||
@@ -263,7 +261,7 @@ describe(createPromotedWidgetView, () => {
|
||||
expect(view.linkedWidgets?.[0].name).toBe('control_after_generate')
|
||||
})
|
||||
|
||||
test('value is store-backed via widgetValueStore', () => {
|
||||
test('value writes propagate to the interior widget (write-through)', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
const innerNode = firstInnerNode(innerNodes)
|
||||
innerNode.addWidget('text', 'myWidget', 'initial', () => {})
|
||||
@@ -273,18 +271,32 @@ describe(createPromotedWidgetView, () => {
|
||||
'myWidget'
|
||||
)
|
||||
|
||||
// Value should read from the store (which was populated by addWidget)
|
||||
// Value should read from interior default initially
|
||||
expect(view.value).toBe('initial')
|
||||
|
||||
// Setting value through the view updates the store
|
||||
view.value = 'updated'
|
||||
expect(view.value).toBe('updated')
|
||||
|
||||
// The interior widget reads from the same store
|
||||
expect(view.value).toBe('updated')
|
||||
// Write-through projects the new value onto the interior widget so
|
||||
// direct interior consumers (prompt-build, etc.) stay consistent.
|
||||
expect(innerNode.widgets![0].value).toBe('updated')
|
||||
|
||||
const perInstanceCell = useWidgetValueStore().getWidget(
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id,
|
||||
view.storeName
|
||||
)
|
||||
expect(perInstanceCell?.value).toBe('updated')
|
||||
|
||||
const interiorCell = useWidgetValueStore().getWidget(
|
||||
subgraphNode.rootGraph.id,
|
||||
innerNode.id,
|
||||
'myWidget'
|
||||
)
|
||||
expect(interiorCell?.value).toBe('updated')
|
||||
})
|
||||
|
||||
test('value falls back to interior widget when store entry is missing', () => {
|
||||
test('value falls back to interior widget when no store entry exists', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
const innerNode = firstInnerNode(innerNodes)
|
||||
const fallbackWidgetShape = {
|
||||
@@ -305,12 +317,11 @@ describe(createPromotedWidgetView, () => {
|
||||
'myWidget'
|
||||
)
|
||||
|
||||
// Read falls back to the interior widget when no store entry exists
|
||||
expect(view.value).toBe('initial')
|
||||
view.value = 'updated'
|
||||
expect(fallbackWidget.value).toBe('updated')
|
||||
})
|
||||
|
||||
test('value setter falls back to host widget when linked states are unavailable', () => {
|
||||
test('value setter writes to both per-instance override and interior widget', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'string_a', type: '*' }]
|
||||
})
|
||||
@@ -327,11 +338,11 @@ describe(createPromotedWidgetView, () => {
|
||||
const linkedView = promotedWidgets(subgraphNode)[0]
|
||||
if (!linkedView) throw new Error('Expected a linked promoted widget')
|
||||
|
||||
const widgetValueStore = useWidgetValueStore()
|
||||
vi.spyOn(widgetValueStore, 'getWidget').mockReturnValue(undefined)
|
||||
|
||||
linkedView.value = 'updated'
|
||||
|
||||
expect(linkedView.value).toBe('updated')
|
||||
// Write-through projects onto the interior so direct interior
|
||||
// consumers (prompt-build, legacy serialization) stay consistent.
|
||||
expect(linkedNode.widgets?.[0].value).toBe('updated')
|
||||
})
|
||||
|
||||
@@ -475,17 +486,80 @@ describe(createPromotedWidgetView, () => {
|
||||
expect(view.hidden).toBe(true)
|
||||
})
|
||||
|
||||
test('label setter persists to widget state', () => {
|
||||
test('label setter propagates to the bound subgraph slot', () => {
|
||||
// Real promotion fixture: produce a view with a genuine slot binding
|
||||
// (input._widget === view AND input._subgraphSlot set), exercising the
|
||||
// production path rather than asserting the rejected widget-state
|
||||
// implementation detail. Mirrors the pattern at "defers promotions
|
||||
// while subgraph node id is -1 and flushes on add" earlier in this file.
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'myWidget', type: '*' }]
|
||||
})
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { id: 41 })
|
||||
subgraphNode.graph?.add(subgraphNode)
|
||||
|
||||
const innerNode = new LGraphNode('InnerNode')
|
||||
const innerInput = innerNode.addInput('myWidget', '*')
|
||||
innerNode.addWidget('text', 'myWidget', 'val', () => {})
|
||||
innerInput.widget = { name: 'myWidget' }
|
||||
subgraph.add(innerNode)
|
||||
|
||||
subgraph.inputNode.slots[0].connect(innerInput, innerNode)
|
||||
subgraphNode._internalConfigureAfterSlots()
|
||||
|
||||
const view = subgraphNode.widgets[0] as PromotedWidgetView | undefined
|
||||
if (!view) throw new Error('Expected a promoted view')
|
||||
|
||||
view.label = 'Renamed'
|
||||
|
||||
expect(view.label).toBe('Renamed')
|
||||
// Slot-side persistence is the durable home for label state.
|
||||
const slot = subgraphNode.inputs.find(
|
||||
(i) => i._widget === view
|
||||
)?._subgraphSlot
|
||||
expect(slot?.label).toBe('Renamed')
|
||||
})
|
||||
|
||||
test('label binding uses the exact promoted view instance when same source widget is promoted twice', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
const innerNode = firstInnerNode(innerNodes)
|
||||
innerNode.addWidget('text', 'myWidget', 'val', () => {})
|
||||
const view = createPromotedWidgetView(
|
||||
innerNode.addWidget('text', 'shared', 'value', () => {})
|
||||
|
||||
subgraphNode.addInput('slot_a', '*')
|
||||
subgraphNode.addInput('slot_b', '*')
|
||||
|
||||
const viewA = createPromotedWidgetView(
|
||||
subgraphNode,
|
||||
String(innerNode.id),
|
||||
'myWidget'
|
||||
'shared',
|
||||
'Slot A'
|
||||
)
|
||||
view.label = 'Renamed'
|
||||
expect(view.label).toBe('Renamed')
|
||||
const viewB = createPromotedWidgetView(
|
||||
subgraphNode,
|
||||
String(innerNode.id),
|
||||
'shared',
|
||||
'Slot B'
|
||||
)
|
||||
|
||||
if (!subgraphNode.inputs[0] || !subgraphNode.inputs[1]) {
|
||||
throw new Error('Expected two subgraph inputs')
|
||||
}
|
||||
|
||||
subgraphNode.inputs[0]._widget = viewA
|
||||
Object.defineProperty(subgraphNode.inputs[0], '_subgraphSlot', {
|
||||
value: { name: 'slot_a', label: 'A' },
|
||||
configurable: true,
|
||||
writable: true
|
||||
})
|
||||
subgraphNode.inputs[1]._widget = viewB
|
||||
Object.defineProperty(subgraphNode.inputs[1], '_subgraphSlot', {
|
||||
value: { name: 'slot_b', label: 'B' },
|
||||
configurable: true,
|
||||
writable: true
|
||||
})
|
||||
|
||||
expect(viewA.label).toBe('A')
|
||||
expect(viewB.label).toBe('B')
|
||||
})
|
||||
|
||||
test('value getter handles number values via isWidgetValue', () => {
|
||||
@@ -515,16 +589,7 @@ describe(createPromotedWidgetView, () => {
|
||||
test('value setter handles object values via isWidgetValue', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
const innerNode = firstInnerNode(innerNodes)
|
||||
const fallbackWidget = {
|
||||
name: 'objWidget',
|
||||
type: 'text',
|
||||
value: 'old',
|
||||
options: {}
|
||||
} as unknown as IBaseWidget
|
||||
innerNode.widgets = [fallbackWidget]
|
||||
|
||||
const widgetValueStore = useWidgetValueStore()
|
||||
vi.spyOn(widgetValueStore, 'getWidget').mockReturnValue(undefined)
|
||||
innerNode.addWidget('text', 'objWidget', 'old', () => {})
|
||||
|
||||
const view = createPromotedWidgetView(
|
||||
subgraphNode,
|
||||
@@ -534,7 +599,9 @@ describe(createPromotedWidgetView, () => {
|
||||
|
||||
const objValue = { key: 'data' }
|
||||
view.value = objValue
|
||||
expect(fallbackWidget.value).toBe(objValue)
|
||||
expect(view.value).toEqual(objValue)
|
||||
// Write-through projects the object onto the interior widget.
|
||||
expect(innerNode.widgets![0].value).toEqual(objValue)
|
||||
})
|
||||
|
||||
test('onPointerDown returns true when interior widget onPointerDown handles it', () => {
|
||||
@@ -876,16 +943,19 @@ describe('SubgraphNode.widgets getter', () => {
|
||||
|
||||
linkedView.value = 'shared-value'
|
||||
|
||||
// Both linked nodes share the same SubgraphInput slot, so the value
|
||||
// propagates to all connected widgets via getLinkedInputWidgets().
|
||||
// Write-through hits only the representative interior (first link).
|
||||
// Sibling interiors and unrelated promoted peers stay independent.
|
||||
expect(linkedView.value).toBe('shared-value')
|
||||
expect(linkedNodeA.widgets?.[0]?.value).toBe('shared-value')
|
||||
expect(linkedNodeB.widgets?.[0]?.value).toBe('shared-value')
|
||||
expect(linkedNodeB.widgets?.[0]?.value).toBe('b')
|
||||
expect(promotedNode.widgets?.[0]?.value).toBe('independent')
|
||||
|
||||
promotedView.value = 'independent-updated'
|
||||
|
||||
expect(promotedView.value).toBe('independent-updated')
|
||||
expect(linkedView.value).toBe('shared-value')
|
||||
expect(linkedNodeA.widgets?.[0]?.value).toBe('shared-value')
|
||||
expect(linkedNodeB.widgets?.[0]?.value).toBe('shared-value')
|
||||
expect(linkedNodeB.widgets?.[0]?.value).toBe('b')
|
||||
expect(promotedNode.widgets?.[0]?.value).toBe('independent-updated')
|
||||
})
|
||||
|
||||
@@ -1223,11 +1293,17 @@ describe('SubgraphNode.widgets getter', () => {
|
||||
firstView.value = 'first-updated'
|
||||
secondView.value = 'second-updated'
|
||||
|
||||
// Distinct subgraph slots map to distinct interior nodes, so
|
||||
// write-through stays scoped to each view's own representative.
|
||||
expect(firstView.value).toBe('first-updated')
|
||||
expect(secondView.value).toBe('second-updated')
|
||||
expect(firstNode.widgets?.[0].value).toBe('first-updated')
|
||||
expect(secondNode.widgets?.[0].value).toBe('second-updated')
|
||||
|
||||
subgraphNode.serialize()
|
||||
|
||||
expect(firstView.value).toBe('first-updated')
|
||||
expect(secondView.value).toBe('second-updated')
|
||||
expect(firstNode.widgets?.[0].value).toBe('first-updated')
|
||||
expect(secondNode.widgets?.[0].value).toBe('second-updated')
|
||||
})
|
||||
@@ -1548,14 +1624,22 @@ describe('SubgraphNode.widgets getter', () => {
|
||||
independentView.value = 'independent-value'
|
||||
linkedView.value = 'shared-linked'
|
||||
|
||||
const widgetStore = useWidgetValueStore()
|
||||
const getValue = (nodeId: string) =>
|
||||
widgetStore.getWidget(graph.id, stripGraphPrefix(nodeId), 'string_a')
|
||||
?.value
|
||||
// Per-instance views carry their own values; both linked and
|
||||
// independent promoted views read from their per-instance store entries
|
||||
// and do not contaminate each other.
|
||||
expect(linkedView.value).toBe('shared-linked')
|
||||
expect(independentView.value).toBe('independent-value')
|
||||
|
||||
expect(getValue('20')).toBe('shared-linked')
|
||||
expect(getValue('18')).toBe('shared-linked')
|
||||
expect(getValue('19')).toBe('independent-value')
|
||||
// Per-instance overrides are owned by the SubgraphNode itself at
|
||||
// `(graphId, hostNode.id, *)`. Each PromotedWidgetView is a first-class
|
||||
// widget on the SubgraphNode, so writes land in the natural
|
||||
// (graphId, nodeId, *) namespace and are isolated from interior entries.
|
||||
const overrideValues = useWidgetValueStore()
|
||||
.getNodeWidgets(graph.id, hostNode.id)
|
||||
.map((entry) => entry.value)
|
||||
expect(overrideValues).toEqual(
|
||||
expect.arrayContaining(['shared-linked', 'independent-value'])
|
||||
)
|
||||
})
|
||||
|
||||
test('fixture refreshes duplicate fallback after linked representative recovers', () => {
|
||||
@@ -2022,13 +2106,15 @@ describe('three-level nested value propagation', () => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
test('value set at outermost level propagates to concrete widget', () => {
|
||||
test('value set at outermost level is visible through the promoted view', () => {
|
||||
const { concreteNode, subgraphNodeA } = createThreeLevelNestedSubgraph()
|
||||
|
||||
expect(subgraphNodeA.widgets).toHaveLength(1)
|
||||
expect(subgraphNodeA.widgets[0].value).toBe(100)
|
||||
|
||||
subgraphNodeA.widgets[0].value = 200
|
||||
expect(subgraphNodeA.widgets[0].value).toBe(200)
|
||||
// Write-through chains through every nested view down to the concrete.
|
||||
expect(concreteNode.widgets![0].value).toBe(200)
|
||||
})
|
||||
|
||||
@@ -2108,6 +2194,8 @@ describe('three-level nested value propagation', () => {
|
||||
|
||||
widgets[1].value = 'updated-second'
|
||||
|
||||
// Write-through follows the disambig chain — only secondTextNode's
|
||||
// interior is mutated; firstTextNode stays untouched.
|
||||
expect(firstTextNode.widgets?.[0]?.value).toBe('11111111111')
|
||||
expect(secondTextNode.widgets?.[0]?.value).toBe('updated-second')
|
||||
expect(widgets[0].value).toBe('11111111111')
|
||||
@@ -2156,13 +2244,14 @@ describe('multi-link representative determinism for input-based promotion', () =
|
||||
// Read returns the first link's value
|
||||
expect(widgets[0].value).toBe('first-val')
|
||||
|
||||
// Write propagates to all linked nodes
|
||||
// Write-through hits only the representative interior (first link).
|
||||
// Sibling interiors connected via the same input stay at their defaults.
|
||||
widgets[0].value = 'updated'
|
||||
expect(firstNode.widgets![0].value).toBe('updated')
|
||||
expect(secondNode.widgets![0].value).toBe('updated')
|
||||
expect(thirdNode.widgets![0].value).toBe('updated')
|
||||
expect(secondNode.widgets![0].value).toBe('second-val')
|
||||
expect(thirdNode.widgets![0].value).toBe('third-val')
|
||||
|
||||
// Repeated reads are still deterministic
|
||||
// Repeated reads through the view return the per-instance override
|
||||
expect(widgets[0].value).toBe('updated')
|
||||
})
|
||||
})
|
||||
@@ -2277,13 +2366,15 @@ describe('promoted combo rendering', () => {
|
||||
expect(renderedText).toContain('a')
|
||||
})
|
||||
|
||||
test('value updates propagate through two promoted input layers', () => {
|
||||
test('value updates at the outer layer write through to the interior combo widget', () => {
|
||||
const { comboWidget, subgraphNodeB } = createTwoLevelNestedSubgraph()
|
||||
comboWidget.computedDisabled = true
|
||||
const promotedWidget = subgraphNodeB.widgets[0]
|
||||
|
||||
expect(promotedWidget.value).toBe('a')
|
||||
promotedWidget.value = 'b'
|
||||
expect(promotedWidget.value).toBe('b')
|
||||
// Write-through chains down to the concrete combo widget.
|
||||
expect(comboWidget.value).toBe('b')
|
||||
|
||||
const fillText = vi.fn()
|
||||
@@ -2677,4 +2768,103 @@ describe('DOM widget promotion', () => {
|
||||
'dom-widget-widgetB'
|
||||
)
|
||||
})
|
||||
|
||||
test('value setter is a no-op while the SubgraphNode is unattached (id === -1)', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
const innerNode = firstInnerNode(innerNodes)
|
||||
innerNode.addWidget('text', 'preAttach', 'initial', () => {})
|
||||
|
||||
const view = createPromotedWidgetView(
|
||||
subgraphNode,
|
||||
String(innerNode.id),
|
||||
'preAttach'
|
||||
)
|
||||
|
||||
// Detach the SubgraphNode so its id reverts to the pre-attach sentinel.
|
||||
Object.assign(subgraphNode, { id: -1 })
|
||||
|
||||
view.value = 'should-not-persist'
|
||||
|
||||
// No WidgetState entry registered at id -1
|
||||
const entries = useWidgetValueStore().getNodeWidgets(
|
||||
subgraphNode.rootGraph.id,
|
||||
-1
|
||||
)
|
||||
expect(entries).toHaveLength(0)
|
||||
// Read still falls back to the interior default
|
||||
expect(view.value).toBe('initial')
|
||||
})
|
||||
|
||||
test('label setter is a no-op while the SubgraphNode is unattached (id === -1)', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
const innerNode = firstInnerNode(innerNodes)
|
||||
innerNode.addWidget('text', 'preAttachLabel', 'initial', () => {})
|
||||
|
||||
const view = createPromotedWidgetView(
|
||||
subgraphNode,
|
||||
String(innerNode.id),
|
||||
'preAttachLabel'
|
||||
)
|
||||
|
||||
Object.assign(subgraphNode, { id: -1 })
|
||||
|
||||
view.label = 'My Label'
|
||||
|
||||
const entries = useWidgetValueStore().getNodeWidgets(
|
||||
subgraphNode.rootGraph.id,
|
||||
-1
|
||||
)
|
||||
expect(entries).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('label setter materializes a per-instance override when no slot is bound', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
const innerNode = firstInnerNode(innerNodes)
|
||||
innerNode.addWidget('text', 'labelOnly', 'initial', () => {})
|
||||
|
||||
const view = createPromotedWidgetView(
|
||||
subgraphNode,
|
||||
String(innerNode.id),
|
||||
'labelOnly'
|
||||
)
|
||||
|
||||
view.label = 'My Label'
|
||||
|
||||
// Without a bound subgraph slot the per-instance override is the only
|
||||
// place the new label can live; the setter must materialize it so
|
||||
// the rename actually takes effect (the getter falls back to
|
||||
// state?.label when no slot is found).
|
||||
const entries = useWidgetValueStore().getNodeWidgets(
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id
|
||||
)
|
||||
expect(entries).toHaveLength(1)
|
||||
expect(entries[0].label).toBe('My Label')
|
||||
expect(view.label).toBe('My Label')
|
||||
})
|
||||
|
||||
test('label setter updates an existing per-instance override when a value override is present', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
const innerNode = firstInnerNode(innerNodes)
|
||||
innerNode.addWidget('text', 'valueAndLabel', 'initial', () => {})
|
||||
|
||||
const view = createPromotedWidgetView(
|
||||
subgraphNode,
|
||||
String(innerNode.id),
|
||||
'valueAndLabel'
|
||||
)
|
||||
|
||||
// Triggering a value write materialises the override (legitimate ownership).
|
||||
view.value = 'override'
|
||||
|
||||
// Now setting a label should update the existing override, not create a new one.
|
||||
view.label = 'My Label'
|
||||
|
||||
const entries = useWidgetValueStore().getNodeWidgets(
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id
|
||||
)
|
||||
expect(entries).toHaveLength(1)
|
||||
expect(entries[0].label).toBe('My Label')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
|
||||
import type { CanvasPointer } from '@/lib/litegraph/src/CanvasPointer'
|
||||
import type { Point } from '@/lib/litegraph/src/interfaces'
|
||||
@@ -9,17 +9,13 @@ import type { BaseWidget } from '@/lib/litegraph/src/widgets/BaseWidget'
|
||||
import { toConcreteWidget } from '@/lib/litegraph/src/widgets/widgetMap'
|
||||
import { t } from '@/i18n'
|
||||
import { useDomWidgetStore } from '@/stores/domWidgetStore'
|
||||
import {
|
||||
stripGraphPrefix,
|
||||
useWidgetValueStore
|
||||
} from '@/stores/widgetValueStore'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import type { WidgetState } from '@/stores/widgetValueStore'
|
||||
import {
|
||||
resolveConcretePromotedWidget,
|
||||
resolvePromotedWidgetAtHost
|
||||
} from '@/core/graph/subgraph/resolveConcretePromotedWidget'
|
||||
import { matchPromotedInput } from '@/core/graph/subgraph/matchPromotedInput'
|
||||
import { hasWidgetNode } from '@/core/graph/subgraph/widgetNodeTypeGuard'
|
||||
import { makeCompositeKey } from '@/utils/compositeKey'
|
||||
|
||||
import { isPromotedWidgetView } from './promotedWidgetTypes'
|
||||
import type { PromotedWidgetView as IPromotedWidgetView } from './promotedWidgetTypes'
|
||||
@@ -74,6 +70,8 @@ class PromotedWidgetView implements IPromotedWidgetView {
|
||||
|
||||
readonly sourceNodeId: string
|
||||
readonly sourceWidgetName: string
|
||||
/** Opaque widget-store name paired with the host SubgraphNode; do not parse. */
|
||||
readonly storeName: string
|
||||
|
||||
readonly serialize = false
|
||||
|
||||
@@ -91,7 +89,7 @@ class PromotedWidgetView implements IPromotedWidgetView {
|
||||
private cachedDeepestByFrame?: { node: LGraphNode; widget: IBaseWidget }
|
||||
private cachedDeepestFrame = -1
|
||||
|
||||
/** Cached reference to the bound subgraph slot, set at construction. */
|
||||
/** Lazily cached bound subgraph slot reference. */
|
||||
private _boundSlot?: SubgraphSlotRef
|
||||
private _boundSlotVersion = -1
|
||||
|
||||
@@ -105,6 +103,11 @@ class PromotedWidgetView implements IPromotedWidgetView {
|
||||
) {
|
||||
this.sourceNodeId = nodeId
|
||||
this.sourceWidgetName = widgetName
|
||||
this.storeName = makeCompositeKey([
|
||||
nodeId,
|
||||
widgetName,
|
||||
disambiguatingSourceNodeId ?? ''
|
||||
])
|
||||
this.graphId = subgraphNode.rootGraph.id
|
||||
}
|
||||
|
||||
@@ -150,71 +153,56 @@ class PromotedWidgetView implements IPromotedWidgetView {
|
||||
}
|
||||
|
||||
get value(): IBaseWidget['value'] {
|
||||
// Sparse override: a WidgetState entry exists only when explicitly set;
|
||||
// otherwise read through to the live interior widget.
|
||||
const state = this.getWidgetState()
|
||||
if (state && isWidgetValue(state.value)) return state.value
|
||||
return this.resolveAtHost()?.widget.value
|
||||
}
|
||||
|
||||
set value(value: IBaseWidget['value']) {
|
||||
const linkedWidgets = this.getLinkedInputWidgets()
|
||||
if (linkedWidgets.length > 0) {
|
||||
const widgetStore = useWidgetValueStore()
|
||||
let didUpdateState = false
|
||||
for (const linkedWidget of linkedWidgets) {
|
||||
const state = widgetStore.getWidget(
|
||||
this.graphId,
|
||||
linkedWidget.nodeId,
|
||||
linkedWidget.widgetName
|
||||
)
|
||||
if (state) {
|
||||
state.value = value
|
||||
didUpdateState = true
|
||||
}
|
||||
}
|
||||
if (!isWidgetValue(value)) return
|
||||
// Pre-attach sentinel: skip writes before LGraph.add() assigns the real id.
|
||||
if (this.subgraphNode.id === -1) return
|
||||
|
||||
const resolved = this.resolveDeepest()
|
||||
if (resolved) {
|
||||
const resolvedState = widgetStore.getWidget(
|
||||
this.graphId,
|
||||
stripGraphPrefix(String(resolved.node.id)),
|
||||
resolved.widget.name
|
||||
)
|
||||
if (resolvedState) {
|
||||
resolvedState.value = value
|
||||
didUpdateState = true
|
||||
}
|
||||
}
|
||||
// The per-instance override keeps Vue render and canvas draw fast paths correct.
|
||||
this.ensureInstanceState().value = value
|
||||
|
||||
if (didUpdateState) return
|
||||
}
|
||||
|
||||
const state = this.getWidgetState()
|
||||
if (state) {
|
||||
state.value = value
|
||||
return
|
||||
}
|
||||
|
||||
const resolved = this.resolveAtHost()
|
||||
if (resolved && isWidgetValue(value)) {
|
||||
resolved.widget.value = value
|
||||
// Write-through to the interior widget: prompt-build, legacy
|
||||
// serialization, and nested promoted views all read the interior widget
|
||||
// directly. Without this projection they would observe the stale
|
||||
// workflow-restored default rather than the user-edited value.
|
||||
const interior = this.resolveAtHost()?.widget
|
||||
if (interior && interior.value !== value) {
|
||||
interior.value = value
|
||||
}
|
||||
}
|
||||
|
||||
get label(): string | undefined {
|
||||
const slot = this.getBoundSubgraphSlot()
|
||||
if (slot) return slot.label ?? slot.displayName ?? slot.name
|
||||
// Fall back to persisted widget state (survives save/reload before
|
||||
// the slot binding is established) then to construction displayName.
|
||||
const state = this.getWidgetState()
|
||||
return state?.label ?? this.displayName
|
||||
}
|
||||
|
||||
/** Slot-bound: only update an existing override. Unbound: materialize one. */
|
||||
set label(value: string | undefined) {
|
||||
const slot = this.getBoundSubgraphSlot()
|
||||
if (slot) slot.label = value || undefined
|
||||
// Also persist to widget state store for save/reload resilience
|
||||
const state = this.getWidgetState()
|
||||
if (state) state.label = value
|
||||
|
||||
// Pre-attach sentinel guard: skip per-instance override write before LGraph.add().
|
||||
if (this.subgraphNode.id === -1) return
|
||||
|
||||
if (slot) {
|
||||
const existing = this.getWidgetState()
|
||||
if (existing) existing.label = value
|
||||
} else {
|
||||
this.ensureInstanceState().label = value
|
||||
}
|
||||
}
|
||||
|
||||
serializeValue(_node: LGraphNode, _index: number): IBaseWidget['value'] {
|
||||
return this.value
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -223,7 +211,7 @@ class PromotedWidgetView implements IPromotedWidgetView {
|
||||
*
|
||||
* Note: Using length as the cache key works because the returned reference
|
||||
* is the same mutable slot object. When slot properties (label, name) change,
|
||||
* the caller reads fresh values from that reference. The cache only needs
|
||||
* the caller reads fresh values from that reference. The cache only needs
|
||||
* to invalidate when slots are added or removed, which changes length.
|
||||
*/
|
||||
private getBoundSubgraphSlot(): SubgraphSlotRef | undefined {
|
||||
@@ -236,21 +224,28 @@ class PromotedWidgetView implements IPromotedWidgetView {
|
||||
}
|
||||
|
||||
private findBoundSubgraphSlot(): SubgraphSlotRef | undefined {
|
||||
// Identity match wins; otherwise fall back to source-identity match
|
||||
// (sibling view bound to the same promoted source).
|
||||
let sourceMatch: SubgraphSlotRef | undefined
|
||||
for (const input of this.subgraphNode.inputs ?? []) {
|
||||
const slot = input._subgraphSlot as SubgraphSlotRef | undefined
|
||||
if (!slot) continue
|
||||
|
||||
if (input._widget === this) return slot
|
||||
|
||||
if (sourceMatch) continue
|
||||
const w = input._widget
|
||||
if (
|
||||
w &&
|
||||
isPromotedWidgetView(w) &&
|
||||
w.sourceNodeId === this.sourceNodeId &&
|
||||
w.sourceWidgetName === this.sourceWidgetName
|
||||
w.sourceWidgetName === this.sourceWidgetName &&
|
||||
w.disambiguatingSourceNodeId === this.disambiguatingSourceNodeId
|
||||
) {
|
||||
return slot
|
||||
sourceMatch = slot
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
return sourceMatch
|
||||
}
|
||||
|
||||
get hidden(): boolean {
|
||||
@@ -385,70 +380,27 @@ class PromotedWidgetView implements IPromotedWidgetView {
|
||||
return resolved
|
||||
}
|
||||
|
||||
private getWidgetState() {
|
||||
const linkedState = this.getLinkedInputWidgetStates()[0]
|
||||
if (linkedState) return linkedState
|
||||
|
||||
const resolved = this.resolveDeepest()
|
||||
if (!resolved) return undefined
|
||||
private getWidgetState(): WidgetState | undefined {
|
||||
return useWidgetValueStore().getWidget(
|
||||
this.graphId,
|
||||
stripGraphPrefix(String(resolved.node.id)),
|
||||
resolved.widget.name
|
||||
this.subgraphNode.id,
|
||||
this.storeName
|
||||
)
|
||||
}
|
||||
|
||||
private getLinkedInputWidgets(): Array<{
|
||||
nodeId: NodeId
|
||||
widgetName: string
|
||||
widget: IBaseWidget
|
||||
}> {
|
||||
const linkedInputSlot = this.subgraphNode.inputs.find((input) => {
|
||||
if (!input._subgraphSlot) return false
|
||||
if (matchPromotedInput([input], this) !== input) return false
|
||||
|
||||
const boundWidget = input._widget
|
||||
if (boundWidget === this) return true
|
||||
|
||||
if (boundWidget && isPromotedWidgetView(boundWidget)) {
|
||||
return (
|
||||
boundWidget.sourceNodeId === this.sourceNodeId &&
|
||||
boundWidget.sourceWidgetName === this.sourceWidgetName &&
|
||||
boundWidget.disambiguatingSourceNodeId ===
|
||||
this.disambiguatingSourceNodeId
|
||||
)
|
||||
}
|
||||
|
||||
return input._subgraphSlot
|
||||
.getConnectedWidgets()
|
||||
.filter(hasWidgetNode)
|
||||
.some(
|
||||
(widget) =>
|
||||
String(widget.node.id) === this.sourceNodeId &&
|
||||
widget.name === this.sourceWidgetName
|
||||
)
|
||||
/** Lazily creates this view's per-instance state from source defaults. */
|
||||
private ensureInstanceState(): WidgetState {
|
||||
const seed = this.resolveDeepest()?.widget ?? this.resolveAtHost()?.widget
|
||||
return useWidgetValueStore().getOrRegister(this.graphId, {
|
||||
nodeId: this.subgraphNode.id,
|
||||
name: this.storeName,
|
||||
type: seed?.type ?? 'text',
|
||||
value: seed?.value,
|
||||
options: seed?.options ?? {},
|
||||
label: seed?.label,
|
||||
serialize: seed?.serialize,
|
||||
disabled: seed?.disabled
|
||||
})
|
||||
const linkedInput = linkedInputSlot?._subgraphSlot
|
||||
if (!linkedInput) return []
|
||||
|
||||
return linkedInput
|
||||
.getConnectedWidgets()
|
||||
.filter(hasWidgetNode)
|
||||
.map((widget) => ({
|
||||
nodeId: stripGraphPrefix(String(widget.node.id)),
|
||||
widgetName: widget.name,
|
||||
widget
|
||||
}))
|
||||
}
|
||||
|
||||
private getLinkedInputWidgetStates(): WidgetState[] {
|
||||
const widgetStore = useWidgetValueStore()
|
||||
|
||||
return this.getLinkedInputWidgets()
|
||||
.map(({ nodeId, widgetName }) =>
|
||||
widgetStore.getWidget(this.graphId, nodeId, widgetName)
|
||||
)
|
||||
.filter((state): state is WidgetState => state !== undefined)
|
||||
}
|
||||
|
||||
private getProjectedWidget(resolved: {
|
||||
|
||||
@@ -103,7 +103,7 @@ describe('Subgraph proxyWidgets', () => {
|
||||
expect(subgraphNode.widgets[0].name).toBe('widgetB')
|
||||
expect(subgraphNode.widgets[1].name).toBe('widgetA')
|
||||
})
|
||||
test('Will mirror changes to value', () => {
|
||||
test('Promoted view falls back to interior; promoted writes mutate interior (write-through)', () => {
|
||||
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
|
||||
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
|
||||
usePromotionStore().setPromotions(
|
||||
@@ -113,9 +113,15 @@ describe('Subgraph proxyWidgets', () => {
|
||||
)
|
||||
expect(subgraphNode.widgets.length).toBe(1)
|
||||
expect(subgraphNode.widgets[0].value).toBe('value')
|
||||
|
||||
// Sparse override: interior writes are visible until the view
|
||||
// acquires its own override.
|
||||
innerNodes[0].widgets![0].value = 'test'
|
||||
expect(subgraphNode.widgets[0].value).toBe('test')
|
||||
|
||||
// Promoted writes record an override AND write through to the interior.
|
||||
subgraphNode.widgets[0].value = 'test2'
|
||||
expect(subgraphNode.widgets[0].value).toBe('test2')
|
||||
expect(innerNodes[0].widgets![0].value).toBe('test2')
|
||||
})
|
||||
test('Will not modify position or sizing of existing widgets', () => {
|
||||
@@ -253,7 +259,7 @@ describe('Subgraph proxyWidgets', () => {
|
||||
expect(subgraphNode.widgets).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('serialize does not produce widgets_values for promoted views', () => {
|
||||
test('serialize stores widgets_values for promoted views', () => {
|
||||
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
|
||||
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
|
||||
usePromotionStore().setPromotions(
|
||||
@@ -265,9 +271,7 @@ describe('Subgraph proxyWidgets', () => {
|
||||
|
||||
const serialized = subgraphNode.serialize()
|
||||
|
||||
// SubgraphNode doesn't set serialize_widgets, so widgets_values is absent.
|
||||
// Even if it were set, views have serialize: false and would be skipped.
|
||||
expect(serialized.widgets_values).toBeUndefined()
|
||||
expect(serialized.widgets_values).toEqual(['value'])
|
||||
})
|
||||
|
||||
test('serialize preserves proxyWidgets in properties', () => {
|
||||
@@ -291,6 +295,28 @@ describe('Subgraph proxyWidgets', () => {
|
||||
])
|
||||
})
|
||||
|
||||
test('serialize() does not mutate the live properties.proxyWidgets', () => {
|
||||
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
|
||||
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
|
||||
innerNodes[0].addWidget('text', 'widgetB', 'b', () => {})
|
||||
usePromotionStore().setPromotions(
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id,
|
||||
[
|
||||
{ sourceNodeId: innerIds[0], sourceWidgetName: 'widgetA' },
|
||||
{ sourceNodeId: innerIds[0], sourceWidgetName: 'widgetB' }
|
||||
]
|
||||
)
|
||||
|
||||
const before = subgraphNode.properties.proxyWidgets
|
||||
const beforeSnapshot = JSON.parse(JSON.stringify(before))
|
||||
|
||||
subgraphNode.serialize()
|
||||
|
||||
expect(subgraphNode.properties.proxyWidgets).toBe(before)
|
||||
expect(subgraphNode.properties.proxyWidgets).toStrictEqual(beforeSnapshot)
|
||||
})
|
||||
|
||||
test('multi-link representative is deterministic across repeated reads', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'shared_input', type: '*' }]
|
||||
@@ -371,8 +397,10 @@ describe('Subgraph proxyWidgets', () => {
|
||||
expect(subgraphNodeA.widgets[0].type).toBe('number')
|
||||
expect(subgraphNodeA.widgets[0].value).toBe(42)
|
||||
|
||||
// Setting value at outermost level propagates to concrete widget
|
||||
// Outermost write records an override AND writes through every nested
|
||||
// promoted view down to the concrete interior widget.
|
||||
subgraphNodeA.widgets[0].value = 99
|
||||
expect(subgraphNodeA.widgets[0].value).toBe(99)
|
||||
expect(concreteNode.widgets![0].value).toBe(99)
|
||||
})
|
||||
|
||||
|
||||
@@ -3,10 +3,26 @@ import { fromZodError } from 'zod-validation-error'
|
||||
|
||||
import type { NodeProperty } from '@/lib/litegraph/src/LGraphNode'
|
||||
|
||||
const proxyWidgetTupleSchema = z.union([
|
||||
z.tuple([z.string(), z.string(), z.string()]),
|
||||
z.tuple([z.string(), z.string()])
|
||||
export const serializedProxyWidgetTupleSchema = z.tuple([
|
||||
z.string(),
|
||||
z.string()
|
||||
])
|
||||
export type SerializedProxyWidgetTuple = z.infer<
|
||||
typeof serializedProxyWidgetTupleSchema
|
||||
>
|
||||
|
||||
const legacyProxyWidgetTupleSchema = z.tuple([
|
||||
z.string(),
|
||||
z.string(),
|
||||
z.string()
|
||||
])
|
||||
|
||||
export const proxyWidgetTupleSchema = z.union([
|
||||
legacyProxyWidgetTupleSchema,
|
||||
serializedProxyWidgetTupleSchema
|
||||
])
|
||||
export type ProxyWidgetTuple = z.infer<typeof proxyWidgetTupleSchema>
|
||||
|
||||
const proxyWidgetsPropertySchema = z.array(proxyWidgetTupleSchema)
|
||||
type ProxyWidgetsProperty = z.infer<typeof proxyWidgetsPropertySchema>
|
||||
|
||||
|
||||
@@ -1027,4 +1027,29 @@ describe('Zero UUID handling in configure', () => {
|
||||
subgraph.configure(subgraphData)
|
||||
expect(subgraph.id).toBe(zeroUuid)
|
||||
})
|
||||
|
||||
describe('trigger() events bus', () => {
|
||||
it('dispatches node:slot-label:changed on graph.events when trigger() is called', () => {
|
||||
const graph = new LGraph()
|
||||
const received: Array<{ type: string; nodeId: NodeId }> = []
|
||||
graph.events.addEventListener('node:slot-label:changed', (e) => {
|
||||
received.push(e.detail)
|
||||
})
|
||||
graph.trigger('node:slot-label:changed', { nodeId: 42 })
|
||||
expect(received.length).toBe(1)
|
||||
expect(received[0]).toEqual({
|
||||
type: 'node:slot-label:changed',
|
||||
nodeId: 42
|
||||
})
|
||||
})
|
||||
|
||||
it('still invokes the legacy onTrigger field for backward compatibility', () => {
|
||||
const graph = new LGraph()
|
||||
const received: Array<{ type: string }> = []
|
||||
graph.onTrigger = (event) => received.push(event)
|
||||
graph.trigger('node:slot-label:changed', { nodeId: 7 })
|
||||
expect(received.length).toBe(1)
|
||||
expect(received[0].type).toBe('node:slot-label:changed')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1367,7 +1367,9 @@ export class LGraph
|
||||
])
|
||||
|
||||
if (validEventTypes.has(action) && param && typeof param === 'object') {
|
||||
this.onTrigger?.({ type: action, ...param } as LGraphTriggerEvent)
|
||||
const event = { type: action, ...param } as LGraphTriggerEvent
|
||||
this.onTrigger?.(event)
|
||||
this.events.dispatch(action as LGraphTriggerEvent['type'], event)
|
||||
}
|
||||
// Don't handle unknown events - just ignore them
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { SerializedProxyWidgetTuple } from '@/core/schemas/promotionSchema'
|
||||
import { LGraph, LGraphNode, createUuidv4 } from '@/lib/litegraph/src/litegraph'
|
||||
import { remapClipboardSubgraphNodeIds } from '@/lib/litegraph/src/LGraphCanvas'
|
||||
import type {
|
||||
@@ -11,7 +12,7 @@ import type {
|
||||
function createSerialisedNode(
|
||||
id: number,
|
||||
type: string,
|
||||
proxyWidgets?: Array<[string, string]>
|
||||
proxyWidgets?: SerializedProxyWidgetTuple[]
|
||||
): ISerialisedNode {
|
||||
return {
|
||||
id,
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { LGraph } from '@/lib/litegraph/src/LGraph'
|
||||
import type { LLink, ResolvedConnection } from '@/lib/litegraph/src/LLink'
|
||||
import type { ReadOnlyRect } from '@/lib/litegraph/src/interfaces'
|
||||
import type { Subgraph } from '@/lib/litegraph/src/subgraph/Subgraph'
|
||||
import type { LGraphTriggerEvent } from '@/lib/litegraph/src/types/graphTriggers'
|
||||
import type {
|
||||
ExportedSubgraph,
|
||||
ISerialisedGraph,
|
||||
@@ -9,6 +10,23 @@ import type {
|
||||
} from '@/lib/litegraph/src/types/serialisation'
|
||||
|
||||
export interface LGraphEventMap {
|
||||
'node:slot-label:changed': Extract<
|
||||
LGraphTriggerEvent,
|
||||
{ type: 'node:slot-label:changed' }
|
||||
>
|
||||
'node:slot-links:changed': Extract<
|
||||
LGraphTriggerEvent,
|
||||
{ type: 'node:slot-links:changed' }
|
||||
>
|
||||
'node:slot-errors:changed': Extract<
|
||||
LGraphTriggerEvent,
|
||||
{ type: 'node:slot-errors:changed' }
|
||||
>
|
||||
'node:property:changed': Extract<
|
||||
LGraphTriggerEvent,
|
||||
{ type: 'node:property:changed' }
|
||||
>
|
||||
|
||||
configuring: {
|
||||
/** The data that was used to configure the graph. */
|
||||
data: ISerialisedGraph | SerialisableGraph
|
||||
|
||||
@@ -0,0 +1,593 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type {
|
||||
ExportedSubgraphInstance,
|
||||
ISlotType,
|
||||
Subgraph
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import {
|
||||
LGraphNode,
|
||||
LiteGraph,
|
||||
SubgraphNode
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
import {
|
||||
createTestSubgraph,
|
||||
createTestSubgraphNode,
|
||||
resetSubgraphFixtureState
|
||||
} from './__fixtures__/subgraphHelpers'
|
||||
|
||||
/**
|
||||
* Registers a minimal SubgraphNode subclass for a subgraph definition so that
|
||||
* `LiteGraph.createNode(subgraphId)` (which is invoked by `LGraphNode.clone`)
|
||||
* succeeds in tests.
|
||||
*/
|
||||
function registerSubgraphNodeType(subgraph: Subgraph): void {
|
||||
const instanceData: ExportedSubgraphInstance = {
|
||||
id: -1,
|
||||
type: subgraph.id,
|
||||
pos: [0, 0],
|
||||
size: [100, 100],
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
flags: {},
|
||||
order: 0,
|
||||
mode: 0
|
||||
}
|
||||
|
||||
const node = class extends SubgraphNode {
|
||||
constructor() {
|
||||
super(subgraph.rootGraph, subgraph, instanceData)
|
||||
}
|
||||
}
|
||||
Object.defineProperty(node, 'title', { value: subgraph.name })
|
||||
LiteGraph.registerNodeType(subgraph.id, node)
|
||||
}
|
||||
|
||||
function createNodeWithWidget(
|
||||
title: string,
|
||||
widgetValue: number = 42,
|
||||
slotType: ISlotType = 'number'
|
||||
) {
|
||||
const node = new LGraphNode(title)
|
||||
const input = node.addInput('value', slotType)
|
||||
node.addOutput('out', slotType)
|
||||
|
||||
const widget = node.addWidget('number', 'widget', widgetValue, () => {}, {
|
||||
min: 0,
|
||||
max: 100,
|
||||
step: 1
|
||||
})
|
||||
input.widget = { name: widget.name }
|
||||
|
||||
return { node, widget, input }
|
||||
}
|
||||
|
||||
const registeredTypes: string[] = []
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
resetSubgraphFixtureState()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
for (const type of registeredTypes) {
|
||||
LiteGraph.unregisterNodeType(type)
|
||||
}
|
||||
registeredTypes.length = 0
|
||||
})
|
||||
|
||||
describe('SubgraphNode multi-instance widget isolation', () => {
|
||||
it('preserves per-instance widget values after configure', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'value', type: 'number' }]
|
||||
})
|
||||
|
||||
const { node } = createNodeWithWidget('TestNode', 0)
|
||||
subgraph.add(node)
|
||||
subgraph.inputNode.slots[0].connect(node.inputs[0], node)
|
||||
|
||||
const instance1 = createTestSubgraphNode(subgraph, { id: 201 })
|
||||
const instance2 = createTestSubgraphNode(subgraph, { id: 202 })
|
||||
|
||||
// Simulate what LGraph.configure does: call configure with different widgets_values
|
||||
instance1.configure({
|
||||
id: 201,
|
||||
type: subgraph.id,
|
||||
pos: [100, 100],
|
||||
size: [200, 100],
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
mode: 0,
|
||||
order: 0,
|
||||
flags: {},
|
||||
properties: { proxyWidgets: [['-1', 'widget']] },
|
||||
widgets_values: [10]
|
||||
})
|
||||
|
||||
instance2.configure({
|
||||
id: 202,
|
||||
type: subgraph.id,
|
||||
pos: [400, 100],
|
||||
size: [200, 100],
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
mode: 0,
|
||||
order: 1,
|
||||
flags: {},
|
||||
properties: { proxyWidgets: [['-1', 'widget']] },
|
||||
widgets_values: [20]
|
||||
})
|
||||
|
||||
const widgets1 = instance1.widgets!
|
||||
const widgets2 = instance2.widgets!
|
||||
|
||||
expect(widgets1.length).toBeGreaterThan(0)
|
||||
expect(widgets2.length).toBeGreaterThan(0)
|
||||
expect(widgets1[0].value).toBe(10)
|
||||
expect(widgets2[0].value).toBe(20)
|
||||
expect(widgets1[0].serializeValue!(instance1, 0)).toBe(10)
|
||||
expect(widgets2[0].serializeValue!(instance2, 0)).toBe(20)
|
||||
expect(instance1.serialize().widgets_values).toEqual([10])
|
||||
expect(instance2.serialize().widgets_values).toEqual([20])
|
||||
})
|
||||
|
||||
it('round-trips per-instance widget values through serialize and configure', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'value', type: 'number' }]
|
||||
})
|
||||
|
||||
const { node } = createNodeWithWidget('TestNode', 0)
|
||||
subgraph.add(node)
|
||||
subgraph.inputNode.slots[0].connect(node.inputs[0], node)
|
||||
|
||||
const originalInstance = createTestSubgraphNode(subgraph, { id: 301 })
|
||||
originalInstance.configure({
|
||||
id: 301,
|
||||
type: subgraph.id,
|
||||
pos: [100, 100],
|
||||
size: [200, 100],
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
mode: 0,
|
||||
order: 0,
|
||||
flags: {},
|
||||
properties: { proxyWidgets: [['-1', 'widget']] },
|
||||
widgets_values: [33]
|
||||
})
|
||||
|
||||
const serialized = originalInstance.serialize()
|
||||
|
||||
const restoredInstance = createTestSubgraphNode(subgraph, { id: 302 })
|
||||
restoredInstance.configure({
|
||||
...serialized,
|
||||
id: 302,
|
||||
type: subgraph.id
|
||||
})
|
||||
|
||||
const restoredWidget = restoredInstance.widgets?.[0]
|
||||
expect(restoredWidget?.value).toBe(33)
|
||||
expect(restoredWidget?.serializeValue?.(restoredInstance, 0)).toBe(33)
|
||||
})
|
||||
|
||||
it('fresh sibling instances follow shared interior until they acquire explicit per-instance overrides', () => {
|
||||
// Sparse override: untouched siblings share the live interior; once
|
||||
// an instance is explicitly set or restored, it diverges.
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'value', type: 'number' }]
|
||||
})
|
||||
|
||||
const { node } = createNodeWithWidget('TestNode', 7)
|
||||
subgraph.add(node)
|
||||
subgraph.inputNode.slots[0].connect(node.inputs[0], node)
|
||||
|
||||
const instance1 = createTestSubgraphNode(subgraph, { id: 401 })
|
||||
const instance2 = createTestSubgraphNode(subgraph, { id: 402 })
|
||||
instance1.graph!.add(instance1)
|
||||
instance2.graph!.add(instance2)
|
||||
|
||||
const widget1 = instance1.widgets?.[0]
|
||||
const widget2 = instance2.widgets?.[0]
|
||||
|
||||
expect(widget1?.value).toBe(7)
|
||||
expect(widget2?.value).toBe(7)
|
||||
|
||||
widget1!.value = 10
|
||||
|
||||
// Instance 2 has no override yet, so it reads through to the now-
|
||||
// mutated shared interior.
|
||||
expect(widget1?.value).toBe(10)
|
||||
expect(widget2?.value).toBe(10)
|
||||
expect(widget1?.serializeValue?.(instance1, 0)).toBe(10)
|
||||
expect(widget2?.serializeValue?.(instance2, 0)).toBe(10)
|
||||
|
||||
// Setting widget2 gives it its own override, allowing divergence.
|
||||
widget2!.value = 33
|
||||
expect(widget1?.value).toBe(10)
|
||||
expect(widget2?.value).toBe(33)
|
||||
})
|
||||
|
||||
it('keeps per-instance override sticky when the inner source widget changes directly', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'value', type: 'number' }]
|
||||
})
|
||||
|
||||
const { node, widget } = createNodeWithWidget('TestNode', 0)
|
||||
subgraph.add(node)
|
||||
subgraph.inputNode.slots[0].connect(node.inputs[0], node)
|
||||
|
||||
const originalInstance = createTestSubgraphNode(subgraph, { id: 601 })
|
||||
originalInstance.configure({
|
||||
id: 601,
|
||||
type: subgraph.id,
|
||||
pos: [100, 100],
|
||||
size: [200, 100],
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
mode: 0,
|
||||
order: 0,
|
||||
flags: {},
|
||||
properties: { proxyWidgets: [['-1', 'widget']] },
|
||||
widgets_values: [33]
|
||||
})
|
||||
|
||||
const serialized = originalInstance.serialize()
|
||||
|
||||
const restoredInstance = createTestSubgraphNode(subgraph, { id: 602 })
|
||||
restoredInstance.configure({
|
||||
...serialized,
|
||||
id: 602,
|
||||
type: subgraph.id
|
||||
})
|
||||
|
||||
expect(restoredInstance.widgets?.[0].value).toBe(33)
|
||||
|
||||
widget.value = 45
|
||||
|
||||
// Override remains sticky — interior change does not leak across the
|
||||
// per-instance boundary.
|
||||
expect(restoredInstance.widgets?.[0].value).toBe(33)
|
||||
expect(
|
||||
restoredInstance.widgets?.[0].serializeValue?.(restoredInstance, 0)
|
||||
).toBe(33)
|
||||
})
|
||||
|
||||
it('preserves per-instance values when reconfigured without widgets_values', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'value', type: 'number' }]
|
||||
})
|
||||
|
||||
const { node, widget } = createNodeWithWidget('TestNode', 5)
|
||||
subgraph.add(node)
|
||||
subgraph.inputNode.slots[0].connect(node.inputs[0], node)
|
||||
|
||||
const instance = createTestSubgraphNode(subgraph, { id: 701 })
|
||||
instance.graph!.add(instance)
|
||||
|
||||
const promotedWidget = instance.widgets?.[0]
|
||||
promotedWidget!.value = 11
|
||||
widget.value = 17
|
||||
|
||||
const serialized = instance.serialize()
|
||||
delete serialized.widgets_values
|
||||
|
||||
instance.configure({
|
||||
...serialized,
|
||||
id: instance.id,
|
||||
type: subgraph.id
|
||||
})
|
||||
|
||||
// Symmetric with super.configure(): absent widgets_values is a no-op
|
||||
// for widget values; the per-instance override is preserved.
|
||||
expect(instance.widgets?.[0].value).toBe(11)
|
||||
expect(instance.widgets?.[0].serializeValue?.(instance, 0)).toBe(11)
|
||||
})
|
||||
|
||||
it('skips non-serializable source widgets during serialize', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'value', type: 'number' }]
|
||||
})
|
||||
|
||||
const { node, widget } = createNodeWithWidget('TestNode', 10)
|
||||
subgraph.add(node)
|
||||
subgraph.inputNode.slots[0].connect(node.inputs[0], node)
|
||||
|
||||
// Mark the source widget as non-persistent (e.g. preview widget)
|
||||
widget.serialize = false
|
||||
|
||||
const instance = createTestSubgraphNode(subgraph, { id: 501 })
|
||||
instance.configure({
|
||||
id: 501,
|
||||
type: subgraph.id,
|
||||
pos: [100, 100],
|
||||
size: [200, 100],
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
mode: 0,
|
||||
order: 0,
|
||||
flags: {},
|
||||
properties: { proxyWidgets: [['-1', 'widget']] },
|
||||
widgets_values: []
|
||||
})
|
||||
|
||||
const serialized = instance.serialize()
|
||||
expect(serialized.widgets_values).toBeUndefined()
|
||||
})
|
||||
|
||||
it('ignores sparse widgets_values holes when restoring promoted widget instances', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [
|
||||
{ name: 'first', type: 'number' },
|
||||
{ name: 'second', type: 'number' }
|
||||
]
|
||||
})
|
||||
|
||||
const firstNode = new LGraphNode('First')
|
||||
const firstInput = firstNode.addInput('first', 'number')
|
||||
const firstWidget = firstNode.addWidget('number', 'first', 5, () => {})
|
||||
firstInput.widget = { name: 'first' }
|
||||
firstWidget.serialize = false
|
||||
|
||||
const secondNode = new LGraphNode('Second')
|
||||
const secondInput = secondNode.addInput('second', 'number')
|
||||
secondNode.addWidget('number', 'second', 9, () => {})
|
||||
secondInput.widget = { name: 'second' }
|
||||
|
||||
subgraph.add(firstNode)
|
||||
subgraph.add(secondNode)
|
||||
subgraph.inputNode.slots[0].connect(firstNode.inputs[0], firstNode)
|
||||
subgraph.inputNode.slots[1].connect(secondNode.inputs[0], secondNode)
|
||||
|
||||
const instance = createTestSubgraphNode(subgraph, { id: 701 })
|
||||
const widgetsValues = new Array<number | undefined>(2)
|
||||
widgetsValues[1] = 11
|
||||
|
||||
instance.configure({
|
||||
id: 701,
|
||||
type: subgraph.id,
|
||||
pos: [0, 0],
|
||||
size: [200, 100],
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
mode: 0,
|
||||
order: 0,
|
||||
flags: {},
|
||||
properties: {
|
||||
proxyWidgets: [
|
||||
['-1', 'first'],
|
||||
['-1', 'second']
|
||||
]
|
||||
},
|
||||
widgets_values: widgetsValues
|
||||
})
|
||||
|
||||
expect(instance.widgets[0].value).toBe(5)
|
||||
expect(instance.widgets[1].value).toBe(11)
|
||||
})
|
||||
|
||||
it('ignores configure replay for promoted widgets whose concrete source is non-serializable', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'value', type: 'number' }]
|
||||
})
|
||||
|
||||
const { node, widget } = createNodeWithWidget('TestNode', 10)
|
||||
widget.serialize = false
|
||||
subgraph.add(node)
|
||||
subgraph.inputNode.slots[0].connect(node.inputs[0], node)
|
||||
|
||||
const instance = createTestSubgraphNode(subgraph, { id: 502 })
|
||||
instance.configure({
|
||||
id: 502,
|
||||
type: subgraph.id,
|
||||
pos: [100, 100],
|
||||
size: [200, 100],
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
mode: 0,
|
||||
order: 0,
|
||||
flags: {},
|
||||
properties: { proxyWidgets: [['-1', 'widget']] },
|
||||
widgets_values: [33]
|
||||
})
|
||||
|
||||
expect(instance.widgets[0].value).toBe(10)
|
||||
|
||||
widget.value = 14
|
||||
|
||||
expect(instance.widgets[0].value).toBe(14)
|
||||
expect(instance.widgets[0].serializeValue?.(instance, 0)).toBe(14)
|
||||
})
|
||||
|
||||
it('serializes nested promoted widgets from the concrete source widget serialize state', () => {
|
||||
const leafSubgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'value', type: 'number' }]
|
||||
})
|
||||
const concreteNode = new LGraphNode('ConcreteNode')
|
||||
const concreteInput = concreteNode.addInput('value', 'number')
|
||||
const concreteWidget = concreteNode.addWidget(
|
||||
'number',
|
||||
'value',
|
||||
5,
|
||||
() => {}
|
||||
)
|
||||
concreteInput.widget = { name: 'value' }
|
||||
leafSubgraph.add(concreteNode)
|
||||
leafSubgraph.inputNode.slots[0].connect(concreteInput, concreteNode)
|
||||
|
||||
const middleNode = createTestSubgraphNode(leafSubgraph, { id: 901 })
|
||||
const middleSubgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'value', type: 'number' }]
|
||||
})
|
||||
middleSubgraph.add(middleNode)
|
||||
middleNode._internalConfigureAfterSlots()
|
||||
middleSubgraph.inputNode.slots[0].connect(middleNode.inputs[0], middleNode)
|
||||
|
||||
const innerHostNode = createTestSubgraphNode(middleSubgraph, { id: 902 })
|
||||
const outerSubgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'value', type: 'number' }]
|
||||
})
|
||||
outerSubgraph.add(innerHostNode)
|
||||
innerHostNode._internalConfigureAfterSlots()
|
||||
outerSubgraph.inputNode.slots[0].connect(
|
||||
innerHostNode.inputs[0],
|
||||
innerHostNode
|
||||
)
|
||||
|
||||
const outerHostNode = createTestSubgraphNode(outerSubgraph, { id: 903 })
|
||||
outerHostNode.graph!.add(outerHostNode)
|
||||
outerHostNode.widgets[0].value = 123
|
||||
|
||||
expect(outerHostNode.serialize().widgets_values).toEqual([123])
|
||||
|
||||
concreteWidget.serialize = false
|
||||
|
||||
expect(outerHostNode.serialize().widgets_values).toBeUndefined()
|
||||
})
|
||||
|
||||
it('does not clobber super.serialize() values when a concrete source widget is non-serializable', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'value', type: 'number' }]
|
||||
})
|
||||
|
||||
const { node, widget } = createNodeWithWidget('TestNode', 0)
|
||||
// Mark the concrete source widget non-serializable so the merge loop skips
|
||||
// index 0, letting super's value survive.
|
||||
widget.serialize = false
|
||||
subgraph.add(node)
|
||||
subgraph.inputNode.slots[0].connect(node.inputs[0], node)
|
||||
|
||||
const instance = createTestSubgraphNode(subgraph, { id: 801 })
|
||||
instance.graph!.add(instance)
|
||||
|
||||
// Stub super.serialize to simulate a native widget contributing a
|
||||
// positional value at index 0 (a slot the promoted view would own
|
||||
// if it were serializable, but this view skips because serialize:false).
|
||||
const SuperProto = Object.getPrototypeOf(Object.getPrototypeOf(instance))
|
||||
const originalSerialize = SuperProto.serialize as () => {
|
||||
widgets_values?: unknown[]
|
||||
}
|
||||
vi.spyOn(SuperProto, 'serialize').mockImplementationOnce(
|
||||
function (this: typeof instance) {
|
||||
const out = originalSerialize.call(this)
|
||||
out.widgets_values = ['native-value']
|
||||
return out
|
||||
}
|
||||
)
|
||||
|
||||
const out = instance.serialize()
|
||||
|
||||
expect(out.widgets_values?.[0]).toBe('native-value')
|
||||
})
|
||||
|
||||
it('round-trips Date widget values via structuredClone (preserves type)', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'value', type: 'number' }]
|
||||
})
|
||||
|
||||
const { node } = createNodeWithWidget('TestNode', 0)
|
||||
subgraph.add(node)
|
||||
subgraph.inputNode.slots[0].connect(node.inputs[0], node)
|
||||
|
||||
const instance = createTestSubgraphNode(subgraph, { id: 901 })
|
||||
instance.graph!.add(instance)
|
||||
|
||||
const date = new Date('2025-01-01T00:00:00.000Z')
|
||||
instance.widgets[0].value = { when: date }
|
||||
|
||||
const out = instance.serialize()
|
||||
const cloned = out.widgets_values?.[0] as { when: Date } | undefined
|
||||
expect(cloned?.when).toBeInstanceOf(Date)
|
||||
expect(cloned?.when.getTime()).toBe(date.getTime())
|
||||
})
|
||||
|
||||
it('preserves per-instance promoted widget values across LGraphNode.clone (copy/paste)', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'value', type: 'number' }]
|
||||
})
|
||||
|
||||
const { node } = createNodeWithWidget('TestNode', 0)
|
||||
subgraph.add(node)
|
||||
subgraph.inputNode.slots[0].connect(node.inputs[0], node)
|
||||
|
||||
registerSubgraphNodeType(subgraph)
|
||||
registeredTypes.push(subgraph.id)
|
||||
|
||||
const original = createTestSubgraphNode(subgraph, { id: 501 })
|
||||
original.configure({
|
||||
id: 501,
|
||||
type: subgraph.id,
|
||||
pos: [100, 100],
|
||||
size: [200, 100],
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
mode: 0,
|
||||
order: 0,
|
||||
flags: {},
|
||||
properties: { proxyWidgets: [['-1', 'widget']] },
|
||||
widgets_values: ['per-instance-value']
|
||||
})
|
||||
|
||||
expect(original.widgets[0].value).toBe('per-instance-value')
|
||||
|
||||
// LGraphNode.clone() invokes LiteGraph.createNode (id = -1), strips the id
|
||||
// from the serialized data, then calls configure(data). The clone then
|
||||
// needs to be added to a graph to receive a real id.
|
||||
const clone = original.clone() as SubgraphNode | null
|
||||
expect(clone).toBeTruthy()
|
||||
if (!clone) throw new Error('clone failed')
|
||||
|
||||
original.graph!.add(clone)
|
||||
expect(clone.id).not.toBe(-1)
|
||||
|
||||
expect(clone.widgets[0].value).toBe('per-instance-value')
|
||||
expect(clone.widgets[0].serializeValue?.(clone, 0)).toBe(
|
||||
'per-instance-value'
|
||||
)
|
||||
})
|
||||
|
||||
it('clears deferred widget replay when reconfigured without widgets_values before attach', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'value', type: 'number' }]
|
||||
})
|
||||
|
||||
const { node } = createNodeWithWidget('TestNode', 5)
|
||||
subgraph.add(node)
|
||||
subgraph.inputNode.slots[0].connect(node.inputs[0], node)
|
||||
|
||||
const detached = createTestSubgraphNode(subgraph, { id: -1 })
|
||||
detached.configure({
|
||||
id: -1,
|
||||
type: subgraph.id,
|
||||
pos: [100, 100],
|
||||
size: [200, 100],
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
mode: 0,
|
||||
order: 0,
|
||||
flags: {},
|
||||
properties: { proxyWidgets: [['-1', 'widget']] },
|
||||
widgets_values: [33]
|
||||
})
|
||||
|
||||
detached.configure({
|
||||
id: -1,
|
||||
type: subgraph.id,
|
||||
pos: [100, 100],
|
||||
size: [200, 100],
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
mode: 0,
|
||||
order: 0,
|
||||
flags: {},
|
||||
properties: { proxyWidgets: [['-1', 'widget']] }
|
||||
})
|
||||
|
||||
detached.graph!.add(detached)
|
||||
|
||||
expect(detached.id).not.toBe(-1)
|
||||
expect(detached.widgets[0].value).toBe(5)
|
||||
expect(detached.widgets[0].serializeValue?.(detached, 0)).toBe(5)
|
||||
})
|
||||
})
|
||||
@@ -1,3 +1,5 @@
|
||||
import { toRaw } from 'vue'
|
||||
|
||||
import type { BaseLGraph, LGraph, SubgraphId } from '@/lib/litegraph/src/LGraph'
|
||||
import type { LGraphButton } from '@/lib/litegraph/src/LGraphButton'
|
||||
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
|
||||
@@ -28,7 +30,10 @@ import type {
|
||||
ISerialisedNode
|
||||
} from '@/lib/litegraph/src/types/serialisation'
|
||||
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import type {
|
||||
IBaseWidget,
|
||||
TWidgetValue
|
||||
} from '@/lib/litegraph/src/types/widgets'
|
||||
import {
|
||||
createPromotedWidgetView,
|
||||
isPromotedWidgetView
|
||||
@@ -44,11 +49,14 @@ import {
|
||||
supportsVirtualCanvasImagePreview
|
||||
} from '@/composables/node/canvasImagePreviewTypes'
|
||||
import { parseProxyWidgets } from '@/core/schemas/promotionSchema'
|
||||
import type { SerializedProxyWidgetTuple } from '@/core/schemas/promotionSchema'
|
||||
import { useDomWidgetStore } from '@/stores/domWidgetStore'
|
||||
import {
|
||||
makePromotionEntryKey,
|
||||
usePromotionStore
|
||||
} from '@/stores/promotionStore'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import { makeCompositeKey } from '@/utils/compositeKey'
|
||||
|
||||
import { ExecutableNodeDTO } from './ExecutableNodeDTO'
|
||||
import type { ExecutableLGraphNode, ExecutionId } from './ExecutableNodeDTO'
|
||||
@@ -103,6 +111,8 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
* lifecycle to persist.
|
||||
*/
|
||||
private _pendingPromotions: PromotedWidgetSource[] = []
|
||||
/** Widgets_values buffered during pre-attach configure(); drained in onAdded(). */
|
||||
private _pendingWidgetsValuesReplay?: TWidgetValue[]
|
||||
private _cacheVersion = 0
|
||||
private _linkedEntriesCache?: {
|
||||
version: number
|
||||
@@ -634,29 +644,30 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
disambiguatingSourceNodeId?: string
|
||||
): string {
|
||||
return disambiguatingSourceNodeId
|
||||
? JSON.stringify([
|
||||
? makeCompositeKey([
|
||||
inputKey,
|
||||
sourceNodeId,
|
||||
sourceWidgetName,
|
||||
inputName,
|
||||
disambiguatingSourceNodeId
|
||||
])
|
||||
: JSON.stringify([inputKey, sourceNodeId, sourceWidgetName, inputName])
|
||||
: makeCompositeKey([inputKey, sourceNodeId, sourceWidgetName, inputName])
|
||||
}
|
||||
|
||||
private _serializeEntries(
|
||||
entries: PromotedWidgetSource[]
|
||||
): (string[] | [string, string, string])[] {
|
||||
return entries.map((e) =>
|
||||
): SerializedProxyWidgetTuple[] {
|
||||
return entries.map((e) => [
|
||||
e.sourceNodeId,
|
||||
e.disambiguatingSourceNodeId
|
||||
? [e.sourceNodeId, e.sourceWidgetName, e.disambiguatingSourceNodeId]
|
||||
: [e.sourceNodeId, e.sourceWidgetName]
|
||||
)
|
||||
? `${e.sourceNodeId}: ${e.disambiguatingSourceNodeId}: ${e.sourceWidgetName}`
|
||||
: e.sourceWidgetName
|
||||
])
|
||||
}
|
||||
|
||||
private _resolveLegacyEntry(
|
||||
widgetName: string
|
||||
): [string, string] | undefined {
|
||||
): SerializedProxyWidgetTuple | undefined {
|
||||
// Legacy -1 entries use the slot name as the widget name.
|
||||
// Find the input with that name, then trace to the connected interior widget.
|
||||
const input = this.inputs.find((i) => i.name === widgetName)
|
||||
@@ -1042,6 +1053,45 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
)
|
||||
|
||||
super.configure(info)
|
||||
|
||||
// Replay widgets_values through promoted views to restore promoted-view
|
||||
// values that super.configure() skips. When the node is not yet attached
|
||||
// (`id === -1`, e.g. during `LGraphNode.clone()`), the PromotedWidgetView
|
||||
// setters short-circuit, so defer the replay until `onAdded()`.
|
||||
this._pendingWidgetsValuesReplay = undefined
|
||||
if (info.widgets_values) {
|
||||
if (this.id === -1) {
|
||||
this._pendingWidgetsValuesReplay = info.widgets_values
|
||||
} else {
|
||||
this._replayPromotedWidgetValues(info.widgets_values)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _replayPromotedWidgetValues(values: TWidgetValue[]): void {
|
||||
const views = this.widgets ?? []
|
||||
const limit = Math.min(views.length, values.length)
|
||||
for (let i = 0; i < limit; i++) {
|
||||
if (!(i in values)) continue
|
||||
|
||||
const view = views[i]
|
||||
const resolved = isPromotedWidgetView(view)
|
||||
? resolveConcretePromotedWidget(
|
||||
this,
|
||||
view.sourceNodeId,
|
||||
view.sourceWidgetName,
|
||||
view.disambiguatingSourceNodeId
|
||||
)
|
||||
: null
|
||||
if (
|
||||
resolved?.status === 'resolved' &&
|
||||
resolved.resolved.widget.serialize === false
|
||||
) {
|
||||
continue
|
||||
}
|
||||
|
||||
view.value = values[i]
|
||||
}
|
||||
}
|
||||
|
||||
override _internalConfigureAfterSlots() {
|
||||
@@ -1303,6 +1353,36 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
override onAdded(_graph: LGraph): void {
|
||||
this._flushPendingPromotions()
|
||||
this._syncPromotions()
|
||||
this._flushPendingWidgetsValuesReplay()
|
||||
}
|
||||
|
||||
private _flushPendingWidgetsValuesReplay(): void {
|
||||
const pending = this._pendingWidgetsValuesReplay
|
||||
if (!pending || this.id === -1) return
|
||||
|
||||
this._pendingWidgetsValuesReplay = undefined
|
||||
this._replayPromotedWidgetValues(pending)
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-projects per-instance promoted-widget override values onto interior
|
||||
* widgets. Runs after lazy-creation interiors (e.g. PrimitiveNode) have
|
||||
* materialized their widgets and re-applied their `widgets_values`.
|
||||
*/
|
||||
override onAfterGraphConfigured(): void {
|
||||
if (this.id === -1) return
|
||||
|
||||
const widgetStore = useWidgetValueStore()
|
||||
for (const view of this.widgets ?? []) {
|
||||
if (!isPromotedWidgetView(view)) continue
|
||||
const widgetState = widgetStore.getWidget(
|
||||
this.rootGraph.id,
|
||||
this.id,
|
||||
view.storeName
|
||||
)
|
||||
if (!widgetState) continue
|
||||
view.value = widgetState.value as IBaseWidget['value']
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1573,36 +1653,59 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronizes widget values from this SubgraphNode instance to the
|
||||
* corresponding widgets in the subgraph definition before serialization.
|
||||
* This ensures nested subgraph widget values are preserved when saving.
|
||||
* Projects per-instance promoted widget values into `widgets_values`
|
||||
* and the canonical promotion-store snapshot into
|
||||
* `properties.proxyWidgets` — both written onto the serialized output.
|
||||
* The live node's `properties.proxyWidgets` is not mutated; the
|
||||
* promotion store is the source of truth at runtime.
|
||||
*/
|
||||
override serialize(): ISerialisedNode {
|
||||
// Sync widget values to subgraph definition before serialization.
|
||||
// Only sync for inputs that are linked to a promoted widget via _widget.
|
||||
for (const input of this.inputs) {
|
||||
if (!input._widget) continue
|
||||
|
||||
const subgraphInput =
|
||||
input._subgraphSlot ??
|
||||
this.subgraph.inputNode.slots.find((slot) => slot.name === input.name)
|
||||
if (!subgraphInput) continue
|
||||
|
||||
const connectedWidgets = subgraphInput.getConnectedWidgets()
|
||||
for (const connectedWidget of connectedWidgets) {
|
||||
connectedWidget.value = input._widget.value
|
||||
}
|
||||
}
|
||||
|
||||
// Write promotion store state back to properties for serialization
|
||||
const entries = usePromotionStore().getPromotions(
|
||||
this.rootGraph.id,
|
||||
this.id
|
||||
)
|
||||
this.properties.proxyWidgets = this._serializeEntries(entries)
|
||||
|
||||
return super.serialize()
|
||||
const serialized = super.serialize()
|
||||
|
||||
// Write the promotion-store snapshot directly onto the serialized
|
||||
// output so the live properties.proxyWidgets stays untouched.
|
||||
serialized.properties ??= {}
|
||||
serialized.properties.proxyWidgets = this._serializeEntries(entries)
|
||||
|
||||
// Promoted views are skipped by super.serialize() (`serialize = false`),
|
||||
// so project their per-instance values back into widgets_values.
|
||||
// Respect the resolved concrete source widget's serialize flag so
|
||||
// transient widgets stay unsaved through nested promotions too.
|
||||
// SubgraphNode never sets `serialize_widgets`, so super.serialize()
|
||||
// never produces `widgets_values` — start from an empty array.
|
||||
const views = this.widgets ?? []
|
||||
const merged: TWidgetValue[] = []
|
||||
views.forEach((view, idx) => {
|
||||
if (isPromotedWidgetView(view)) {
|
||||
const resolved = resolveConcretePromotedWidget(
|
||||
this,
|
||||
view.sourceNodeId,
|
||||
view.sourceWidgetName,
|
||||
view.disambiguatingSourceNodeId
|
||||
)
|
||||
if (
|
||||
resolved.status === 'resolved' &&
|
||||
resolved.resolved.widget.serialize === false
|
||||
) {
|
||||
return
|
||||
}
|
||||
}
|
||||
const value = view.value
|
||||
merged[idx] =
|
||||
value != null && typeof value === 'object'
|
||||
? (structuredClone(toRaw(value)) as TWidgetValue)
|
||||
: (value as TWidgetValue)
|
||||
})
|
||||
if (merged.length > 0) serialized.widgets_values = merged
|
||||
|
||||
return serialized
|
||||
}
|
||||
|
||||
override clone() {
|
||||
const clone = super.clone()
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
|
||||
import { parseProxyWidgets } from '@/core/schemas/promotionSchema'
|
||||
import { duplicateSubgraphNodeIds } from '@/lib/litegraph/src/__fixtures__/duplicateSubgraphNodeIds'
|
||||
import {
|
||||
LGraph,
|
||||
@@ -522,15 +523,19 @@ describe('SubgraphSerialization - Data Integrity', () => {
|
||||
const subgraphB = graph.subgraphs.get(DUPLICATE_ID_SUBGRAPH_B)!
|
||||
const subgraphBIds = new Set(subgraphB.nodes.map((node) => String(node.id)))
|
||||
|
||||
const rootProxyWidgetsA = graph.getNodeById(102)?.properties?.proxyWidgets
|
||||
expect(Array.isArray(rootProxyWidgetsA)).toBe(true)
|
||||
for (const entry of rootProxyWidgetsA as string[][]) {
|
||||
const rootProxyWidgetsA = parseProxyWidgets(
|
||||
graph.getNodeById(102)?.properties?.proxyWidgets
|
||||
)
|
||||
expect(rootProxyWidgetsA.length).toBeGreaterThan(0)
|
||||
for (const entry of rootProxyWidgetsA) {
|
||||
expect(subgraphAIds.has(String(entry[0]))).toBe(true)
|
||||
}
|
||||
|
||||
const rootProxyWidgetsB = graph.getNodeById(103)?.properties?.proxyWidgets
|
||||
expect(Array.isArray(rootProxyWidgetsB)).toBe(true)
|
||||
for (const entry of rootProxyWidgetsB as string[][]) {
|
||||
const rootProxyWidgetsB = parseProxyWidgets(
|
||||
graph.getNodeById(103)?.properties?.proxyWidgets
|
||||
)
|
||||
expect(rootProxyWidgetsB.length).toBeGreaterThan(0)
|
||||
for (const entry of rootProxyWidgetsB) {
|
||||
expect(subgraphBIds.has(String(entry[0]))).toBe(true)
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { parseProxyWidgets } from '@/core/schemas/promotionSchema'
|
||||
import type {
|
||||
ISlotType,
|
||||
Subgraph,
|
||||
@@ -409,7 +410,10 @@ describe('SubgraphWidgetPromotion', () => {
|
||||
String(samplerNode.id)
|
||||
)
|
||||
expect(hostNode.properties.proxyWidgets).toStrictEqual([
|
||||
[String(nestedNode.id), 'noise_seed', String(samplerNode.id)]
|
||||
[
|
||||
String(nestedNode.id),
|
||||
`${nestedNode.id}: ${samplerNode.id}: noise_seed`
|
||||
]
|
||||
])
|
||||
})
|
||||
|
||||
@@ -430,8 +434,9 @@ describe('SubgraphWidgetPromotion', () => {
|
||||
|
||||
// serialize() syncs the promotion store into properties.proxyWidgets
|
||||
const serialized = hostNode.serialize()
|
||||
const originalProxyWidgets = serialized.properties!
|
||||
.proxyWidgets as string[][]
|
||||
const originalProxyWidgets = parseProxyWidgets(
|
||||
serialized.properties?.proxyWidgets
|
||||
)
|
||||
|
||||
expect(originalProxyWidgets.length).toBeGreaterThan(0)
|
||||
expect(
|
||||
@@ -441,7 +446,9 @@ describe('SubgraphWidgetPromotion', () => {
|
||||
// Simulate clone: create a second SubgraphNode configured from serialized data
|
||||
const cloneNode = createTestSubgraphNode(subgraph)
|
||||
cloneNode.configure(serialized)
|
||||
const cloneProxyWidgets = cloneNode.properties.proxyWidgets as string[][]
|
||||
const cloneProxyWidgets = parseProxyWidgets(
|
||||
cloneNode.properties.proxyWidgets
|
||||
)
|
||||
|
||||
expect(cloneProxyWidgets.length).toBeGreaterThan(0)
|
||||
expect(
|
||||
|
||||
@@ -39,12 +39,7 @@
|
||||
<template v-if="!showEmailForm">
|
||||
<!-- OAuth Buttons (primary) -->
|
||||
<div class="flex flex-col gap-4">
|
||||
<Button
|
||||
v-if="!googleSsoBlockedReason"
|
||||
type="button"
|
||||
class="h-10 w-full"
|
||||
@click="signInWithGoogle"
|
||||
>
|
||||
<Button type="button" class="h-10 w-full" @click="signInWithGoogle">
|
||||
<i class="pi pi-google mr-2"></i>
|
||||
{{ t('auth.login.loginWithGoogle') }}
|
||||
</Button>
|
||||
@@ -121,7 +116,6 @@ import { useFreeTierOnboarding } from '@/platform/cloud/onboarding/composables/u
|
||||
import { getSafePreviousFullPath } from '@/platform/cloud/onboarding/utils/previousFullPath'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import type { SignInData } from '@/schemas/signInSchema'
|
||||
import { getGoogleSsoBlockedReason } from '@/base/webviewDetection'
|
||||
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
@@ -132,7 +126,6 @@ const authError = ref('')
|
||||
const toastStore = useToastStore()
|
||||
const showEmailForm = ref(false)
|
||||
const { isFreeTierEnabled, freeTierCredits } = useFreeTierOnboarding()
|
||||
const googleSsoBlockedReason = getGoogleSsoBlockedReason()
|
||||
|
||||
function switchToEmailForm() {
|
||||
showEmailForm.value = true
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
|
||||
<!-- OAuth Buttons (primary) -->
|
||||
<div class="flex flex-col gap-4">
|
||||
<div v-if="!googleSsoBlockedReason" class="relative">
|
||||
<div class="relative">
|
||||
<Button type="button" class="h-10 w-full" @click="signInWithGoogle">
|
||||
<i class="pi pi-google mr-2"></i>
|
||||
{{ t('auth.signup.signUpWithGoogle') }}
|
||||
@@ -141,7 +141,6 @@ import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import type { SignUpData } from '@/schemas/signInSchema'
|
||||
import { isInChina } from '@/utils/networkUtil'
|
||||
import { getGoogleSsoBlockedReason } from '@/base/webviewDetection'
|
||||
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
@@ -159,7 +158,6 @@ const {
|
||||
switchToEmailForm,
|
||||
switchToSocialLogin
|
||||
} = useFreeTierOnboarding()
|
||||
const googleSsoBlockedReason = getGoogleSsoBlockedReason()
|
||||
|
||||
const navigateToLogin = async () => {
|
||||
await router.push({ name: 'cloud-login', query: route.query })
|
||||
|
||||
@@ -580,71 +580,6 @@ describe('useWorkspaceAuthStore', () => {
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2)
|
||||
expect(workspaceToken.value).toBe('refreshed-token')
|
||||
})
|
||||
|
||||
it('keeps the current workspace token when refresh auth fails before expiry', async () => {
|
||||
mockGetIdToken.mockResolvedValue('firebase-token-xyz')
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockTokenResponse)
|
||||
})
|
||||
vi.stubGlobal('fetch', mockFetch)
|
||||
|
||||
const store = useWorkspaceAuthStore()
|
||||
const { currentWorkspace, workspaceToken } = storeToRefs(store)
|
||||
|
||||
await store.switchWorkspace('workspace-123')
|
||||
|
||||
mockGetIdToken.mockResolvedValue(undefined)
|
||||
const refreshPromise = store.refreshToken()
|
||||
await vi.advanceTimersByTimeAsync(7_000)
|
||||
await refreshPromise
|
||||
|
||||
expect(currentWorkspace.value).toEqual(mockWorkspaceWithRole)
|
||||
expect(workspaceToken.value).toBe('workspace-token-abc')
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1)
|
||||
|
||||
mockGetIdToken.mockResolvedValue('firebase-token-xyz')
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
...mockTokenResponse,
|
||||
token: 'recovered-token'
|
||||
})
|
||||
})
|
||||
|
||||
await vi.advanceTimersByTimeAsync(60_000)
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(workspaceToken.value).toBe('recovered-token')
|
||||
})
|
||||
})
|
||||
|
||||
it('clears workspace context when refresh keeps failing after token expiry', async () => {
|
||||
mockGetIdToken.mockResolvedValue('firebase-token-xyz')
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockTokenResponse)
|
||||
})
|
||||
)
|
||||
|
||||
const store = useWorkspaceAuthStore()
|
||||
const { currentWorkspace, workspaceToken } = storeToRefs(store)
|
||||
|
||||
await store.switchWorkspace('workspace-123')
|
||||
|
||||
vi.setSystemTime(Date.now() + 2 * 60 * 60 * 1000)
|
||||
mockGetIdToken.mockResolvedValue(undefined)
|
||||
|
||||
const refreshPromise = store.refreshToken()
|
||||
await vi.advanceTimersByTimeAsync(7_000)
|
||||
await refreshPromise
|
||||
|
||||
expect(currentWorkspace.value).toBeNull()
|
||||
expect(workspaceToken.value).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('isAuthenticated computed', () => {
|
||||
|
||||
@@ -49,7 +49,6 @@ export const useWorkspaceAuthStore = defineStore('workspaceAuth', () => {
|
||||
// State
|
||||
const currentWorkspace = shallowRef<WorkspaceWithRole | null>(null)
|
||||
const workspaceToken = ref<string | null>(null)
|
||||
const workspaceTokenExpiresAt = ref<number | null>(null)
|
||||
const isLoading = ref(false)
|
||||
const error = ref<Error | null>(null)
|
||||
|
||||
@@ -83,33 +82,6 @@ export const useWorkspaceAuthStore = defineStore('workspaceAuth', () => {
|
||||
}, delay)
|
||||
}
|
||||
|
||||
function hasUsableWorkspaceToken(): boolean {
|
||||
return (
|
||||
workspaceToken.value !== null &&
|
||||
workspaceTokenExpiresAt.value !== null &&
|
||||
workspaceTokenExpiresAt.value > Date.now()
|
||||
)
|
||||
}
|
||||
|
||||
function scheduleRefreshRetry(): void {
|
||||
stopRefreshTimer()
|
||||
|
||||
if (!hasUsableWorkspaceToken() || workspaceTokenExpiresAt.value === null) {
|
||||
clearWorkspaceContext()
|
||||
return
|
||||
}
|
||||
|
||||
const remainingMs = workspaceTokenExpiresAt.value - Date.now()
|
||||
const retryDelay =
|
||||
remainingMs <= 10_000
|
||||
? remainingMs
|
||||
: Math.min(60_000, Math.floor(remainingMs / 2))
|
||||
|
||||
refreshTimerId = setTimeout(() => {
|
||||
void refreshToken()
|
||||
}, retryDelay)
|
||||
}
|
||||
|
||||
function persistToSession(
|
||||
workspace: WorkspaceWithRole,
|
||||
token: string,
|
||||
@@ -183,7 +155,6 @@ export const useWorkspaceAuthStore = defineStore('workspaceAuth', () => {
|
||||
|
||||
currentWorkspace.value = parseResult.data
|
||||
workspaceToken.value = token
|
||||
workspaceTokenExpiresAt.value = expiresAt
|
||||
error.value = null
|
||||
|
||||
scheduleTokenRefresh(expiresAt)
|
||||
@@ -288,7 +259,6 @@ export const useWorkspaceAuthStore = defineStore('workspaceAuth', () => {
|
||||
|
||||
currentWorkspace.value = workspaceWithRole
|
||||
workspaceToken.value = data.token
|
||||
workspaceTokenExpiresAt.value = expiresAt
|
||||
|
||||
persistToSession(workspaceWithRole, data.token, expiresAt)
|
||||
scheduleTokenRefresh(expiresAt)
|
||||
@@ -326,11 +296,15 @@ export const useWorkspaceAuthStore = defineStore('workspaceAuth', () => {
|
||||
} catch (err) {
|
||||
const isAuthError = err instanceof WorkspaceAuthError
|
||||
|
||||
const isWorkspaceAccessRevoked =
|
||||
const isPermanentError =
|
||||
isAuthError &&
|
||||
(err.code === 'ACCESS_DENIED' || err.code === 'WORKSPACE_NOT_FOUND')
|
||||
(err.code === 'ACCESS_DENIED' ||
|
||||
err.code === 'WORKSPACE_NOT_FOUND' ||
|
||||
err.code === 'INVALID_FIREBASE_TOKEN' ||
|
||||
err.code === 'NOT_AUTHENTICATED')
|
||||
|
||||
if (isWorkspaceAccessRevoked) {
|
||||
if (isPermanentError) {
|
||||
// Only clear context if this refresh is still for the current workspace
|
||||
if (capturedRequestId === refreshRequestId) {
|
||||
console.error('Workspace access revoked or auth invalid:', err)
|
||||
clearWorkspaceContext()
|
||||
@@ -338,14 +312,10 @@ export const useWorkspaceAuthStore = defineStore('workspaceAuth', () => {
|
||||
return
|
||||
}
|
||||
|
||||
const shouldRetryImmediately =
|
||||
attempt < maxRetries &&
|
||||
(!isAuthError ||
|
||||
err.code === 'TOKEN_EXCHANGE_FAILED' ||
|
||||
err.code === 'INVALID_FIREBASE_TOKEN' ||
|
||||
err.code === 'NOT_AUTHENTICATED')
|
||||
const isTransientError =
|
||||
isAuthError && err.code === 'TOKEN_EXCHANGE_FAILED'
|
||||
|
||||
if (shouldRetryImmediately) {
|
||||
if (isTransientError && attempt < maxRetries) {
|
||||
const delay = baseDelayMs * Math.pow(2, attempt)
|
||||
console.warn(
|
||||
`Token refresh failed (attempt ${attempt + 1}/${maxRetries + 1}), retrying in ${delay}ms:`,
|
||||
@@ -355,29 +325,17 @@ export const useWorkspaceAuthStore = defineStore('workspaceAuth', () => {
|
||||
continue
|
||||
}
|
||||
|
||||
if (
|
||||
capturedRequestId === refreshRequestId &&
|
||||
hasUsableWorkspaceToken()
|
||||
) {
|
||||
console.warn(
|
||||
'Workspace token refresh failed, keeping current token until expiry:',
|
||||
err
|
||||
)
|
||||
scheduleRefreshRetry()
|
||||
return
|
||||
}
|
||||
|
||||
// Only clear context if this refresh is still for the current workspace
|
||||
if (capturedRequestId === refreshRequestId) {
|
||||
console.error('Failed to refresh workspace token after retries:', err)
|
||||
clearWorkspaceContext()
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getWorkspaceAuthHeader(): AuthHeader | null {
|
||||
if (!hasUsableWorkspaceToken()) {
|
||||
if (!workspaceToken.value) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
@@ -386,9 +344,7 @@ export const useWorkspaceAuthStore = defineStore('workspaceAuth', () => {
|
||||
}
|
||||
|
||||
function getWorkspaceToken(): string | undefined {
|
||||
return hasUsableWorkspaceToken()
|
||||
? (workspaceToken.value ?? undefined)
|
||||
: undefined
|
||||
return workspaceToken.value ?? undefined
|
||||
}
|
||||
|
||||
function clearWorkspaceContext(): void {
|
||||
@@ -397,7 +353,6 @@ export const useWorkspaceAuthStore = defineStore('workspaceAuth', () => {
|
||||
stopRefreshTimer()
|
||||
currentWorkspace.value = null
|
||||
workspaceToken.value = null
|
||||
workspaceTokenExpiresAt.value = null
|
||||
error.value = null
|
||||
clearSessionStorage()
|
||||
}
|
||||
|
||||
@@ -129,25 +129,22 @@ describe('NodeWidgets', () => {
|
||||
const duplicateA = createMockWidget({
|
||||
name: 'string_a',
|
||||
type: 'text',
|
||||
nodeId: '5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19',
|
||||
storeNodeId: '5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19',
|
||||
storeName: 'string_a',
|
||||
nodeId: 'host-node',
|
||||
instanceWidgetName: 'source:19:string_a',
|
||||
slotName: 'string_a'
|
||||
})
|
||||
const duplicateB = createMockWidget({
|
||||
name: 'string_a',
|
||||
type: 'text',
|
||||
nodeId: '5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19',
|
||||
storeNodeId: '5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19',
|
||||
storeName: 'string_a',
|
||||
nodeId: 'host-node',
|
||||
instanceWidgetName: 'source:19:string_a',
|
||||
slotName: 'string_a'
|
||||
})
|
||||
const distinct = createMockWidget({
|
||||
name: 'string_a',
|
||||
type: 'text',
|
||||
nodeId: '5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:20',
|
||||
storeNodeId: '5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:20',
|
||||
storeName: 'string_a',
|
||||
nodeId: 'host-node',
|
||||
instanceWidgetName: 'source:20:string_a',
|
||||
slotName: 'string_a'
|
||||
})
|
||||
const nodeData = createMockNodeData('SubgraphNode', [
|
||||
@@ -165,18 +162,16 @@ describe('NodeWidgets', () => {
|
||||
const hiddenDuplicate = createMockWidget({
|
||||
name: 'string_a',
|
||||
type: 'text',
|
||||
nodeId: '5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19',
|
||||
storeNodeId: '5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19',
|
||||
storeName: 'string_a',
|
||||
nodeId: 'host-node',
|
||||
instanceWidgetName: 'source:19:string_a',
|
||||
slotName: 'string_a',
|
||||
options: { hidden: true }
|
||||
})
|
||||
const visibleDuplicate = createMockWidget({
|
||||
name: 'string_a',
|
||||
type: 'text',
|
||||
nodeId: '5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19',
|
||||
storeNodeId: '5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19',
|
||||
storeName: 'string_a',
|
||||
nodeId: 'host-node',
|
||||
instanceWidgetName: 'source:19:string_a',
|
||||
slotName: 'string_a',
|
||||
options: { hidden: false }
|
||||
})
|
||||
@@ -194,17 +189,15 @@ describe('NodeWidgets', () => {
|
||||
const textWidget = createMockWidget({
|
||||
name: 'string_a',
|
||||
type: 'text',
|
||||
nodeId: '5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19',
|
||||
storeNodeId: '5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19',
|
||||
storeName: 'string_a',
|
||||
nodeId: 'host-node',
|
||||
instanceWidgetName: 'source:19:string_a',
|
||||
slotName: 'string_a'
|
||||
})
|
||||
const comboWidget = createMockWidget({
|
||||
name: 'string_a',
|
||||
type: 'combo',
|
||||
nodeId: '5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19',
|
||||
storeNodeId: '5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19',
|
||||
storeName: 'string_a',
|
||||
nodeId: 'host-node',
|
||||
instanceWidgetName: 'source:19:string_a',
|
||||
slotName: 'string_a'
|
||||
})
|
||||
const nodeData = createMockNodeData('SubgraphNode', [
|
||||
@@ -217,24 +210,20 @@ describe('NodeWidgets', () => {
|
||||
expect(container.querySelectorAll('.lg-node-widget')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('keeps unresolved same-name promoted entries distinct by source execution identity', () => {
|
||||
it('keeps same-name promoted entries distinct by instance widget identity', () => {
|
||||
const firstTransientEntry = createMockWidget({
|
||||
nodeId: undefined,
|
||||
storeNodeId: undefined,
|
||||
name: 'string_a',
|
||||
storeName: 'string_a',
|
||||
instanceWidgetName: 'source:18:string_a',
|
||||
slotName: 'string_a',
|
||||
type: 'text',
|
||||
sourceExecutionId: '65:18'
|
||||
type: 'text'
|
||||
})
|
||||
const secondTransientEntry = createMockWidget({
|
||||
nodeId: undefined,
|
||||
storeNodeId: undefined,
|
||||
name: 'string_a',
|
||||
storeName: 'string_a',
|
||||
instanceWidgetName: 'source:19:string_a',
|
||||
slotName: 'string_a',
|
||||
type: 'text',
|
||||
sourceExecutionId: '65:19'
|
||||
type: 'text'
|
||||
})
|
||||
const nodeData = createMockNodeData('SubgraphNode', [
|
||||
firstTransientEntry,
|
||||
@@ -250,17 +239,15 @@ describe('NodeWidgets', () => {
|
||||
const firstPromoted = createMockWidget({
|
||||
name: 'text',
|
||||
type: 'text',
|
||||
nodeId: 'outer-subgraph:1',
|
||||
storeNodeId: 'outer-subgraph:1',
|
||||
storeName: 'text',
|
||||
nodeId: 'host-node',
|
||||
instanceWidgetName: 'promoted:text:1',
|
||||
slotName: 'text'
|
||||
})
|
||||
const secondPromoted = createMockWidget({
|
||||
name: 'text',
|
||||
type: 'text',
|
||||
nodeId: 'outer-subgraph:2',
|
||||
storeNodeId: 'outer-subgraph:2',
|
||||
storeName: 'text',
|
||||
nodeId: 'host-node',
|
||||
instanceWidgetName: 'promoted:text:2',
|
||||
slotName: 'text'
|
||||
})
|
||||
|
||||
@@ -302,8 +289,16 @@ describe('NodeWidgets', () => {
|
||||
|
||||
it('keeps AppInput ids mapped to node identity for selection', () => {
|
||||
const nodeData = createMockNodeData('TestNode', [
|
||||
createMockWidget({ nodeId: 'test_node', name: 'seed_a', type: 'text' }),
|
||||
createMockWidget({ nodeId: 'test_node', name: 'seed_b', type: 'text' })
|
||||
createMockWidget({
|
||||
nodeId: 'test_node',
|
||||
name: 'seed_a',
|
||||
type: 'text'
|
||||
}),
|
||||
createMockWidget({
|
||||
nodeId: 'test_node',
|
||||
name: 'seed_b',
|
||||
type: 'text'
|
||||
})
|
||||
])
|
||||
|
||||
const { container } = render(NodeWidgets, {
|
||||
@@ -333,6 +328,6 @@ describe('NodeWidgets', () => {
|
||||
el.getAttribute('data-id')
|
||||
)
|
||||
|
||||
expect(ids).toStrictEqual(['test_node', 'test_node'])
|
||||
expect(ids).toStrictEqual(['1', '1'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -14,6 +14,7 @@ import { usePromotionStore } from '@/stores/promotionStore'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import { makeCompositeKey } from '@/utils/compositeKey'
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => ({
|
||||
@@ -42,10 +43,10 @@ const createMockWidget = (
|
||||
})
|
||||
|
||||
describe('getWidgetIdentity', () => {
|
||||
it('returns stable dedupeIdentity for widgets with storeNodeId', () => {
|
||||
it('returns stable dedupeIdentity for widgets with nodeId and instanceWidgetName', () => {
|
||||
const widget = createMockWidget({
|
||||
storeNodeId: 'subgraph:19',
|
||||
storeName: 'text',
|
||||
nodeId: 'subgraph:19',
|
||||
instanceWidgetName: 'text',
|
||||
slotName: 'text',
|
||||
type: 'text'
|
||||
})
|
||||
@@ -54,24 +55,26 @@ describe('getWidgetIdentity', () => {
|
||||
expect(renderKey).toBe(dedupeIdentity)
|
||||
})
|
||||
|
||||
it('returns transient renderKey for widgets without stable identity', () => {
|
||||
it('returns transient renderKey when neither widget nor caller provide a stable identity', () => {
|
||||
const widget = createMockWidget({
|
||||
nodeId: undefined,
|
||||
storeNodeId: undefined,
|
||||
sourceExecutionId: undefined
|
||||
})
|
||||
const { dedupeIdentity, renderKey } = getWidgetIdentity(widget, '5', 3)
|
||||
const { dedupeIdentity, renderKey } = getWidgetIdentity(
|
||||
widget,
|
||||
undefined,
|
||||
3
|
||||
)
|
||||
expect(dedupeIdentity).toBeUndefined()
|
||||
expect(renderKey).toBe('transient:5:test_widget:test_widget:combo:3')
|
||||
expect(renderKey).toBe('transient::test_widget:test_widget:combo:3')
|
||||
})
|
||||
|
||||
it('uses sourceExecutionId for identity when no nodeId', () => {
|
||||
it('uses sourceExecutionId for identity when no node id is available', () => {
|
||||
const widget = createMockWidget({
|
||||
nodeId: undefined,
|
||||
storeNodeId: undefined,
|
||||
sourceExecutionId: '65:18'
|
||||
})
|
||||
const { dedupeIdentity } = getWidgetIdentity(widget, '1', 0)
|
||||
const { dedupeIdentity } = getWidgetIdentity(widget, undefined, 0)
|
||||
expect(dedupeIdentity).toBe('exec:65:18:test_widget:test_widget:combo')
|
||||
})
|
||||
})
|
||||
@@ -211,9 +214,9 @@ describe('computeProcessedWidgets borderStyle', () => {
|
||||
const promotedWidget = createMockWidget({
|
||||
name: 'text',
|
||||
type: 'combo',
|
||||
nodeId: 'inner-subgraph:1',
|
||||
storeNodeId: 'inner-subgraph:1',
|
||||
storeName: 'text',
|
||||
nodeId: '3',
|
||||
instanceWidgetName: 'promoted:text:1',
|
||||
source: { sourceNodeId: '1', sourceWidgetName: 'text' },
|
||||
slotName: 'text'
|
||||
})
|
||||
|
||||
@@ -247,13 +250,58 @@ describe('computeProcessedWidgets borderStyle', () => {
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('uses disambiguatingSourceNodeId when checking promoted border styling', () => {
|
||||
const promotedWidget = createMockWidget({
|
||||
name: 'text',
|
||||
type: 'combo',
|
||||
nodeId: '3',
|
||||
instanceWidgetName: 'promoted:text:1',
|
||||
source: {
|
||||
sourceNodeId: '3',
|
||||
sourceWidgetName: 'text',
|
||||
disambiguatingSourceNodeId: '1'
|
||||
},
|
||||
slotName: 'text'
|
||||
})
|
||||
|
||||
usePromotionStore().promote('graph-test', '4', {
|
||||
sourceNodeId: '3',
|
||||
sourceWidgetName: 'text',
|
||||
disambiguatingSourceNodeId: '1'
|
||||
})
|
||||
|
||||
const result = computeProcessedWidgets({
|
||||
nodeData: {
|
||||
id: '3',
|
||||
type: 'SubgraphNode',
|
||||
widgets: [promotedWidget],
|
||||
title: 'Test',
|
||||
mode: 0,
|
||||
selected: false,
|
||||
executing: false,
|
||||
inputs: [],
|
||||
outputs: []
|
||||
},
|
||||
graphId: 'graph-test',
|
||||
showAdvanced: false,
|
||||
isGraphReady: false,
|
||||
rootGraph: null,
|
||||
ui: noopUi
|
||||
})
|
||||
|
||||
expect(
|
||||
result.some((w) => w.simplified.borderStyle?.includes('promoted'))
|
||||
).toBe(true)
|
||||
expect(result[0]?.id).toBe('3')
|
||||
})
|
||||
|
||||
it('does not apply promoted border styling to outermost widgets', () => {
|
||||
const promotedWidget = createMockWidget({
|
||||
name: 'text',
|
||||
type: 'combo',
|
||||
nodeId: 'inner-subgraph:1',
|
||||
storeNodeId: 'inner-subgraph:1',
|
||||
storeName: 'text',
|
||||
nodeId: '4',
|
||||
instanceWidgetName: 'promoted:text:1',
|
||||
source: { sourceNodeId: '1', sourceWidgetName: 'text' },
|
||||
slotName: 'text'
|
||||
})
|
||||
|
||||
@@ -323,8 +371,7 @@ describe('computeProcessedWidgets borderStyle', () => {
|
||||
name: 'text',
|
||||
type: 'combo',
|
||||
nodeId: '1',
|
||||
storeNodeId: '1',
|
||||
storeName: 'text',
|
||||
instanceWidgetName: 'text',
|
||||
slotName: 'text',
|
||||
options: { hidden: true }
|
||||
})
|
||||
@@ -333,8 +380,7 @@ describe('computeProcessedWidgets borderStyle', () => {
|
||||
name: 'text',
|
||||
type: 'combo',
|
||||
nodeId: '1',
|
||||
storeNodeId: '1',
|
||||
storeName: 'text',
|
||||
instanceWidgetName: 'text',
|
||||
slotName: 'text'
|
||||
})
|
||||
|
||||
@@ -362,6 +408,162 @@ describe('computeProcessedWidgets borderStyle', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('per-instance value lookup for promoted widgets', () => {
|
||||
const GRAPH_ID = 'graph-test'
|
||||
const INTERIOR_NODE_ID = '5'
|
||||
const INTERIOR_WIDGET_NAME = 'text'
|
||||
const STORE_NAME = makeCompositeKey([
|
||||
INTERIOR_NODE_ID,
|
||||
INTERIOR_WIDGET_NAME,
|
||||
''
|
||||
])
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
function buildPromotedWidget(instanceId: string): SafeWidgetData {
|
||||
return createMockWidget({
|
||||
name: INTERIOR_WIDGET_NAME,
|
||||
type: 'text',
|
||||
slotName: INTERIOR_WIDGET_NAME,
|
||||
nodeId: instanceId,
|
||||
instanceWidgetName: STORE_NAME,
|
||||
source: {
|
||||
sourceNodeId: INTERIOR_NODE_ID,
|
||||
sourceWidgetName: INTERIOR_WIDGET_NAME
|
||||
},
|
||||
sourceNodeLocatorId: `subgraph-def:${INTERIOR_NODE_ID}`
|
||||
})
|
||||
}
|
||||
|
||||
function processInstance(
|
||||
instanceId: string,
|
||||
widget: SafeWidgetData
|
||||
): ReturnType<typeof computeProcessedWidgets> {
|
||||
return computeProcessedWidgets({
|
||||
nodeData: {
|
||||
id: instanceId,
|
||||
type: 'SubgraphNode',
|
||||
widgets: [widget],
|
||||
title: 'Subgraph Instance',
|
||||
mode: 0,
|
||||
selected: false,
|
||||
executing: false,
|
||||
inputs: [],
|
||||
outputs: []
|
||||
},
|
||||
graphId: GRAPH_ID,
|
||||
showAdvanced: false,
|
||||
isGraphReady: false,
|
||||
rootGraph: null,
|
||||
ui: noopUi
|
||||
})
|
||||
}
|
||||
|
||||
it('reads per-instance values from per-instance WidgetState entries when multiple instances share a definition', () => {
|
||||
const widgetValueStore = useWidgetValueStore()
|
||||
widgetValueStore.registerWidget(GRAPH_ID, {
|
||||
nodeId: '100',
|
||||
name: STORE_NAME,
|
||||
type: 'text',
|
||||
value: 'valueA',
|
||||
options: {}
|
||||
})
|
||||
widgetValueStore.registerWidget(GRAPH_ID, {
|
||||
nodeId: '200',
|
||||
name: STORE_NAME,
|
||||
type: 'text',
|
||||
value: 'valueB',
|
||||
options: {}
|
||||
})
|
||||
|
||||
const [instanceA] = processInstance('100', buildPromotedWidget('100'))
|
||||
const [instanceB] = processInstance('200', buildPromotedWidget('200'))
|
||||
|
||||
expect(instanceA.value).toBe('valueA')
|
||||
expect(instanceB.value).toBe('valueB')
|
||||
})
|
||||
|
||||
it('produces distinct dedupe identities per instance so duplicate-pruning does not collapse them', () => {
|
||||
const widgetA = buildPromotedWidget('100')
|
||||
const widgetB = buildPromotedWidget('200')
|
||||
|
||||
const identityA = getWidgetIdentity(widgetA, '100', 0)
|
||||
const identityB = getWidgetIdentity(widgetB, '200', 0)
|
||||
|
||||
expect(identityA.dedupeIdentity).not.toBe(identityB.dedupeIdentity)
|
||||
})
|
||||
|
||||
it('falls back to interior WidgetState value when per-instance override is absent for a promoted widget', () => {
|
||||
const widgetValueStore = useWidgetValueStore()
|
||||
widgetValueStore.registerWidget(GRAPH_ID, {
|
||||
nodeId: INTERIOR_NODE_ID,
|
||||
name: INTERIOR_WIDGET_NAME,
|
||||
type: 'text',
|
||||
value: 'interior-value',
|
||||
options: {}
|
||||
})
|
||||
|
||||
const promotedWidget = createMockWidget({
|
||||
name: INTERIOR_WIDGET_NAME,
|
||||
type: 'text',
|
||||
slotName: INTERIOR_WIDGET_NAME,
|
||||
nodeId: '57',
|
||||
instanceWidgetName: 'STORE',
|
||||
source: {
|
||||
sourceNodeId: INTERIOR_NODE_ID,
|
||||
sourceWidgetName: INTERIOR_WIDGET_NAME
|
||||
}
|
||||
})
|
||||
|
||||
const [result] = processInstance('57', promotedWidget)
|
||||
|
||||
expect(result.value).toBe('interior-value')
|
||||
})
|
||||
})
|
||||
|
||||
describe('ordinary widget dedupe', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
it('does not collapse duplicate ordinary widget refs in node.widgets[]', () => {
|
||||
// Ordinary widgets do NOT carry their own identity (no widget.nodeId,
|
||||
// no instanceWidgetName). When a host node legitimately renders the
|
||||
// same widget twice, both must produce distinct entries with distinct
|
||||
// renderKeys.
|
||||
const ordinaryWidget = createMockWidget({
|
||||
name: 'seed',
|
||||
type: 'number',
|
||||
nodeId: undefined,
|
||||
instanceWidgetName: undefined
|
||||
})
|
||||
|
||||
const result = computeProcessedWidgets({
|
||||
nodeData: {
|
||||
id: '1',
|
||||
type: 'TestNode',
|
||||
widgets: [ordinaryWidget, ordinaryWidget],
|
||||
title: 'Test',
|
||||
mode: 0,
|
||||
selected: false,
|
||||
executing: false,
|
||||
inputs: [],
|
||||
outputs: []
|
||||
},
|
||||
graphId: 'graph-test',
|
||||
showAdvanced: false,
|
||||
isGraphReady: false,
|
||||
rootGraph: null,
|
||||
ui: noopUi
|
||||
})
|
||||
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result[0].renderKey).not.toBe(result[1].renderKey)
|
||||
})
|
||||
})
|
||||
|
||||
describe('createWidgetUpdateHandler (via computeProcessedWidgets)', () => {
|
||||
const GRAPH_ID = 'graph-test'
|
||||
const NODE_ID = '1'
|
||||
|
||||
@@ -126,8 +126,12 @@ export function getWidgetIdentity(
|
||||
dedupeIdentity?: string
|
||||
renderKey: string
|
||||
} {
|
||||
const rawWidgetId = widget.storeNodeId ?? widget.nodeId
|
||||
const storeWidgetName = widget.storeName ?? widget.name
|
||||
// Only widgets that carry their own identity (promoted widget views set
|
||||
// `widget.nodeId`) get a stable dedupe identity. Ordinary widgets must
|
||||
// fall through to the transient renderKey form so duplicate refs in
|
||||
// `node.widgets[]` are not collapsed into one entry.
|
||||
const rawWidgetId = widget.nodeId
|
||||
const storeWidgetName = widget.instanceWidgetName ?? widget.name
|
||||
const slotNameForIdentity = widget.slotName ?? widget.name
|
||||
const stableIdentityRoot = rawWidgetId
|
||||
? `node:${String(stripGraphPrefix(rawWidgetId))}`
|
||||
@@ -187,6 +191,7 @@ export function computeProcessedWidgets({
|
||||
identity: ReturnType<typeof getWidgetIdentity>
|
||||
mergedOptions: IWidgetOptions
|
||||
widgetState: WidgetState | undefined
|
||||
perInstanceWidgetState: WidgetState | undefined
|
||||
isVisible: boolean
|
||||
}> = []
|
||||
const dedupeIndexByIdentity = new Map<string, number>()
|
||||
@@ -195,13 +200,24 @@ export function computeProcessedWidgets({
|
||||
if (!shouldRenderAsVue(widget)) continue
|
||||
|
||||
const identity = getWidgetIdentity(widget, nodeId, index)
|
||||
const storeWidgetName = widget.storeName ?? widget.name
|
||||
const bareWidgetId = String(
|
||||
stripGraphPrefix(widget.storeNodeId ?? widget.nodeId ?? nodeId ?? '')
|
||||
)
|
||||
const widgetState = graphId
|
||||
const storeWidgetName = widget.instanceWidgetName ?? widget.name
|
||||
const bareWidgetId = String(stripGraphPrefix(widget.nodeId ?? nodeId ?? ''))
|
||||
const perInstanceWidgetState = graphId
|
||||
? widgetValueStore.getWidget(graphId, bareWidgetId, storeWidgetName)
|
||||
: undefined
|
||||
// For freshly-created promoted widget views the per-instance host
|
||||
// override may not be registered yet. Fall back to the interior source
|
||||
// WidgetState entry for reads only — the write path keeps using the
|
||||
// per-instance override.
|
||||
const fallbackWidgetState =
|
||||
graphId && widget.source && !perInstanceWidgetState
|
||||
? widgetValueStore.getWidget(
|
||||
graphId,
|
||||
widget.source.sourceNodeId,
|
||||
widget.source.sourceWidgetName
|
||||
)
|
||||
: undefined
|
||||
const widgetState = perInstanceWidgetState ?? fallbackWidgetState
|
||||
const mergedOptions: IWidgetOptions = {
|
||||
...(widget.options ?? {}),
|
||||
...(widgetState?.options ?? {})
|
||||
@@ -213,6 +229,7 @@ export function computeProcessedWidgets({
|
||||
identity,
|
||||
mergedOptions,
|
||||
widgetState,
|
||||
perInstanceWidgetState,
|
||||
isVisible: visible
|
||||
})
|
||||
continue
|
||||
@@ -226,6 +243,7 @@ export function computeProcessedWidgets({
|
||||
identity,
|
||||
mergedOptions,
|
||||
widgetState,
|
||||
perInstanceWidgetState,
|
||||
isVisible: visible
|
||||
})
|
||||
continue
|
||||
@@ -238,6 +256,7 @@ export function computeProcessedWidgets({
|
||||
identity,
|
||||
mergedOptions,
|
||||
widgetState,
|
||||
perInstanceWidgetState,
|
||||
isVisible: true
|
||||
}
|
||||
}
|
||||
@@ -247,14 +266,21 @@ export function computeProcessedWidgets({
|
||||
widget,
|
||||
mergedOptions,
|
||||
widgetState,
|
||||
perInstanceWidgetState,
|
||||
identity: { renderKey }
|
||||
} of uniqueWidgets) {
|
||||
const hostNodeId = String(nodeId ?? '')
|
||||
const bareWidgetId = String(
|
||||
stripGraphPrefix(widget.storeNodeId ?? widget.nodeId ?? nodeId ?? '')
|
||||
)
|
||||
const promotionSourceNodeId = widget.storeName
|
||||
? String(bareWidgetId)
|
||||
const hostBareId = String(stripGraphPrefix(nodeId ?? ''))
|
||||
const sourceNodeId = widget.source?.sourceNodeId
|
||||
const widgetId = sourceNodeId ?? hostBareId
|
||||
// Promotion-store grouping keys off the interior source identity.
|
||||
// For promoted views: use the source local id; for non-promoted
|
||||
// widgets: the host node id stands in (a non-promoted widget acts as
|
||||
// its own source for border-style purposes).
|
||||
const promotionLookupNodeId =
|
||||
widget.source?.disambiguatingSourceNodeId ?? sourceNodeId ?? hostBareId
|
||||
const promotionSourceNodeId = widget.source
|
||||
? promotionLookupNodeId
|
||||
: undefined
|
||||
|
||||
const vueComponent =
|
||||
@@ -274,7 +300,7 @@ export function computeProcessedWidgets({
|
||||
graphId &&
|
||||
promotionStore.isPromotedByAny(graphId, {
|
||||
sourceNodeId: hostNodeId,
|
||||
sourceWidgetName: widget.storeName ?? widget.name,
|
||||
sourceWidgetName: widget.name,
|
||||
disambiguatingSourceNodeId: promotionSourceNodeId
|
||||
})
|
||||
? 'ring ring-component-node-widget-promoted'
|
||||
@@ -290,11 +316,12 @@ export function computeProcessedWidgets({
|
||||
}
|
||||
: undefined
|
||||
|
||||
const nodeLocatorId = widget.nodeId
|
||||
? widget.nodeId
|
||||
: nodeData
|
||||
? getLocatorIdFromNodeData(nodeData)
|
||||
: undefined
|
||||
// Locator points at the source node for promoted views (canvas/save
|
||||
// flows resolve through the interior identity), and falls back to
|
||||
// the host node's locator for non-promoted widgets.
|
||||
const nodeLocatorId =
|
||||
widget.sourceNodeLocatorId ??
|
||||
(nodeData ? getLocatorIdFromNodeData(nodeData) : undefined)
|
||||
|
||||
const simplified: SimplifiedWidget = {
|
||||
name: widget.name,
|
||||
@@ -311,7 +338,7 @@ export function computeProcessedWidgets({
|
||||
}
|
||||
|
||||
const updateHandler = createWidgetUpdateHandler(
|
||||
widgetState,
|
||||
perInstanceWidgetState,
|
||||
widget,
|
||||
nodeExecId,
|
||||
widgetOptions,
|
||||
@@ -323,13 +350,7 @@ export function computeProcessedWidgets({
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (nodeId !== undefined) ui.handleNodeRightClick(e, nodeId)
|
||||
showNodeOptions(
|
||||
e,
|
||||
widget.name,
|
||||
widget.nodeId !== undefined
|
||||
? String(stripGraphPrefix(widget.nodeId))
|
||||
: undefined
|
||||
)
|
||||
showNodeOptions(e, widget.name, widget.source?.sourceNodeId)
|
||||
}
|
||||
|
||||
result.push({
|
||||
@@ -344,7 +365,7 @@ export function computeProcessedWidgets({
|
||||
missingModelStore
|
||||
),
|
||||
hidden: mergedOptions.hidden ?? false,
|
||||
id: String(bareWidgetId),
|
||||
id: widgetId,
|
||||
name: widget.name,
|
||||
renderKey,
|
||||
type: widget.type,
|
||||
|
||||
@@ -4,12 +4,22 @@ import { setActivePinia } from 'pinia'
|
||||
import { nextTick } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import type { PromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { LGraphNode as LiteGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraph, SubgraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import {
|
||||
createTestSubgraph,
|
||||
createTestSubgraphNode,
|
||||
resetSubgraphFixtureState
|
||||
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
|
||||
import type { LoadedComfyWorkflow } from '@/platform/workflow/management/stores/comfyWorkflow'
|
||||
import { ComfyWorkflow as ComfyWorkflowClass } from '@/platform/workflow/management/stores/comfyWorkflow'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import type { ChangeTracker } from '@/scripts/changeTracker'
|
||||
import { usePromotionStore } from '@/stores/promotionStore'
|
||||
import { createMockChangeTracker } from '@/utils/__tests__/litegraphTestUtils'
|
||||
|
||||
const mockEmptyWorkflowDialog = vi.hoisted(() => {
|
||||
@@ -26,7 +36,12 @@ const mockEmptyWorkflowDialog = vi.hoisted(() => {
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
rootGraph: { extra: {}, nodes: [{ id: 1 }], events: new EventTarget() }
|
||||
rootGraph: {
|
||||
extra: {},
|
||||
nodes: [{ id: 1, isSubgraphNode: () => false }],
|
||||
events: new EventTarget(),
|
||||
getNodeById: () => undefined
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
@@ -47,6 +62,16 @@ vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
vi.mock('@/components/builder/useEmptyWorkflowDialog', () => ({
|
||||
useEmptyWorkflowDialog: () => mockEmptyWorkflowDialog
|
||||
}))
|
||||
vi.mock('@/stores/domWidgetStore', () => ({
|
||||
useDomWidgetStore: () => ({
|
||||
widgetStates: new Map(),
|
||||
setPositionOverride: vi.fn(),
|
||||
clearPositionOverride: vi.fn()
|
||||
})
|
||||
}))
|
||||
vi.mock('@/services/litegraphService', () => ({
|
||||
useLitegraphService: () => ({ updatePreviews: () => ({}) })
|
||||
}))
|
||||
|
||||
const mockSettings = vi.hoisted(() => {
|
||||
const store: Record<string, unknown> = {}
|
||||
@@ -68,6 +93,35 @@ vi.mock('@/platform/settings/settingStore', () => ({
|
||||
|
||||
import { useAppModeStore } from './appModeStore'
|
||||
|
||||
function createPromotedWidgetFixture(hostId: number): {
|
||||
graph: LGraph
|
||||
host: SubgraphNode
|
||||
promoted: PromotedWidgetView
|
||||
} {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'value', type: '*' }]
|
||||
})
|
||||
const inner = new LiteGraphNode('Inner')
|
||||
const input = inner.addInput('value', '*')
|
||||
inner.addWidget('text', 'value', 'a', () => {})
|
||||
input.widget = { name: 'value' }
|
||||
subgraph.add(inner)
|
||||
subgraph.inputNode.slots[0].connect(input, inner)
|
||||
|
||||
const host = createTestSubgraphNode(subgraph, { id: hostId })
|
||||
host._internalConfigureAfterSlots()
|
||||
host.graph!.add(host)
|
||||
|
||||
usePromotionStore().setPromotions(host.rootGraph.id, host.id, [
|
||||
{ sourceNodeId: String(inner.id), sourceWidgetName: 'value' }
|
||||
])
|
||||
|
||||
const promoted = host.widgets.find(isPromotedWidgetView)
|
||||
if (!promoted) throw new Error('Expected promoted widget view')
|
||||
|
||||
return { graph: host.graph!, host, promoted }
|
||||
}
|
||||
|
||||
function createBuilderWorkflow(
|
||||
activeMode: string = 'builder:inputs'
|
||||
): LoadedComfyWorkflow {
|
||||
@@ -106,10 +160,13 @@ describe('appModeStore', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
resetSubgraphFixtureState()
|
||||
vi.mocked(app.rootGraph).extra = {}
|
||||
mockResolveNode.mockReturnValue(undefined)
|
||||
mockSettings.reset()
|
||||
vi.mocked(app.rootGraph).nodes = [{ id: 1 } as LGraphNode]
|
||||
vi.mocked(app.rootGraph).nodes = [
|
||||
{ id: 1, isSubgraphNode: () => false } as LGraphNode
|
||||
]
|
||||
workflowStore = useWorkflowStore()
|
||||
store = useAppModeStore()
|
||||
vi.clearAllMocks()
|
||||
@@ -258,6 +315,105 @@ describe('appModeStore', () => {
|
||||
expect(store.selectedInputs).toEqual([[1, 'prompt', { height: 150 }]])
|
||||
})
|
||||
|
||||
it('loadSelections rewrites legacy promoted tuples to host node id and storeName', async () => {
|
||||
const { graph, host, promoted } = createPromotedWidgetFixture(500)
|
||||
const { resolveNode: actualResolveNode } = (await vi.importActual(
|
||||
'@/utils/litegraphUtil'
|
||||
)) as {
|
||||
resolveNode: (nodeId: NodeId, graph: LGraph) => LGraphNode | undefined
|
||||
}
|
||||
const originalRootGraph = app.rootGraph
|
||||
|
||||
mockResolveNode.mockImplementation((id) => actualResolveNode(id, graph))
|
||||
Object.defineProperty(app, 'rootGraph', { value: graph, writable: true })
|
||||
|
||||
try {
|
||||
store.loadSelections({
|
||||
inputs: [[promoted.sourceNodeId, promoted.sourceWidgetName]],
|
||||
outputs: []
|
||||
})
|
||||
|
||||
expect(store.selectedInputs).toEqual([[host.id, promoted.storeName]])
|
||||
} finally {
|
||||
Object.defineProperty(app, 'rootGraph', {
|
||||
value: originalRootGraph,
|
||||
writable: true
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('preserves selected promoted-widget identity per instance across save/reload', async () => {
|
||||
// Build one subgraph definition with one promoted widget,
|
||||
// then create two SubgraphNode instances of that definition.
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'value', type: '*' }]
|
||||
})
|
||||
const inner = new LiteGraphNode('Inner')
|
||||
const input = inner.addInput('value', '*')
|
||||
inner.addWidget('text', 'value', 'a', () => {})
|
||||
input.widget = { name: 'value' }
|
||||
subgraph.add(inner)
|
||||
subgraph.inputNode.slots[0].connect(input, inner)
|
||||
|
||||
const hostA = createTestSubgraphNode(subgraph, { id: 701 })
|
||||
hostA._internalConfigureAfterSlots()
|
||||
hostA.graph!.add(hostA)
|
||||
|
||||
const hostB = createTestSubgraphNode(subgraph, { id: 702 })
|
||||
hostB._internalConfigureAfterSlots()
|
||||
hostB.graph!.add(hostB)
|
||||
|
||||
const promotionStore = usePromotionStore()
|
||||
promotionStore.setPromotions(hostA.rootGraph.id, hostA.id, [
|
||||
{ sourceNodeId: String(inner.id), sourceWidgetName: 'value' }
|
||||
])
|
||||
promotionStore.setPromotions(hostB.rootGraph.id, hostB.id, [
|
||||
{ sourceNodeId: String(inner.id), sourceWidgetName: 'value' }
|
||||
])
|
||||
|
||||
const promotedA = hostA.widgets.find(isPromotedWidgetView)
|
||||
const promotedB = hostB.widgets.find(isPromotedWidgetView)
|
||||
if (!promotedA || !promotedB) throw new Error('Expected promoted views')
|
||||
|
||||
// Precondition: storeNames are equal (interior identity matches),
|
||||
// host ids differ.
|
||||
expect(promotedA.storeName).toBe(promotedB.storeName)
|
||||
expect(hostA.id).not.toBe(hostB.id)
|
||||
|
||||
const { resolveNode: actualResolveNode } = (await vi.importActual(
|
||||
'@/utils/litegraphUtil'
|
||||
)) as {
|
||||
resolveNode: (nodeId: NodeId, graph: LGraph) => LGraphNode | undefined
|
||||
}
|
||||
const graph = hostA.graph!
|
||||
const originalRootGraph = app.rootGraph
|
||||
mockResolveNode.mockImplementation((id) => actualResolveNode(id, graph))
|
||||
Object.defineProperty(app, 'rootGraph', { value: graph, writable: true })
|
||||
|
||||
try {
|
||||
store.loadSelections({
|
||||
inputs: [
|
||||
[hostA.id, promotedA.storeName],
|
||||
[hostB.id, promotedB.storeName]
|
||||
],
|
||||
outputs: []
|
||||
})
|
||||
|
||||
expect(store.selectedInputs).toHaveLength(2)
|
||||
expect(store.selectedInputs).toEqual(
|
||||
expect.arrayContaining([
|
||||
[hostA.id, promotedA.storeName],
|
||||
[hostB.id, promotedB.storeName]
|
||||
])
|
||||
)
|
||||
} finally {
|
||||
Object.defineProperty(app, 'rootGraph', {
|
||||
value: originalRootGraph,
|
||||
writable: true
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps inputs for existing nodes even if widget is missing', async () => {
|
||||
const node1 = mockNode(1)
|
||||
mockResolveNode.mockImplementation((id) =>
|
||||
@@ -443,6 +599,18 @@ describe('appModeStore', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('removeSelectedInput', () => {
|
||||
it('uses host node id and promoted storeName', () => {
|
||||
const { host, promoted } = createPromotedWidgetFixture(601)
|
||||
|
||||
store.selectedInputs = [[host.id, promoted.storeName]]
|
||||
|
||||
store.removeSelectedInput(promoted, host)
|
||||
|
||||
expect(store.selectedInputs).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('autoEnableVueNodes', () => {
|
||||
it('enables Vue nodes when entering select mode with them disabled', async () => {
|
||||
mockSettings.store['Comfy.VueNodes.Enabled'] = false
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useEventListener } from '@vueuse/core'
|
||||
|
||||
import { useEmptyWorkflowDialog } from '@/components/builder/useEmptyWorkflowDialog'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type {
|
||||
InputWidgetConfig,
|
||||
LinearData,
|
||||
@@ -16,9 +16,12 @@ import { useWorkflowStore } from '@/platform/workflow/management/stores/workflow
|
||||
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import { ChangeTracker } from '@/scripts/changeTracker'
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { resolveNode } from '@/utils/litegraphUtil'
|
||||
import {
|
||||
getSelectedWidgetIdentity,
|
||||
resolveNode,
|
||||
resolveNodeWidget
|
||||
} from '@/utils/litegraphUtil'
|
||||
|
||||
export function nodeTypeValidForApp(type: string) {
|
||||
return !['Note', 'MarkdownNote'].includes(type)
|
||||
@@ -46,14 +49,37 @@ export const useAppModeStore = defineStore('appMode', () => {
|
||||
// Prune entries referencing nodes deleted in workflow mode.
|
||||
// Only check node existence, not widgets — dynamic widgets can
|
||||
// hide/show other widgets so a missing widget does not mean stale data.
|
||||
function normalizeSelectedInput(input: LinearInput): LinearInput {
|
||||
const [nodeId, widgetName, config] = input
|
||||
if (!app.rootGraph) return input
|
||||
|
||||
const resolved = resolveNodeWidget(nodeId, widgetName, app.rootGraph)
|
||||
if (resolved.length < 2) return input
|
||||
|
||||
const node = resolved[0]
|
||||
const widget = resolved[1]
|
||||
if (!node || !widget) return input
|
||||
|
||||
const [canonicalNodeId, canonicalWidgetName] = getSelectedWidgetIdentity(
|
||||
node,
|
||||
widget
|
||||
)
|
||||
return config === undefined
|
||||
? [canonicalNodeId, canonicalWidgetName]
|
||||
: [canonicalNodeId, canonicalWidgetName, config]
|
||||
}
|
||||
|
||||
function pruneLinearData(data: Partial<LinearData> | undefined): LinearData {
|
||||
const rawInputs = data?.inputs ?? []
|
||||
const rawOutputs = data?.outputs ?? []
|
||||
const normalizedInputs = app.rootGraph
|
||||
? rawInputs.map(normalizeSelectedInput)
|
||||
: rawInputs
|
||||
|
||||
return {
|
||||
inputs: app.rootGraph
|
||||
? rawInputs.filter(([nodeId]) => resolveNode(nodeId))
|
||||
: rawInputs,
|
||||
? normalizedInputs.filter(([nodeId]) => resolveNode(nodeId))
|
||||
: normalizedInputs,
|
||||
outputs: app.rootGraph
|
||||
? rawOutputs.filter((nodeId) => resolveNode(nodeId))
|
||||
: rawOutputs
|
||||
@@ -153,11 +179,8 @@ export const useAppModeStore = defineStore('appMode', () => {
|
||||
setMode('graph')
|
||||
}
|
||||
|
||||
function removeSelectedInput(widget: IBaseWidget, node: { id: NodeId }) {
|
||||
const storeId = isPromotedWidgetView(widget) ? widget.sourceNodeId : node.id
|
||||
const storeName = isPromotedWidgetView(widget)
|
||||
? widget.sourceWidgetName
|
||||
: widget.name
|
||||
function removeSelectedInput(widget: IBaseWidget, node: LGraphNode) {
|
||||
const [storeId, storeName] = getSelectedWidgetIdentity(node, widget)
|
||||
const index = selectedInputs.value.findIndex(
|
||||
([id, name]) => storeId == id && storeName === name
|
||||
)
|
||||
|
||||
@@ -60,6 +60,16 @@ export const useWidgetValueStore = defineStore('widgetValue', () => {
|
||||
return widgetStates.get(key) as WidgetState<TValue>
|
||||
}
|
||||
|
||||
/** First registration wins; later `state` seeds are discarded. */
|
||||
function getOrRegister(graphId: UUID, state: WidgetState): WidgetState {
|
||||
const widgetStates = getWidgetStateMap(graphId)
|
||||
const key = makeKey(state.nodeId, state.name)
|
||||
const existing = widgetStates.get(key)
|
||||
if (existing) return existing
|
||||
widgetStates.set(key, state)
|
||||
return state
|
||||
}
|
||||
|
||||
function getNodeWidgets(graphId: UUID, nodeId: NodeId): WidgetState[] {
|
||||
const widgetStates = getWidgetStateMap(graphId)
|
||||
const prefix = `${nodeId}:`
|
||||
@@ -82,6 +92,7 @@ export const useWidgetValueStore = defineStore('widgetValue', () => {
|
||||
|
||||
return {
|
||||
registerWidget,
|
||||
getOrRegister,
|
||||
getWidget,
|
||||
getNodeWidgets,
|
||||
clearGraph
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { LGraphNode, NodeId } from '@/lib/litegraph/src/litegraph'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import type { NodeLocatorId } from '@/types/nodeIdentification'
|
||||
import { makeCompositeKey } from '@/utils/compositeKey'
|
||||
import { getNodeByLocatorId } from '@/utils/graphTraversalUtil'
|
||||
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
@@ -75,7 +76,7 @@ export const useFavoritedWidgetsStore = defineStore('favoritedWidgets', () => {
|
||||
* Generate a unique string key for a favorited widget ID.
|
||||
*/
|
||||
function getFavoriteKey(id: FavoritedWidgetId): string {
|
||||
return JSON.stringify([id.nodeLocatorId, id.widgetName])
|
||||
return makeCompositeKey([id.nodeLocatorId, id.widgetName])
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
25
src/utils/compositeKey.test.ts
Normal file
25
src/utils/compositeKey.test.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { makeCompositeKey } from './compositeKey'
|
||||
|
||||
describe('makeCompositeKey', () => {
|
||||
it('produces a stable string for a tuple of values', () => {
|
||||
expect(makeCompositeKey(['a', 'b', 'c'])).toBe('["a","b","c"]')
|
||||
})
|
||||
|
||||
it('distinguishes tuples whose joined parts collide', () => {
|
||||
// Without an injective encoding, ['ab', 'c'] and ['a', 'bc'] could collide.
|
||||
expect(makeCompositeKey(['ab', 'c'])).not.toBe(
|
||||
makeCompositeKey(['a', 'bc'])
|
||||
)
|
||||
})
|
||||
|
||||
it('handles empty parts and undefined slots', () => {
|
||||
expect(makeCompositeKey(['x', '', 'y'])).toBe('["x","","y"]')
|
||||
expect(makeCompositeKey(['x', undefined, 'y'])).toBe('["x",null,"y"]')
|
||||
})
|
||||
|
||||
it('preserves part order', () => {
|
||||
expect(makeCompositeKey(['1', '2'])).not.toBe(makeCompositeKey(['2', '1']))
|
||||
})
|
||||
})
|
||||
10
src/utils/compositeKey.ts
Normal file
10
src/utils/compositeKey.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Build an opaque composite-key string from a tuple of values, suitable for
|
||||
* use as a Map or Set key. Uses JSON.stringify so the encoding is injective
|
||||
* across arbitrary string inputs (no separator collision possible). Keep the
|
||||
* format opaque at consumer boundaries — do not parse it externally except
|
||||
* in modules that own the round-trip (e.g. favoritedWidgetsStore).
|
||||
*/
|
||||
export function makeCompositeKey(parts: readonly unknown[]): string {
|
||||
return JSON.stringify(parts)
|
||||
}
|
||||
170
src/utils/executionUtil.test.ts
Normal file
170
src/utils/executionUtil.test.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import type { PromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetView'
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import {
|
||||
createTestRootGraph,
|
||||
createTestSubgraph,
|
||||
createTestSubgraphNode,
|
||||
resetSubgraphFixtureState
|
||||
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
|
||||
import { usePromotionStore } from '@/stores/promotionStore'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import { makeCompositeKey } from '@/utils/compositeKey'
|
||||
|
||||
import { graphToPrompt } from './executionUtil'
|
||||
|
||||
describe('graphToPrompt with promoted subgraph widgets (PR #11811)', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
resetSubgraphFixtureState()
|
||||
})
|
||||
|
||||
it('emits the user-edited promoted value, not the interior default', async () => {
|
||||
const rootGraph = createTestRootGraph()
|
||||
const subgraph = createTestSubgraph({ rootGraph })
|
||||
|
||||
// Interior node with a text widget "value" defaulting to "interior"
|
||||
const interiorNode = new LGraphNode('Interior')
|
||||
interiorNode.addWidget('text', 'value', 'interior', () => {})
|
||||
subgraph.add(interiorNode)
|
||||
|
||||
// SubgraphNode instance in the root graph
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, {
|
||||
parentGraph: rootGraph
|
||||
})
|
||||
rootGraph.add(subgraphNode)
|
||||
|
||||
// Promote the interior widget (mirrors proxyWidgets=[["<id>","value"]])
|
||||
usePromotionStore().promote(rootGraph.id, subgraphNode.id, {
|
||||
sourceNodeId: String(interiorNode.id),
|
||||
sourceWidgetName: 'value'
|
||||
})
|
||||
|
||||
// User-edits the exterior promoted widget to "exterior". This is the same
|
||||
// path the Vue widget update handler exercises in production.
|
||||
const view = subgraphNode.widgets[0] as PromotedWidgetView | undefined
|
||||
if (!view) throw new Error('Expected a promoted view on the SubgraphNode')
|
||||
view.value = 'exterior'
|
||||
|
||||
const { output } = await graphToPrompt(rootGraph)
|
||||
|
||||
const execId = `${subgraphNode.id}:${interiorNode.id}`
|
||||
expect(output[execId]).toBeDefined()
|
||||
expect(output[execId].inputs.value).toBe('exterior')
|
||||
})
|
||||
|
||||
it('isolates promoted values across two SubgraphNode instances of the same Subgraph', async () => {
|
||||
const rootGraph = createTestRootGraph()
|
||||
const subgraph = createTestSubgraph({ rootGraph })
|
||||
|
||||
const interiorNode = new LGraphNode('Interior')
|
||||
interiorNode.addWidget('text', 'value', 'interior', () => {})
|
||||
subgraph.add(interiorNode)
|
||||
|
||||
const instanceA = createTestSubgraphNode(subgraph, {
|
||||
parentGraph: rootGraph
|
||||
})
|
||||
rootGraph.add(instanceA)
|
||||
const instanceB = createTestSubgraphNode(subgraph, {
|
||||
parentGraph: rootGraph
|
||||
})
|
||||
rootGraph.add(instanceB)
|
||||
|
||||
const promotionStore = usePromotionStore()
|
||||
promotionStore.promote(rootGraph.id, instanceA.id, {
|
||||
sourceNodeId: String(interiorNode.id),
|
||||
sourceWidgetName: 'value'
|
||||
})
|
||||
promotionStore.promote(rootGraph.id, instanceB.id, {
|
||||
sourceNodeId: String(interiorNode.id),
|
||||
sourceWidgetName: 'value'
|
||||
})
|
||||
|
||||
const storeName = makeCompositeKey([String(interiorNode.id), 'value', ''])
|
||||
const widgetStore = useWidgetValueStore()
|
||||
widgetStore.registerWidget(rootGraph.id, {
|
||||
nodeId: instanceA.id,
|
||||
name: storeName,
|
||||
type: 'text',
|
||||
value: 'A-value',
|
||||
options: {}
|
||||
})
|
||||
widgetStore.registerWidget(rootGraph.id, {
|
||||
nodeId: instanceB.id,
|
||||
name: storeName,
|
||||
type: 'text',
|
||||
value: 'B-value',
|
||||
options: {}
|
||||
})
|
||||
|
||||
const { output } = await graphToPrompt(rootGraph)
|
||||
|
||||
expect(output[`${instanceA.id}:${interiorNode.id}`].inputs.value).toBe(
|
||||
'A-value'
|
||||
)
|
||||
expect(output[`${instanceB.id}:${interiorNode.id}`].inputs.value).toBe(
|
||||
'B-value'
|
||||
)
|
||||
})
|
||||
|
||||
it('emits the per-instance promoted value for a lazy-creation interior (PrimitiveNode-like)', async () => {
|
||||
// Mimics PrimitiveNode lazy widget creation (src/extensions/core/widgetInputs.ts:32+).
|
||||
class LazyPrimitiveLikeNode extends LGraphNode {
|
||||
constructor() {
|
||||
super('LazyPrimitiveLike')
|
||||
this.serialize_widgets = true
|
||||
}
|
||||
override onAfterGraphConfigured(): void {
|
||||
if (this.widgets?.length) return
|
||||
const widget = this.addWidget('text', 'value', '', () => {})
|
||||
const stored = this.widgets_values
|
||||
if (stored?.length) {
|
||||
widget.value = stored[0] as string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const rootGraph = createTestRootGraph()
|
||||
const subgraph = createTestSubgraph({ rootGraph })
|
||||
|
||||
const interiorNode = new LazyPrimitiveLikeNode()
|
||||
interiorNode.widgets_values = ['interior']
|
||||
subgraph.add(interiorNode)
|
||||
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, {
|
||||
parentGraph: rootGraph
|
||||
})
|
||||
rootGraph.add(subgraphNode)
|
||||
|
||||
// Configure with proxyWidgets + exterior before the interior exists.
|
||||
subgraphNode.configure({
|
||||
id: subgraphNode.id,
|
||||
type: subgraph.id,
|
||||
pos: [100, 100],
|
||||
size: [200, 100],
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
mode: 0,
|
||||
order: 0,
|
||||
flags: {},
|
||||
properties: {
|
||||
proxyWidgets: [[String(interiorNode.id), 'value']]
|
||||
},
|
||||
widgets_values: ['exterior']
|
||||
})
|
||||
|
||||
// Lazy materialization clobbers exterior with widgets_values=["interior"].
|
||||
interiorNode.onAfterGraphConfigured()
|
||||
|
||||
// SubgraphNode hook re-projects the per-instance override afterward.
|
||||
subgraphNode.onAfterGraphConfigured?.()
|
||||
|
||||
const { output } = await graphToPrompt(rootGraph)
|
||||
const execId = `${subgraphNode.id}:${interiorNode.id}`
|
||||
expect(output[execId]).toBeDefined()
|
||||
expect(output[execId].inputs.value).toBe('exterior')
|
||||
})
|
||||
})
|
||||
@@ -1,3 +1,4 @@
|
||||
import { resolveConcretePromotedWidget } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
|
||||
import type {
|
||||
ExecutableLGraphNode,
|
||||
ExecutionId,
|
||||
@@ -7,14 +8,95 @@ import {
|
||||
ExecutableNodeDTO,
|
||||
LGraphEventMode
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import type {
|
||||
ComfyApiWorkflow,
|
||||
ComfyWorkflowJSON
|
||||
} from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import { usePromotionStore } from '@/stores/promotionStore'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import { makeCompositeKey } from '@/utils/compositeKey'
|
||||
|
||||
import { ExecutableGroupNodeDTO, isGroupNode } from './executableGroupNodeDto'
|
||||
import { compressWidgetInputSlots } from './litegraphUtil'
|
||||
|
||||
/**
|
||||
* Looks up the effective value for a flattened interior widget by walking the
|
||||
* ancestor SubgraphNode chain (outermost → innermost) and returning the first
|
||||
* per-instance promoted-widget override that targets this exact widget object.
|
||||
*
|
||||
* Mirrors the read semantics used by the Vue / canvas render paths so that
|
||||
* prompt-build does not desync from the on-screen value.
|
||||
*/
|
||||
function resolvePromotedWidgetOverride(
|
||||
node: ExecutableLGraphNode,
|
||||
widget: IBaseWidget
|
||||
): { hit: true; value: unknown } | { hit: false } {
|
||||
if (!(node instanceof ExecutableNodeDTO)) return { hit: false }
|
||||
if (node.subgraphNodePath.length === 0) return { hit: false }
|
||||
|
||||
const rootGraph = node.graph.rootGraph
|
||||
const hosts = rootGraph.resolveSubgraphIdPath(node.subgraphNodePath)
|
||||
const promotionStore = usePromotionStore()
|
||||
const widgetStore = useWidgetValueStore()
|
||||
|
||||
for (const host of hosts) {
|
||||
const entries = promotionStore.getPromotionsRef(rootGraph.id, host.id)
|
||||
for (const entry of entries) {
|
||||
const resolved = resolveConcretePromotedWidget(
|
||||
host,
|
||||
entry.sourceNodeId,
|
||||
entry.sourceWidgetName,
|
||||
entry.disambiguatingSourceNodeId
|
||||
)
|
||||
if (resolved.status !== 'resolved') continue
|
||||
if (resolved.resolved.widget !== widget) continue
|
||||
|
||||
const storeName = makeCompositeKey([
|
||||
entry.sourceNodeId,
|
||||
entry.sourceWidgetName,
|
||||
entry.disambiguatingSourceNodeId ?? ''
|
||||
])
|
||||
const state = widgetStore.getWidget(rootGraph.id, host.id, storeName)
|
||||
if (state) return { hit: true, value: state.value }
|
||||
}
|
||||
}
|
||||
|
||||
return { hit: false }
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes the value used for prompt serialization for a single widget.
|
||||
* Falls back to the standard `widget.serializeValue` / `widget.value` path,
|
||||
* but routes through the per-instance promoted override when one applies. When a
|
||||
* custom `serializeValue` is defined, it is invoked on a proxy widget whose
|
||||
* `.value` returns the override, preserving widget-specific serialization.
|
||||
*/
|
||||
async function getExecutableWidgetValue(
|
||||
node: ExecutableLGraphNode,
|
||||
widget: IBaseWidget,
|
||||
index: number
|
||||
): Promise<unknown> {
|
||||
const override = resolvePromotedWidgetOverride(node, widget)
|
||||
|
||||
if (!override.hit) {
|
||||
return widget.serializeValue
|
||||
? await widget.serializeValue(node, index)
|
||||
: widget.value
|
||||
}
|
||||
|
||||
if (!widget.serializeValue) return override.value
|
||||
|
||||
const widgetProxy = Object.create(widget) as IBaseWidget
|
||||
Object.defineProperty(widgetProxy, 'value', {
|
||||
get: () => override.value,
|
||||
set: () => {},
|
||||
enumerable: true,
|
||||
configurable: true
|
||||
})
|
||||
return await widget.serializeValue.call(widgetProxy, node, index)
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts the current graph workflow for sending to the API.
|
||||
* @note Node widgets are updated before serialization to prepare queueing.
|
||||
@@ -99,9 +181,7 @@ export const graphToPrompt = async (
|
||||
for (const [i, widget] of widgets.entries()) {
|
||||
if (!widget.name || widget.options?.serialize === false) continue
|
||||
|
||||
const widgetValue = widget.serializeValue
|
||||
? await widget.serializeValue(node, i)
|
||||
: widget.value
|
||||
const widgetValue = await getExecutableWidgetValue(node, widget, i)
|
||||
// By default, Array values are reserved to represent node connections.
|
||||
// We need to wrap the array as an object to avoid the misinterpretation
|
||||
// of the array as a node connection.
|
||||
|
||||
@@ -1,11 +1,63 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { createTestSubgraph } from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
|
||||
import {
|
||||
createTestSubgraph,
|
||||
createTestSubgraphNode,
|
||||
resetSubgraphFixtureState
|
||||
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
|
||||
import { usePromotionStore } from '@/stores/promotionStore'
|
||||
|
||||
import { resolveNode } from './litegraphUtil'
|
||||
import { resolveNode, resolveNodeWidget } from './litegraphUtil'
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => ({})
|
||||
}))
|
||||
vi.mock('@/stores/domWidgetStore', () => ({
|
||||
useDomWidgetStore: () => ({
|
||||
widgetStates: new Map(),
|
||||
setPositionOverride: vi.fn(),
|
||||
clearPositionOverride: vi.fn()
|
||||
})
|
||||
}))
|
||||
vi.mock('@/services/litegraphService', () => ({
|
||||
useLitegraphService: () => ({ updatePreviews: () => ({}) })
|
||||
}))
|
||||
|
||||
function createPromotedWidgetFixture(hostId: number) {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'value', type: '*' }]
|
||||
})
|
||||
const inner = new LGraphNode('Inner')
|
||||
const input = inner.addInput('value', '*')
|
||||
inner.addWidget('text', 'value', 'a', () => {})
|
||||
input.widget = { name: 'value' }
|
||||
subgraph.add(inner)
|
||||
subgraph.inputNode.slots[0].connect(input, inner)
|
||||
|
||||
const host = createTestSubgraphNode(subgraph, { id: hostId })
|
||||
host._internalConfigureAfterSlots()
|
||||
host.graph!.add(host)
|
||||
|
||||
usePromotionStore().setPromotions(host.rootGraph.id, host.id, [
|
||||
{ sourceNodeId: String(inner.id), sourceWidgetName: 'value' }
|
||||
])
|
||||
|
||||
const promoted = host.widgets.find(isPromotedWidgetView)
|
||||
if (!promoted) throw new Error('Expected promoted widget view')
|
||||
|
||||
return { host, promoted }
|
||||
}
|
||||
|
||||
describe('resolveNode', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
resetSubgraphFixtureState()
|
||||
})
|
||||
|
||||
it('returns undefined when graph is null', () => {
|
||||
expect(resolveNode(1, null)).toBeUndefined()
|
||||
})
|
||||
@@ -67,4 +119,43 @@ describe('resolveNode', () => {
|
||||
const targetNode = sg2._nodes[0]
|
||||
expect(resolveNode(targetNode.id, rootGraph)).toBe(targetNode)
|
||||
})
|
||||
|
||||
it('resolves promoted widget by host node id and storeName', () => {
|
||||
const { host, promoted } = createPromotedWidgetFixture(501)
|
||||
|
||||
const [resolvedNode, resolvedWidget] = resolveNodeWidget(
|
||||
host.id,
|
||||
promoted.storeName,
|
||||
host.graph!
|
||||
)
|
||||
|
||||
expect(resolvedNode).toBe(host)
|
||||
expect(resolvedWidget).toBe(promoted)
|
||||
})
|
||||
|
||||
it('keeps legacy fallback for saved promoted widget source tuples', () => {
|
||||
const { host, promoted } = createPromotedWidgetFixture(502)
|
||||
|
||||
const [resolvedNode, resolvedWidget] = resolveNodeWidget(
|
||||
promoted.sourceNodeId,
|
||||
promoted.sourceWidgetName,
|
||||
host.graph!
|
||||
)
|
||||
|
||||
expect(resolvedNode).toBe(host)
|
||||
expect(resolvedWidget).toBe(promoted)
|
||||
})
|
||||
|
||||
it('keeps legacy fallback for saved promoted widget source tuples with numeric node ids', () => {
|
||||
const { host, promoted } = createPromotedWidgetFixture(503)
|
||||
|
||||
const [resolvedNode, resolvedWidget] = resolveNodeWidget(
|
||||
Number(promoted.sourceNodeId),
|
||||
promoted.sourceWidgetName,
|
||||
host.graph!
|
||||
)
|
||||
|
||||
expect(resolvedNode).toBe(host)
|
||||
expect(resolvedWidget).toBe(promoted)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -328,25 +328,43 @@ export function resolveNode(
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function getSelectedWidgetIdentity(
|
||||
node: Pick<LGraphNode, 'id'>,
|
||||
widget: IBaseWidget
|
||||
): [NodeId, string] {
|
||||
if (isPromotedWidgetView(widget)) return [node.id, widget.storeName]
|
||||
return [node.id, widget.name]
|
||||
}
|
||||
|
||||
export function resolveNodeWidget(
|
||||
nodeId: NodeId,
|
||||
widgetName?: string,
|
||||
graph: LGraph = app.rootGraph
|
||||
graph: LGraph | null | undefined = app.rootGraph
|
||||
): [LGraphNode, IBaseWidget] | [LGraphNode] | [] {
|
||||
if (!graph || typeof graph.getNodeById !== 'function') return []
|
||||
|
||||
const node = graph.getNodeById(nodeId)
|
||||
const normalizedSourceNodeId = String(nodeId)
|
||||
if (!widgetName) return node ? [node] : []
|
||||
|
||||
if (node) {
|
||||
const widget = node.widgets?.find((w) => w.name === widgetName)
|
||||
const widget = node.widgets?.find(
|
||||
(w) =>
|
||||
w.name === widgetName ||
|
||||
(isPromotedWidgetView(w) && w.storeName === widgetName)
|
||||
)
|
||||
return widget ? [node, widget] : []
|
||||
}
|
||||
|
||||
for (const node of graph.nodes) {
|
||||
if (!node.isSubgraphNode()) continue
|
||||
if (typeof node.isSubgraphNode !== 'function' || !node.isSubgraphNode())
|
||||
continue
|
||||
const widget = node.widgets?.find(
|
||||
(w) =>
|
||||
isPromotedWidgetView(w) &&
|
||||
w.sourceWidgetName === widgetName &&
|
||||
w.sourceNodeId === nodeId
|
||||
w.sourceNodeId === normalizedSourceNodeId
|
||||
)
|
||||
if (widget) return [node, widget]
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ const _useWorkflowPacks = () => {
|
||||
|
||||
// Check if node is a core node
|
||||
const nodeDef = nodeDefStore.nodeDefsByName[nodeName]
|
||||
if (nodeDef?.isCoreNode) {
|
||||
if (nodeDef?.nodeSource.type === 'core') {
|
||||
if (!systemStatsStore.systemStats) {
|
||||
await systemStatsStore.refetchSystemStats()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user