mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-18 01:38:03 +00:00
Compare commits
32 Commits
glary/remo
...
drjkl/subg
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba7a45239a | ||
|
|
b8bb67bcc7 | ||
|
|
9e7ea6a9d9 | ||
|
|
83b2104983 | ||
|
|
66c89c8e52 | ||
|
|
676a392187 | ||
|
|
4b2ae3fd84 | ||
|
|
dac3396de8 | ||
|
|
d253d87c92 | ||
|
|
4033dde983 | ||
|
|
61a444ed99 | ||
|
|
385a1d421d | ||
|
|
341fef46a9 | ||
|
|
24b548aebc | ||
|
|
6a33fd4a05 | ||
|
|
6ea278da30 | ||
|
|
e8272776aa | ||
|
|
0c0c3d9f2e | ||
|
|
7dd1b7da4b | ||
|
|
a9213760c8 | ||
|
|
ae506465c7 | ||
|
|
c27921f172 | ||
|
|
7f80f72a62 | ||
|
|
a0470f5116 | ||
|
|
519191b5f6 | ||
|
|
dbabf2de3c | ||
|
|
d6c9565622 | ||
|
|
d1fb94dc7d | ||
|
|
71cb98f7b3 | ||
|
|
559bc10b94 | ||
|
|
07c3b324ca | ||
|
|
2ab95698cf |
@@ -1,695 +0,0 @@
|
||||
---
|
||||
name: bug-dump-ingest
|
||||
description: 'Syncs the #bug-dump Slack channel into Linear as the system of record AND auto-fixes verified real bugs via red-green-fix. Every Linear operation (create, search, link, label) is performed by posting an @Linear mention in the bug-dump thread — no Linear MCP, no API key. Flow: fetch → mandatory dedupe gate (@Linear search + gh PR search) → false-defect verification → post @Linear create in thread (tool call) → parse bot card for FE-NNNN + URL → post :white_check_mark: confirmation reply → if candidate is a verified real bug with no dedupe hit and no open PR, invoke red-green-fix automatically to produce failing test + fix + PR. Respects team emoji scheme (:white_check_mark: ticket created, :pr-open: PR open, :question: needs context, :repeat: duplicate). Use when asked to sync #bug-dump to Linear, triage slack bugs, run a bug-dump sweep, or ingest bug reports. Triggers on: bug-dump, sync bug-dump, ingest bugs, triage slack bugs, bug sweep.'
|
||||
---
|
||||
|
||||
# Bug Dump Ingest
|
||||
|
||||
**Primary job: sync `#bug-dump` (Slack: `C0A4XMHANP3`) into Linear as the source of truth, then auto-fix the verified real bugs.** Linear is where status, labels, and follow-up triage happen — this skill gets every bug into Linear with enough context that a downstream agent or human can work from Linear alone. **Every Linear action is performed by mentioning `@Linear` in the bug-dump thread**; there is no Linear MCP and no API key path. When pre-flight verification confirms a candidate is a real bug (not dedupe, not already in a PR, not out of scope), the skill then invokes `red-green-fix` automatically.
|
||||
|
||||
```text
|
||||
fetch → pre-flight dedupe gate (@Linear search + gh) → verify false defects → present approvals
|
||||
→ POST "@Linear create ..." thread reply via slack_send_message (mandatory tool call)
|
||||
→ poll slack_read_thread → parse Linear bot card for FE-NNNN + URL
|
||||
→ POST :white_check_mark: confirmation thread reply via slack_send_message
|
||||
→ if verification = "real bug" AND no dedupe AND no open PR:
|
||||
invoke Skill(skill="red-green-fix") → POST :pr-open: thread reply
|
||||
```
|
||||
|
||||
### Non-negotiable rules
|
||||
|
||||
1. **Linear actions are Slack tool calls.** The skill MUST drive Linear by calling `mcp__plugin_slack_slack__slack_send_message` with `thread_ts` set and text that mentions `@Linear`. There is no MCP-direct path and no API-key path. Printing `@Linear create ...` into the Claude CLI response is NOT a substitute — the Slack thread reply is what triggers the Linear bot, and its card is the canonical receipt.
|
||||
2. **Dedupe is a gate, not a suggestion.** No candidate is proposed for creation until `@Linear search` AND `gh pr` search have been run and recorded. A hit short-circuits creation to `L` (link) or `pr-open`.
|
||||
3. **Auto-fix real bugs.** When the dedupe gate is clean AND false-defect verification is clean AND the candidate isn't on the handoff-exclusion list (see § Handoff conditions), after Linear creation the skill invokes `red-green-fix` via the `Skill` tool — without waiting for an extra human prompt.
|
||||
|
||||
### What the skill cannot do
|
||||
|
||||
The Slack MCP exposes no `reactions.add` tool, so the skill cannot put a `:white_check_mark:` reaction on the parent message. The thread reply with the leading `:white_check_mark:` emoji is the skill's canonical marker; a human can additionally add the parent reaction for channel visibility (see § Parent reaction — optional visibility nudge). Both are respected by Processed Detection.
|
||||
|
||||
## Team emoji scheme
|
||||
|
||||
| Emoji | Meaning | Who adds it | Skill behavior |
|
||||
| -------------------- | ------------------ | ------------------------------------------------------ | ---------------------------------------------- |
|
||||
| `:white_check_mark:` | Ticket created | Human on parent (after skill files); also in bot reply | Skip in future sweeps |
|
||||
| `:pr-open:` | PR open | Human | Skip creation; include PR link in approval row |
|
||||
| `:question:` | Needs more context | Human | Skip creation; agent may ask for clarification |
|
||||
| `:repeat:` | Duplicate | Human | Skip creation; link existing Linear issue |
|
||||
|
||||
## Design Priority
|
||||
|
||||
Optimize for **coverage, label quality, and proven fixes** over fix-path cleverness. Linear is the downstream triage surface — once every bug is there with status, labels, and context, agents and humans can work from Linear alone. A Linear ticket with a wrong severity is cheap to fix; a Slack-only bug is invisible to downstream tooling; a "filed but not fixed" real regression wastes a human turn that the skill could have spent on a red-green PR.
|
||||
|
||||
## Quick Start
|
||||
|
||||
1. **Scope** — default window: messages in the last 48h. Override with `--since YYYY-MM-DD` or a Slack permalink list.
|
||||
2. **Fetch** — `slack_read_channel` for `C0A4XMHANP3`; `slack_read_thread` per message with replies.
|
||||
3. **Filter** — drop already-processed (see Processed Detection).
|
||||
4. **Classify** — bug / discussion / meta (see Classification Rules).
|
||||
5. **Pre-flight dedupe gate (MANDATORY)** — for every bug candidate, run `@Linear search` AND `gh pr` search BEFORE proposing (see § Pre-flight Dedupe Gate). A hit means the candidate goes into the batch as `L` (link) or `pr-open`, not as a new create.
|
||||
6. **Verify false defects** — per candidate, run quick checks before proposing (see False-Defect Verification).
|
||||
7. **Extract** — normalize to ticket schema (see Ticket Schema).
|
||||
8. **Human approval** — batch table, collect Y/N/?/S/L/R per candidate (see Interactive Approval). Default recommendation for clean candidates is `Y` (file + auto-fix).
|
||||
9. **Post `@Linear create` thread reply — MANDATORY TOOL CALL** — for each approved `Y`/`L` row, call `mcp__plugin_slack_slack__slack_send_message` with `channel_id=C0A4XMHANP3`, `thread_ts=<parent-ts>`, and text starting with `@Linear create` (see § Linear Slack Bot Integration). Do NOT print the command into chat as a substitute.
|
||||
10. **Capture the Linear bot card** — poll `slack_read_thread` up to 3× with ~3s spacing, parse the first Linear-app reply for the `FE-NNNN` identifier and `https://linear.app/...` URL. No URL = not ingested; never fabricate one.
|
||||
11. **Post `:white_check_mark:` confirmation reply — MANDATORY TOOL CALL** — call `slack_send_message` again with text starting with `:white_check_mark: Filed to Linear: <URL>` so future sweeps can detect the marker via `has::white_check_mark: from:me`. Record both `ts` values in the session log.
|
||||
12. **Auto-fix (clean candidates only)** — if dedupe gate is clean AND false-defect verification is clean AND the candidate isn't on the Handoff-Exclusion list, immediately invoke the `red-green-fix` skill via the `Skill` tool. See § Fix Workflow for the exact call contract.
|
||||
13. **Log** — append to session log; update `processed.json`.
|
||||
|
||||
## System Context
|
||||
|
||||
| Item | Value |
|
||||
| ------------------ | -------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Source channel | `#bug-dump` (`C0A4XMHANP3`) |
|
||||
| Destination | Linear `Frontend Engineering` team, via the Linear Slack app (`@Linear`). Team is named in every `@Linear create` message. |
|
||||
| Default state | `Triage` — every `@Linear create` message includes `Status: Triage` |
|
||||
| State dir | `~/temp/bug-dump-ingest/` |
|
||||
| Processed registry | `~/temp/bug-dump-ingest/processed.json` |
|
||||
| Session log | `~/temp/bug-dump-ingest/session-YYYY-MM-DD.md` |
|
||||
| Drafts (failure) | `~/temp/bug-dump-ingest/drafts/*.md` — written only when `@Linear` never replies, so the human can retry manually |
|
||||
|
||||
## Label Taxonomy
|
||||
|
||||
Every created Linear issue MUST get the following labels, passed as a comma-separated list in the `Labels:` line of the `@Linear create` message. The Linear Slack app creates missing labels on first use:
|
||||
|
||||
| Label kind | Values | Source |
|
||||
| ------------ | ------------------------------------------------------------------------------ | ------------------------- |
|
||||
| `source:` | `source:bug-dump` | Always (marks Slack sync) |
|
||||
| `area:` | `area:ui`, `area:node-system`, `area:workflow`, `area:cloud`, `area:templates` | Area Heuristics |
|
||||
| `env:` | `env:cloud-prod`, `env:cloud-dev`, `env:local`, `env:electron` | Env Heuristics |
|
||||
| `severity:` | `sev:high`, `sev:medium`, `sev:low` | Severity Heuristics |
|
||||
| `reporter:` | `reporter:<slack-handle>` (kebab-case) | From message author |
|
||||
| Status flags | `needs-repro`, `needs-backend`, `regression`, `pr-open` | When applicable |
|
||||
|
||||
Label rules:
|
||||
|
||||
- Always include `source:bug-dump`, exactly one `area:`, at least one `env:` (or `env:unknown`), exactly one `severity:`, exactly one `reporter:`.
|
||||
- `needs-repro` — set when repro steps were ambiguous; signals "human should confirm before fix".
|
||||
- `needs-backend` — set when fix is clearly in ComfyUI backend, not this frontend repo.
|
||||
- `regression` — set when the bug mentions a version/upgrade correlation.
|
||||
- `pr-open` — set instead of creating a fresh ticket when a fix PR already exists; the Linear issue becomes a tracker.
|
||||
|
||||
Labels are the primary affordance for downstream triage — invest in getting them right, not just in the title.
|
||||
|
||||
## Processed Detection
|
||||
|
||||
A top-level message is considered already-handled (skip creation) if ANY of:
|
||||
|
||||
- Its timestamp appears in `processed.json`.
|
||||
- It carries a `:white_check_mark:` reaction on the parent — ticket already created.
|
||||
- It carries a `:pr-open:` reaction — fix PR is open; skill records the PR link in the session log rather than creating a fresh Linear issue.
|
||||
- It carries a `:repeat:` reaction — duplicate; skill attempts to find the original Linear issue and link it in the session log.
|
||||
- It carries a `:question:` reaction — needs more context; skill skips creation and records for follow-up.
|
||||
- Its thread contains a reply with a `https://linear.app/` URL (fetch via `slack_read_thread`).
|
||||
- Its thread contains a reply starting with `:white_check_mark:` from the skill's bot user.
|
||||
- It is a system/meta message (`has joined the channel`, bot-only message).
|
||||
- Its thread already contains resolution confirmation (`"solved"`, `"resolved"`, `:done:` reaction from the reporter) AND has no fix PR referenced — treat as "resolved without ticket, skip".
|
||||
|
||||
Never re-ingest a message already marked in any of the above ways.
|
||||
|
||||
Filter query for Slack search-based sweeps:
|
||||
|
||||
```text
|
||||
in:<#C0A4XMHANP3> -has::white_check_mark: -has::pr-open: -has::repeat: -has::question: after:YYYY-MM-DD
|
||||
```
|
||||
|
||||
## False-Defect Verification
|
||||
|
||||
Before a candidate hits the approval batch, run cheap checks to demote obvious non-bugs. Goal: keep the approval table high-signal. This is not a full repro — just fast heuristics that catch the top false-positive classes.
|
||||
|
||||
| Check | Command / Signal | Demote-to |
|
||||
| ---------------------------------------- | ---------------------------------------------------------------- | ---------- |
|
||||
| Reporter self-resolved in same msg | "no action needed", "solved", "nvm", "fixed it" | `resolved` |
|
||||
| Reporter self-resolved in thread | `slack_read_thread` → reporter's last reply contains "solved" | `resolved` |
|
||||
| Fix PR merged on main | `gh search prs "in:title <keyword>" --state merged --limit 3` | `fixed` |
|
||||
| Fix PR open (already-filed) | `gh search prs "<keyword>" --state open --limit 3` | `pr-open` |
|
||||
| Linear issue exists (open) | Linear `searchIssues` on title keywords → any open match | `dedupe` |
|
||||
| Behavior is documented / intended | grep `docs/` and `src/locales/en/*.json` for the feature | `expected` |
|
||||
| Not reproducible — feature doesn't exist | grep `src/` for mentioned component/feature → 0 hits | `stale` |
|
||||
| Env drift only (local setup issue) | Thread contains "my machine", "my setup", "proxy" without others | `env` |
|
||||
|
||||
For each demoted candidate, record the demotion reason in the approval table as `Verify: <tag>` so the human can override if they disagree. Never hard-skip based on verification alone — always show the row with the demotion.
|
||||
|
||||
### Recommended verify commands
|
||||
|
||||
```bash
|
||||
# 1. Search recent PRs for the feature in question
|
||||
gh search prs "<keyword>" --repo Comfy-Org/ComfyUI_frontend --limit 5
|
||||
|
||||
# 2. Grep for the feature / component mentioned
|
||||
rg -l "<ComponentOrFeatureName>" src/ apps/
|
||||
|
||||
# 3. Check if it's a known i18n / documented setting
|
||||
rg "<setting-key>" src/locales/en/ docs/
|
||||
```
|
||||
|
||||
Keep verification under ~30s per candidate. If it takes longer, propose a ticket and let the human decide — don't let verification become the bottleneck.
|
||||
|
||||
## Classification Rules
|
||||
|
||||
For each unprocessed top-level message, decide:
|
||||
|
||||
| Class | Signal | Action |
|
||||
| ----------------- | --------------------------------------------------------------------------------------------------------- | ---------------------------- |
|
||||
| **bug** | Describes unexpected behavior, visual glitch, error, regression, crash. Usually has repro steps or media. | Propose Linear ticket |
|
||||
| **discussion** | Design question, rollout thoughts, team chatter, PR planning (e.g. "how about we make a PR to do...") | Skip |
|
||||
| **question** | User asking if something is expected or known | Skip unless answered = bug |
|
||||
| **meta** | Channel joins, bot messages, cross-posts without content | Skip |
|
||||
| **already-filed** | Thread shows PR already open OR existing Linear link | Skip, log with existing link |
|
||||
|
||||
When ambiguous, default to **bug** and let the human decide in the approval batch.
|
||||
|
||||
## Ticket Schema
|
||||
|
||||
Normalize each bug to this shape before presenting:
|
||||
|
||||
```json
|
||||
{
|
||||
"slack_ts": "1776639963.837519",
|
||||
"slack_permalink": "https://comfy-organization.slack.com/archives/C0A4XMHANP3/p1776639963837519",
|
||||
"reporter": "Ali Ranjah (wavey)",
|
||||
"title": "Unet model dropdown missing selected model",
|
||||
"description": "Body with repro steps, env, attachments list, thread summary",
|
||||
"env": ["cloud prod"],
|
||||
"severity": "low | medium | high",
|
||||
"area": "ui | node-system | workflow | cloud | templates | unknown",
|
||||
"attachments": [{ "name": "...", "id": "F...", "type": "image/png" }],
|
||||
"thread_resolution": "solved | open | none"
|
||||
}
|
||||
```
|
||||
|
||||
Keep descriptions copy-paste friendly: lead with repro bullets, then env, then "See Slack: <permalink>". Attach thread summary only if it adds context beyond the top-level message.
|
||||
|
||||
### Severity Heuristics
|
||||
|
||||
- **high** — crash, data loss, blocks a template or core feature, affects paying users broadly (e.g. "job ends in 30m on Pro", "widget values reset").
|
||||
- **medium** — visible regression, template error, wrong pricing, broken UX on a common path.
|
||||
- **low** — cosmetic, single-template edge case, minor tooltip/boundary issue.
|
||||
|
||||
When unsure, mark `medium` and flag for human in the approval batch.
|
||||
|
||||
### Area Heuristics
|
||||
|
||||
- `ui` — visual glitches, palette issues, popover clipping, dropdown styling.
|
||||
- `node-system` — canvas perf, reroute, node drag, widget rendering, undo.
|
||||
- `workflow` — template failures, save/load, refresh regressions.
|
||||
- `cloud` — jobs, pricing, assets, auth, queue.
|
||||
- `templates` — specific template errors.
|
||||
|
||||
## Pre-flight Dedupe Gate (MANDATORY)
|
||||
|
||||
Before any candidate enters the approval table, run BOTH checks below and record the result in the row's `Dedup` and `PR` columns. This is a hard gate — no candidate may be proposed for creation without a verdict.
|
||||
|
||||
### Check 1 — Open Linear issues (via `@Linear search`)
|
||||
|
||||
Extract 3-5 keyword terms from the proposed title (strip stopwords). Post a search command to the bug-dump thread — use a scratch thread if no parent `ts` is available yet, but prefer the candidate's own parent thread so the search card becomes part of that thread's audit trail:
|
||||
|
||||
```text
|
||||
mcp__plugin_slack_slack__slack_send_message({
|
||||
channel_id: "C0A4XMHANP3",
|
||||
thread_ts: "<parent-ts>",
|
||||
text: "@Linear search <keyword-1> <keyword-2>\nTeam: Frontend Engineering\nStatus: open"
|
||||
})
|
||||
```
|
||||
|
||||
Poll `slack_read_thread` for up to 10s; parse the Linear app's card reply for `FE-NNNN` identifiers and URLs. Run the search twice with different keyword subsets if the first returns zero hits — reworded titles are the top false-negative class.
|
||||
|
||||
If `@Linear search` is not supported by the workspace's Linear app version, fall back to a Slack search for prior `@Linear` card replies in the channel:
|
||||
|
||||
```text
|
||||
mcp__plugin_slack_slack__slack_search_public({
|
||||
query: "in:<#C0A4XMHANP3> from:@Linear <keyword-1> <keyword-2>"
|
||||
})
|
||||
```
|
||||
|
||||
This scans past Linear bot replies in the channel — any reply containing a matching `FE-NNNN` URL is a candidate duplicate. Record which dedupe path was used in the session log.
|
||||
|
||||
Treat a hit as a duplicate if any of:
|
||||
|
||||
- Title overlap ≥ 80% (after lowercasing + stopword removal)
|
||||
- Same reporter + same component reference in description
|
||||
- Same stack trace or error code
|
||||
|
||||
**Verdict:** set `Dedup: FE-NNNN` and default recommendation to `L` (link, don't create). The human may still override to `Y` to file a separate ticket.
|
||||
|
||||
### Check 2 — Open or merged fix PRs on GitHub
|
||||
|
||||
```bash
|
||||
# Open PRs matching title keywords
|
||||
gh pr list --repo Comfy-Org/ComfyUI_frontend --state open \
|
||||
--search "<keyword-1> <keyword-2>" --limit 5 \
|
||||
--json number,title,url,createdAt
|
||||
|
||||
# Recent merged fixes (last 30d) — catches "already fixed, waiting to ship"
|
||||
gh pr list --repo Comfy-Org/ComfyUI_frontend --state merged \
|
||||
--search "<keyword-1> <keyword-2> merged:>=<YYYY-MM-DD>" --limit 5 \
|
||||
--json number,title,url,mergedAt
|
||||
```
|
||||
|
||||
Treat a hit as a match if the PR title/body mentions the same component or bug phrase and the PR is unmerged or merged within the window covering the reporter's observation.
|
||||
|
||||
**Verdict:**
|
||||
|
||||
- Open PR match → set `PR: #NNNN (open)`, recommendation `pr-open` (file Linear with `pr-open` label linking the PR, skip auto-fix).
|
||||
- Merged PR match → set `PR: #NNNN (merged)`, recommendation `fixed` (demote in verify, usually skip; human can override if the reporter claims the fix didn't land).
|
||||
|
||||
### Failure handling
|
||||
|
||||
If either check errors (Linear Slack app silent or not in channel, `gh` auth expired), DO NOT proceed to proposal — stop the sweep, report the failure to the user, and let them decide whether to re-run or manually dedupe. A silent skip of dedupe is never acceptable; it's the single biggest source of duplicate tickets.
|
||||
|
||||
Log each dedupe query + top hits in `~/temp/bug-dump-ingest/session-YYYY-MM-DD.md` under a per-candidate `Dedup trace:` block so the human can audit.
|
||||
|
||||
## Interactive Approval
|
||||
|
||||
Present candidates in batches of 5-10. Table format (10 columns):
|
||||
|
||||
```text
|
||||
# | Slack (author, time) | Proposed title | Env | Sev | Area | Dedup | PR | Verify | Rec
|
||||
----+------------------------+-----------------------------------------+------------+------+------------+------------+---------------+-------------+-----
|
||||
1 | wavey, 04-20 08:06 | Unet dropdown missing selected model | cloud prod | low | ui | - | - | resolved | N
|
||||
2 | Denys, 04-18 05:45 | Pro plan jobs end at 30 minutes | cloud prod | high | cloud | - | - | clean | Y
|
||||
3 | Terry Jia, 04-18 12:52 | Nodes 2.0 canvas lag on large workflows | - | high | node-system| FE-4521 | - | clean | L
|
||||
4 | Pablo, 04-17 08:52 | Multi-asset delete popup shows hashes | cloud prod | low | ui | - | #11402 (open) | clean | pr-open
|
||||
```
|
||||
|
||||
Each row MUST show: Slack author + date, proposed title, env tags, severity, area, **dedupe status from the Pre-flight Dedupe Gate**, **open/merged PR hit from the Pre-flight Dedupe Gate**, verify tag (from False-Defect Verification), and agent recommendation.
|
||||
|
||||
### Default recommendation logic
|
||||
|
||||
The skill computes `Rec` deterministically from the gate results:
|
||||
|
||||
- `L` — Dedupe hit on open Linear issue.
|
||||
- `pr-open` — Open GitHub PR hit.
|
||||
- `fixed` — Merged PR hit within the reporter's observation window.
|
||||
- `N` — Verify tag is `resolved`, `expected`, `stale`, or `env` only.
|
||||
- `?` — Repro incomplete or classification ambiguous.
|
||||
- `Y` — Everything clean AND candidate is not on the § Handoff-Exclusion list. This is the "file + auto-fix" path.
|
||||
- `Y (file-only)` — Clean but on the handoff-exclusion list (e.g. touches LGraphNode, needs backend). File Linear, skip auto-fix.
|
||||
|
||||
### Response format
|
||||
|
||||
- `Y` — default path: create Linear ticket, post `:white_check_mark:` thread reply, AND if the candidate is eligible (dedupe clean, verify clean, not on handoff-exclusion list), immediately invoke `red-green-fix` via the `Skill` tool. See § Fix Workflow.
|
||||
- `S` — **skip auto-fix** for this row: create Linear ticket + thread reply only, do NOT run red-green-fix. Use when the human knows a specific person is already investigating or wants to batch fixes.
|
||||
- `N` — skip entirely (log reason in session file).
|
||||
- `?` — mark as needs-context; skill posts a thread reply asking for repro details and prompts the human to add `:question:` to the parent.
|
||||
- `L` — link to existing Linear issue instead of creating (skill asks which one if the Pre-flight Dedupe Gate didn't return an exact match).
|
||||
- `R` — duplicate of another bug-dump message; skill links the two and prompts the human for `:repeat:` on the parent.
|
||||
- `E` — edit proposed title/description before creating (skill shows draft for inline tweaks).
|
||||
- Bulk responses accepted: `1 N, 2 Y, 3 L FE-4521, 4 pr-open #11402, 5 ?` — any row omitted from the response is treated as its computed `Rec` default.
|
||||
|
||||
Do not post any `@Linear create` messages until all candidates in the batch have a terminal decision. Auto-fix invocations run sequentially AFTER every `@Linear create` has produced a parsed `FE-NNNN`, so every `red-green-fix` call has a `Fixes FE-NNNN` to put in the PR body.
|
||||
|
||||
## Linear Slack Bot Integration (@Linear)
|
||||
|
||||
Every Linear action — create, search, link, label, status change — is performed by posting a message to the candidate's thread in `#bug-dump` that mentions `@Linear`. The Linear Slack app parses the mention and responds with a card in the same thread. There is no Linear MCP path and no `LINEAR_API_KEY` path; see `reference/linear-api.md` § "Why no direct API path" for the rationale.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- The Comfy Slack workspace already has the Linear Slack app installed (this is how humans add `@Linear` mentions today).
|
||||
- Channel `C0A4XMHANP3` is connected to the `Frontend Engineering` Linear team.
|
||||
- No per-machine setup. If a `@Linear` invocation produces no bot reply, the app is not in the channel — surface to the human, do NOT retry silently.
|
||||
|
||||
### Create an issue
|
||||
|
||||
For each approved `Y` candidate, call:
|
||||
|
||||
```text
|
||||
mcp__plugin_slack_slack__slack_send_message({
|
||||
channel_id: "C0A4XMHANP3",
|
||||
thread_ts: "<parent-ts>",
|
||||
text: "@Linear create\nTeam: Frontend Engineering\nTitle: <title>\nStatus: Triage\nLabels: source:bug-dump, area:<area>, env:<env>, sev:<severity>, reporter:<handle>\n\n<description>\n\nSource: <slack-permalink>"
|
||||
})
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- First line MUST be `@Linear create` — this is the command token.
|
||||
- `Team: Frontend Engineering` is required on every create — without it the bot falls back to the workspace default, which may route to a different team.
|
||||
- `Status: Triage` pins the initial state (per § System Context).
|
||||
- `Labels:` — comma-separated, full `source:bug-dump, area:*, env:*, sev:*, reporter:*` set per § Label Taxonomy. Missing labels are auto-created by the Linear Slack app on first use.
|
||||
- Description body is markdown — see `reference/linear-api.md` § "Description body template" and `reference/schema.md` for per-field extraction.
|
||||
- Use real newlines (not literal `\n`) when constructing the text.
|
||||
|
||||
After the tool call returns, poll `slack_read_thread` for the Linear app's reply card (up to 3× with ~3s spacing). Parse the card for:
|
||||
|
||||
- An `FE-NNNN` identifier
|
||||
- A `https://linear.app/<org>/issue/FE-NNNN` URL
|
||||
|
||||
The URL is the ingested receipt. The skill then posts the `:white_check_mark:` confirmation reply (§ Slack Thread Reply).
|
||||
|
||||
### Search (dedupe)
|
||||
|
||||
See § Pre-flight Dedupe Gate § Check 1 for the search command shape and handling of the bot's reply. The search is a tool call in the candidate's thread — not a chat aside.
|
||||
|
||||
### Link an existing issue (`L` response)
|
||||
|
||||
When the human picks `L FE-4521` for a row, do NOT post `@Linear create`. Instead:
|
||||
|
||||
```text
|
||||
mcp__plugin_slack_slack__slack_send_message({
|
||||
channel_id: "C0A4XMHANP3",
|
||||
thread_ts: "<parent-ts>",
|
||||
text: "@Linear link FE-4521"
|
||||
})
|
||||
```
|
||||
|
||||
The bot replies with the linked issue card. Then post the `:white_check_mark:` confirmation reply (adjusted to say `Linked to Linear:` rather than `Filed to Linear:`) so Processed Detection still matches.
|
||||
|
||||
### Label / status updates
|
||||
|
||||
When a later sweep needs to flip a ticket (e.g. a PR opened after initial ingest, so add `pr-open` and link):
|
||||
|
||||
```text
|
||||
mcp__plugin_slack_slack__slack_send_message({
|
||||
channel_id: "C0A4XMHANP3",
|
||||
thread_ts: "<parent-ts>",
|
||||
text: "@Linear FE-4521 add-labels pr-open"
|
||||
})
|
||||
```
|
||||
|
||||
Status changes are rarely driven by this skill directly — Linear auto-moves issues to `In Review` when a PR with `Fixes FE-NNNN` is opened, and the `red-green-fix` skill handles that PR body.
|
||||
|
||||
### Captured fields per create
|
||||
|
||||
Every successful create must produce, via the Linear bot's reply card:
|
||||
|
||||
- `identifier` — e.g. `FE-4710`, used in `Fixes <LIN-ID>` references and session log
|
||||
- `url` — `https://linear.app/.../issue/FE-4710`, included verbatim in the `:white_check_mark:` reply
|
||||
- `ts` of the Linear bot's card reply — recorded in session log for audit
|
||||
|
||||
If the card is missing the URL or identifier, fall through to the failure path below — do NOT fabricate either value.
|
||||
|
||||
### Failure path
|
||||
|
||||
If the Linear bot does not reply within the poll window, OR replies with a parse error (`couldn't parse`, `no team matched`, `failed`):
|
||||
|
||||
1. Write a draft markdown file to `~/temp/bug-dump-ingest/drafts/NN-short-slug.md` containing the full `@Linear create` text that was sent plus any partial bot reply.
|
||||
2. Post a thread reply that is explicit about the failure — do NOT include `:white_check_mark:` or a fake Linear URL:
|
||||
```text
|
||||
:warning: bug-dump-ingest: @Linear did not respond. Drafted at ~/temp/bug-dump-ingest/drafts/<slug>.md — please file manually and reply with the FE-NNNN.
|
||||
```
|
||||
3. Skip auto-fix for this candidate (no Linear ID = no `Fixes` reference).
|
||||
4. Log the failure in the session log.
|
||||
|
||||
Never invent a Linear URL. Never post `:white_check_mark: Filed to Linear: ...` without a real URL parsed from a real Linear bot card.
|
||||
|
||||
## Slack Thread Reply (Ingested Marker) — MANDATORY TOOL CALL
|
||||
|
||||
Every approved candidate produces **two** mandatory `slack_send_message` calls in the parent thread:
|
||||
|
||||
1. The `@Linear create` (or `@Linear link`) command — see § Linear Slack Bot Integration.
|
||||
2. The `:white_check_mark:` confirmation reply described below, posted after a real `FE-NNNN` + URL have been parsed from the Linear bot's card.
|
||||
|
||||
The second reply is what future sweeps grep for via `has::white_check_mark: from:me`. Even though the Linear bot's own card already contains the URL, the `:white_check_mark:` prefix is the canonical Processed Detection marker — without it, a future sweep may re-ingest the same bug.
|
||||
|
||||
The skill is not done with a candidate until BOTH calls have succeeded. If either fails, do not claim the candidate is ingested.
|
||||
|
||||
### Required call shape
|
||||
|
||||
```text
|
||||
mcp__plugin_slack_slack__slack_send_message({
|
||||
channel_id: "C0A4XMHANP3",
|
||||
thread_ts: "<parent-message-ts>", // dotted form, e.g. "1776714531.990509"
|
||||
text: ":white_check_mark: Filed to Linear: <LINEAR_URL>\nReporter: <@USER_ID>\nSev: <severity> • Area: <area>"
|
||||
})
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- `thread_ts` MUST be the parent message ts — never the channel ts, never omitted. An omitted `thread_ts` posts at channel level, which pollutes `#bug-dump` and breaks Processed Detection.
|
||||
- The text MUST start with `:white_check_mark:` followed by a space and `Filed to Linear:`. This exact prefix is what future sweeps grep for via `has::white_check_mark: from:me`.
|
||||
- The Linear URL MUST be present. No URL = not ingested; future sweeps will re-file the same bug.
|
||||
- Plain text only — no markdown tables, no bold, no code fences. Slack renders the emoji shortcode into a real `:white_check_mark:` only when the message is plain text.
|
||||
- Capture the returned `ts` and record it in the session log for audit.
|
||||
|
||||
### NEVER-do list (common failure mode)
|
||||
|
||||
- **Do NOT** print `@Linear create ...` or `:white_check_mark: Filed to Linear: <URL>` into the Claude CLI chat response as a substitute for calling `slack_send_message`. The CLI output is not seen by Slack. If you find yourself typing either into a plain assistant message, stop and issue the tool call instead.
|
||||
- **Do NOT** claim the thread reply was posted until the `slack_send_message` tool call has returned a success with a `ts`. If the tool call errors, surface the error and halt the batch — do not fabricate a reply.
|
||||
- **Do NOT** use any other tool (e.g. `slack_schedule_message`, `slack_send_message_draft`) as a substitute. Only an immediate `slack_send_message` with `thread_ts` set counts — the Linear Slack app does not trigger on scheduled/draft messages.
|
||||
- **Do NOT** substitute any direct Linear API call (MCP, GraphQL, curl) for the `@Linear` mention. The Slack thread is intentionally the single audit trail.
|
||||
|
||||
### Fix-path reply (after red-green-fix opens a PR)
|
||||
|
||||
When `red-green-fix` returns a PR URL for an auto-fixed candidate, the skill MUST post a second thread reply on the same parent — again via `slack_send_message`:
|
||||
|
||||
```text
|
||||
mcp__plugin_slack_slack__slack_send_message({
|
||||
channel_id: "C0A4XMHANP3",
|
||||
thread_ts: "<same parent ts>",
|
||||
text: ":pr-open: Fix PR: <PR_URL>\nRed-green verified: <unit|e2e> test proves the regression.\nFixes <LIN-ID>"
|
||||
})
|
||||
```
|
||||
|
||||
Same "tool call, not chat output" rule applies.
|
||||
|
||||
### Parent reaction — optional visibility nudge (not on critical path)
|
||||
|
||||
The Slack MCP does not expose `reactions.add`, so the skill cannot set a `:white_check_mark:` reaction on the parent. The thread reply above is sufficient for Processed Detection; the parent reaction is a human-only "visible in channel" nudge. At the end of the run, the skill MAY print a compact list for the human:
|
||||
|
||||
```text
|
||||
Optional: add :white_check_mark: to parent messages for in-channel visibility.
|
||||
LIN-4710 → <permalink>
|
||||
LIN-4711 → <permalink>
|
||||
```
|
||||
|
||||
This is a convenience, not a deliverable — a missing parent reaction does not cause re-ingestion.
|
||||
|
||||
## Fix Workflow (auto-invoke red-green-fix)
|
||||
|
||||
For every `Y` row whose `Rec` resolved to auto-fix (dedupe clean, verify clean, not on handoff-exclusion list), the skill MUST — after Linear creation and the `:white_check_mark:` thread reply — invoke the `red-green-fix` skill via the `Skill` tool. This is a real tool call, not a narrative handoff.
|
||||
|
||||
### Required Skill tool call
|
||||
|
||||
```text
|
||||
Skill({
|
||||
skill: "red-green-fix",
|
||||
args: "<composed prompt — see below>"
|
||||
})
|
||||
```
|
||||
|
||||
Compose `args` as a single self-contained prompt so the sub-invocation has everything it needs without re-reading the Linear issue:
|
||||
|
||||
```text
|
||||
Bug: <title>
|
||||
Linear: <LIN-ID> (<LINEAR_URL>)
|
||||
Source: Slack <permalink>
|
||||
Reporter: <display-name>
|
||||
Env: <env tags>
|
||||
Area: <area>
|
||||
Branch: fix/<lin-id-lowercase>-<short-slug>
|
||||
|
||||
Repro:
|
||||
1. <step>
|
||||
2. <step>
|
||||
|
||||
Expected: <expected behavior>
|
||||
Actual: <actual behavior>
|
||||
|
||||
Test layer (inferred from area):
|
||||
- ui → Vitest colocated + Playwright e2e tagged @regression
|
||||
- node-system → Playwright e2e primarily
|
||||
- workflow / templates → Playwright e2e
|
||||
- cloud → Vitest if client-side; otherwise STOP and label the Linear issue "needs-backend"
|
||||
|
||||
Test naming:
|
||||
- describe('<LIN-ID>: <one-line bug summary>', ...)
|
||||
- Playwright test title must include the LIN-ID.
|
||||
|
||||
PR body must include:
|
||||
- "Fixes <LIN-ID>"
|
||||
- "Source: Slack <permalink>"
|
||||
|
||||
Follow the red-green-fix two-commit sequence exactly. Do NOT skip the red commit.
|
||||
```
|
||||
|
||||
The skill MUST wait for `red-green-fix` to return before moving to the next candidate. Process one auto-fix at a time so branch state is deterministic.
|
||||
|
||||
### Verifying the invocation ran
|
||||
|
||||
After the `Skill` call returns, the skill MUST confirm at least one of:
|
||||
|
||||
1. A new git branch named `fix/<lin-id>-*` exists (`git branch --list "fix/<lin-id>-*"`).
|
||||
2. A PR URL is present in `red-green-fix`'s return payload.
|
||||
|
||||
If neither is true, the invocation silently no-op'd. Log the failure to the session log as `auto-fix skipped: invocation returned without branch or PR` and continue — do NOT post the `:pr-open:` thread reply.
|
||||
|
||||
### Inputs summary
|
||||
|
||||
- **Bug description** — the Linear description (includes repro, env, source permalink).
|
||||
- **Linear ID** — inserted into the PR body as `Fixes <LIN-ID>`.
|
||||
- **Branch name** — `fix/<lin-id>-<short-slug>` (e.g. `fix/lin-4711-pro-plan-30min-timeout`).
|
||||
- **Test layer** — inferred from `area`:
|
||||
- `ui` → unit (Vitest) + e2e (Playwright)
|
||||
- `node-system` → e2e primarily; unit if isolable
|
||||
- `workflow` / `templates` → e2e
|
||||
- `cloud` → unit if client-side logic, otherwise flag "backend — out of scope for this repo"
|
||||
|
||||
### Handoff-Exclusion list (do NOT auto-invoke red-green-fix)
|
||||
|
||||
These rows still get a Linear ticket + `:white_check_mark:` thread reply, but the skill MUST skip the `Skill(skill="red-green-fix")` call and instead post a thread nudge explaining why:
|
||||
|
||||
- Repro steps are incomplete (no clear numbered steps, no env) — reply in thread: "Need clearer repro before I can write a failing test. What's the shortest path to reproduce?"
|
||||
- Fix requires backend / ComfyUI repo changes (not frontend) — label Linear `needs-backend`.
|
||||
- Linear ticket was dedupe-linked rather than newly created — existing owner may already be fixing.
|
||||
- Severity is cosmetic AND reporter hasn't asked for a fix — file ticket only.
|
||||
- Fix would touch `LGraphNode`, `LGraphCanvas`, `LGraph`, or `Subgraph` god-objects (ADR-0003/0008 — always human decision).
|
||||
- Pre-flight Dedupe Gate found an open PR (`pr-open`) or a matching merged PR (`fixed`).
|
||||
|
||||
When a row is excluded, record the reason in the session log under `auto-fix excluded: <reason>`.
|
||||
|
||||
### Test authoring rules
|
||||
|
||||
Both tests MUST be written in the "red" commit BEFORE any fix code (per red-green-fix). Rules specific to bug-dump ingestion:
|
||||
|
||||
- **Unit test (Vitest)** — colocated next to the implementation, `<file>.test.ts`. Exercise the specific logic path reproduced by the reporter. One `describe` block named after the Linear ID:
|
||||
|
||||
```typescript
|
||||
// src/components/node/UnetDropdown.test.ts
|
||||
describe('LIN-4710: unet dropdown missing selected model', () => {
|
||||
it('includes the currently-selected model in the list even when not in available models', () => {
|
||||
// ...
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
- **E2E test (Playwright)** — under `browser_tests/tests/`, follow `writing-playwright-tests` skill. Tag with `@regression` and include the Linear ID in the test title:
|
||||
|
||||
```typescript
|
||||
test.describe(
|
||||
'LIN-4710 unet dropdown regression',
|
||||
{ tag: ['@regression'] },
|
||||
() => {
|
||||
test('keeps selected model visible in the dropdown', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// ...
|
||||
})
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
- **Mock data types** — follow `docs/guidance/playwright.md`: mock responses typed from `packages/ingest-types`, `packages/registry-types`, `src/schemas/` — never `as any`.
|
||||
|
||||
(The Handoff-Exclusion list above governs when `red-green-fix` is NOT invoked.)
|
||||
|
||||
### PR body template
|
||||
|
||||
The red-green-fix skill's PR template is extended with a `Source` line:
|
||||
|
||||
```markdown
|
||||
## Summary
|
||||
|
||||
<Root cause>
|
||||
|
||||
- Fixes LIN-NNN
|
||||
- Source: Slack <permalink>
|
||||
|
||||
## Red-Green Verification
|
||||
|
||||
| Commit | CI Status | Purpose |
|
||||
| ------------------------------------------ | -------------------- | ------------------------------- |
|
||||
| `test: LIN-NNN add failing test for <bug>` | :red_circle: Red | Proves the test catches the bug |
|
||||
| `fix: <bug summary>` | :green_circle: Green | Proves the fix resolves the bug |
|
||||
|
||||
## Test Plan
|
||||
|
||||
- [ ] Unit regression test passes locally
|
||||
- [ ] E2E regression test passes locally (if UI)
|
||||
- [ ] Manual repro no longer reproduces
|
||||
- [ ] Linear ticket linked
|
||||
```
|
||||
|
||||
After the PR merges, post the second thread reply on Slack (see Slack Thread Reply § Fix-path reply).
|
||||
|
||||
## Emoji Reaction Hints (read-only)
|
||||
|
||||
The agent cannot add reactions, but respects human-set reactions when filtering. The canonical team scheme (primary):
|
||||
|
||||
| Reaction | Meaning | Action |
|
||||
| -------------------- | ------------------ | -------------------------------------------------------- |
|
||||
| `:white_check_mark:` | Ticket created | Skip — already ingested |
|
||||
| `:pr-open:` | PR open | Skip creation; record PR link in session log |
|
||||
| `:question:` | Needs more context | Skip creation; agent may post a thread reply asking |
|
||||
| `:repeat:` | Duplicate | Skip creation; link existing Linear issue in session log |
|
||||
|
||||
Incidental reactions observed in the channel — treat as soft hints only, do NOT skip solely on these:
|
||||
|
||||
| Reaction | Meaning | Action |
|
||||
| -------- | ------------------- | -------------------------------------------------- |
|
||||
| `:eyes:` | Someone is triaging | Still ingestable |
|
||||
| `:done:` | Reporter resolved | Demote to `resolved` in verify, but still show row |
|
||||
| `:+1:` | Acknowledged | Ignore |
|
||||
|
||||
Approval-table response code `R` (new) corresponds to `:repeat:` — if you pick `R`, the skill treats it as duplicate and asks for the target Linear ID.
|
||||
|
||||
## Session Log
|
||||
|
||||
Append to `~/temp/bug-dump-ingest/session-YYYY-MM-DD.md`:
|
||||
|
||||
```text
|
||||
Bug Dump Ingest Session -- 2026-04-20 11:40 KST
|
||||
|
||||
Window: 2026-04-18 00:00 — 2026-04-20 12:00 KST
|
||||
Scanned: 28 top-level messages
|
||||
Skipped (meta/discussion/processed): 14
|
||||
Proposed: 14
|
||||
Approved: 11
|
||||
Created in Linear: 10
|
||||
Draft-only (creation failed): 1
|
||||
Linked-only (dedupe): 1
|
||||
Thread replies posted: 11
|
||||
|
||||
Created:
|
||||
- LIN-4710 Unet model dropdown missing selected model -- wavey -- low/ui
|
||||
- LIN-4711 Pro plan jobs end at 30 minutes -- Denys -- high/cloud
|
||||
- ...
|
||||
|
||||
Skipped with reason:
|
||||
- 1776592837.616399 -- design discussion in thread, not a bug
|
||||
- ...
|
||||
```
|
||||
|
||||
## Gotchas
|
||||
|
||||
### Thread summaries, not raw dumps
|
||||
|
||||
Pulling the full thread often adds noise. Summarize replies to: (a) confirmed reproductions by other users, (b) env/version details added in replies, (c) links to related PRs/commits. Drop emojis-only replies, joined-channel notifications, and off-topic chatter.
|
||||
|
||||
### Cross-posts are not bugs
|
||||
|
||||
When the top-level message is just a link to a Slack message in another channel (e.g. "X posting" with a URL and nothing else), follow the link to the original source and ingest from there — do NOT create a ticket from the cross-post itself.
|
||||
|
||||
### Resolved-in-thread messages
|
||||
|
||||
If the reporter replies `"No action needed, this is solved"` (see wavey 2026-04-20 08:06), mark the ticket for SKIP in the approval table, not auto-skip. The human may still want a regression test ticket.
|
||||
|
||||
### Permalinks
|
||||
|
||||
Construct Slack permalinks as:
|
||||
|
||||
```text
|
||||
https://comfy-organization.slack.com/archives/{CHANNEL_ID}/p{TS_WITH_DOT_REMOVED}
|
||||
```
|
||||
|
||||
E.g. `1776510375.473579` → `p1776510375473579`.
|
||||
|
||||
### Attachment handling
|
||||
|
||||
Slack file IDs (e.g. `F0AT...`) are private. Do NOT link them directly in Linear. Instead, list the filename and type in the Linear description and include the Slack permalink — anyone with Slack access can see the attachments from the thread.
|
||||
|
||||
### No auto-create without approval
|
||||
|
||||
Never create Linear issues without a human `Y`. This is a hard rule — the skill exists to reduce human toil, not to replace triage judgment.
|
||||
|
||||
## Reference Files
|
||||
|
||||
- `reference/linear-api.md` — `@Linear` Slack bot command reference (create, search, link, labels, status).
|
||||
- `reference/schema.md` — full ticket schema with field-by-field extraction notes.
|
||||
- `reference/examples.md` — worked examples drawn from real #bug-dump messages.
|
||||
- `reference/verify-commands.md` — cookbook of false-defect verification commands per bug class.
|
||||
|
||||
## Related Skills
|
||||
|
||||
- `red-green-fix` — auto-invoked via the `Skill` tool for every eligible `Y` candidate to produce a failing test + fix + PR with the red-green CI proof.
|
||||
- `writing-playwright-tests` — used by red-green-fix when an e2e test is needed.
|
||||
- `hardening-flaky-e2e-tests` — if the e2e test added in the fix PR starts flaking, jump to this skill.
|
||||
@@ -1,123 +0,0 @@
|
||||
# Worked Examples
|
||||
|
||||
Real #bug-dump messages (2026-04-17 → 2026-04-20) normalized through the skill.
|
||||
|
||||
## Example 1 — Clean bug with repro
|
||||
|
||||
**Source message** (wavey, 2026-04-20 08:06):
|
||||
|
||||
> unet model dropdown doesnt display all available models, think this is part of a larger issue with model dropdowns..
|
||||
>
|
||||
> • open flux.2 klein 4b image edit template
|
||||
> • open unet drop down --> notice selected model isnt present in the list, even though its selected
|
||||
> • execute (to check if it flags the model as missing) --> notice it still runs
|
||||
> No action needed, this is solved
|
||||
|
||||
**Thread resolution**: "No action needed, this is solved" — reporter resolved it in the same message.
|
||||
|
||||
**Classification**: bug, but `thread_resolution = solved`. Flag for human.
|
||||
|
||||
**Approval row**:
|
||||
|
||||
```text
|
||||
1 | wavey, 04-20 08:06 | Unet dropdown missing selected model | cloud | low | ui | N | N (reporter marked solved)
|
||||
```
|
||||
|
||||
Default recommendation: `N`. If human overrides to `Y`, file with a "Regression test" label so QA still tracks it.
|
||||
|
||||
---
|
||||
|
||||
## Example 2 — Clear high-severity cloud bug
|
||||
|
||||
**Source message** (Denys Puziak, 2026-04-18 05:45):
|
||||
|
||||
> I see two reports about jobs ending in 30 minutes while the user is on the Pro plan
|
||||
> cc @Hunter
|
||||
> https://discord.com/channels/.../1494078128971055145
|
||||
|
||||
**Classification**: bug, `env: [cloud prod]` (Pro plan = cloud), `severity: high` (paying users), `area: cloud`.
|
||||
|
||||
**Proposed title**: `Pro plan jobs end at 30 minutes`
|
||||
|
||||
**Description** (excerpt):
|
||||
|
||||
```markdown
|
||||
**Reporter:** Denys Puziak
|
||||
**Env:** cloud prod
|
||||
**Severity (proposed):** high
|
||||
**Area:** cloud
|
||||
|
||||
## Repro
|
||||
|
||||
1. User on Pro plan submits a job
|
||||
2. Job ends at 30 minutes instead of the Pro plan limit
|
||||
|
||||
## Notes
|
||||
|
||||
- Two user reports aggregated by Denys
|
||||
- cc'd @Hunter
|
||||
|
||||
## Source
|
||||
|
||||
Slack: <permalink>
|
||||
Discord thread: https://discord.com/channels/.../1494078128971055145
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Example 3 — Not a bug (discussion)
|
||||
|
||||
**Source message** (Christian Byrne, 2026-04-19 19:00):
|
||||
|
||||
> @Glary-Bot okay option A is clearly superior and I feel embarrassed I didn't see that line myself...
|
||||
|
||||
**Classification**: discussion (design review chatter). Skip. Log reason in session file.
|
||||
|
||||
---
|
||||
|
||||
## Example 4 — Meta-action / PR planning
|
||||
|
||||
**Source message** (Christian Byrne, 2026-04-19 09:30):
|
||||
|
||||
> @Glary-Bot how about we make a PR to do:
|
||||
>
|
||||
> 1. Audit the rest of the codebase...
|
||||
> 2. Create a helper in src/base...
|
||||
|
||||
**Classification**: discussion (PR-plan proposal). Skip.
|
||||
|
||||
---
|
||||
|
||||
## Example 5 — Performance regression
|
||||
|
||||
**Source message** (Terry Jia, 2026-04-18 12:52):
|
||||
|
||||
> With Nodes 2.0, large workflows (hundreds of nodes) make the canvas extremely laggy and unusable for actual work — switching tabs takes several seconds or more. Switching back to Litegraph, performance is significantly better.
|
||||
|
||||
**Classification**: bug, `area: node-system`, `severity: high`.
|
||||
|
||||
**Dedupe**: Post `@Linear search nodes 2.0 performance canvas lag` (Team: Frontend Engineering, Status: open) in the candidate's thread. Likely matches exist — flag `Dedup? ?` and ask human which ticket to link to.
|
||||
|
||||
---
|
||||
|
||||
## Example 6 — Reporter says it's a question, not a report
|
||||
|
||||
**Source message** (Luke, 2026-04-17 08:27):
|
||||
|
||||
> Is NodeInfo supposed to show information or docs about the node? It just brings up the node sidebar
|
||||
|
||||
**Classification**: question → ambiguous. Read thread. If replies confirm "that's unexpected, should show docs", upgrade to bug. If "yes that's intended", skip.
|
||||
|
||||
Default recommendation in the approval batch: `?` (needs expansion).
|
||||
|
||||
---
|
||||
|
||||
## Example 7 — Bug with PR already in flight
|
||||
|
||||
**Source message** (Pablo, 2026-04-17 08:52):
|
||||
|
||||
> when deleting multiple assets on cloud -> the confirmation popup still has the assets hashes as names instead of the display name
|
||||
|
||||
**Reaction**: `pr-open (1)` — someone's opened a PR.
|
||||
|
||||
**Classification**: `already-filed` branch. Skip creation; in the session log, note "PR already open". If the human wants a tracking Linear ticket anyway, still fileable with a link to the PR.
|
||||
@@ -1,160 +0,0 @@
|
||||
# Linear Slack Bot (@Linear) Reference
|
||||
|
||||
The skill drives Linear exclusively through the Linear Slack app (`@Linear`). **There is no Linear MCP, no `LINEAR_API_KEY`, no GraphQL.** Every Linear read/write happens as a Slack message that mentions `@Linear` in the `#bug-dump` thread, and the Linear Slack app performs the action and posts a reply card containing the issue URL.
|
||||
|
||||
## Why Slack-only
|
||||
|
||||
- The `#bug-dump` thread is already the source of truth; keeping the entire lifecycle (report → ticket → PR → resolution) in one thread means Processed Detection can grep the thread instead of a separate registry.
|
||||
- No API key rotation, no MCP server install, no OAuth browser flow — works on any machine that already has the Slack MCP configured.
|
||||
- The Linear Slack app's reply card (with issue URL, title, status, and assignee) IS the canonical receipt; the skill records its `ts` in the session log.
|
||||
|
||||
## Prerequisites (one-time, per workspace)
|
||||
|
||||
The Comfy Slack workspace must already have the Linear Slack app installed (it is — that's how humans use `@Linear` reactions today) and `#bug-dump` (channel `C0A4XMHANP3`) must have Linear enabled for the `Frontend Engineering` team. Nothing else to configure. If a `@Linear` invocation silently does nothing, the bot isn't present in the channel — surface that to the human rather than re-trying.
|
||||
|
||||
## Supported operations
|
||||
|
||||
Every operation is a `mcp__plugin_slack_slack__slack_send_message` call with `channel_id=C0A4XMHANP3` and `thread_ts=<parent-ts>`. The `text` is a natural-language instruction to the Linear bot. Keep the text concise — Linear parses the first line as the command intent.
|
||||
|
||||
### 1. Create an issue from the thread
|
||||
|
||||
```text
|
||||
mcp__plugin_slack_slack__slack_send_message({
|
||||
channel_id: "C0A4XMHANP3",
|
||||
thread_ts: "<parent-ts>",
|
||||
text: "@Linear create\nTeam: Frontend Engineering\nTitle: <title>\nStatus: Triage\nLabels: source:bug-dump, area:<area>, env:<env>, sev:<severity>, reporter:<handle>\n\n<description body>\n\nSource: <slack-permalink>"
|
||||
})
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- Start with `@Linear create` on its own line — this is the command token the bot keys on.
|
||||
- Always specify `Team: Frontend Engineering`. Without it, the bot falls back to the Slack workspace's default team, which may not be FE.
|
||||
- `Status: Triage` pins the initial workflow state.
|
||||
- `Labels:` — comma-separated. If a label doesn't exist yet in Linear, the bot creates it on first use (verified in Linear workspace settings). Keep the taxonomy exactly as SKILL.md § Label Taxonomy.
|
||||
- `<description body>` — markdown per `reference/schema.md` Description Template. Use real newlines, not literal `\n`.
|
||||
- End with `Source: <slack-permalink>` so the Linear issue body links back even if the auto-attachment of the parent message fails.
|
||||
|
||||
The Linear bot replies in the same thread with a card that contains:
|
||||
|
||||
- The Linear URL (`https://linear.app/comfy-org/issue/FE-NNNN`)
|
||||
- Status, assignee (initially unassigned), and applied labels
|
||||
- A "View in Linear" button
|
||||
|
||||
Parse the URL out of the bot's reply text (or attachments). If no card reply appears within ~10s of polling `slack_read_thread`, treat it as a creation failure — do NOT proceed to the `:white_check_mark:` confirmation reply.
|
||||
|
||||
### 2. Search existing open issues (dedupe)
|
||||
|
||||
```text
|
||||
mcp__plugin_slack_slack__slack_send_message({
|
||||
channel_id: "C0A4XMHANP3",
|
||||
thread_ts: "<parent-ts>",
|
||||
text: "@Linear search <keyword-1> <keyword-2>\nTeam: Frontend Engineering\nStatus: open"
|
||||
})
|
||||
```
|
||||
|
||||
The bot replies with a card listing up to ~5 matching open issues. Parse identifier (`FE-NNNN`) and URL per row. Treat a hit as a duplicate per SKILL.md § Pre-flight Dedupe Gate § Check 1.
|
||||
|
||||
If `@Linear search` is not supported in the installed Slack app version, fall back to Slack-native search across the `#bug-dump` thread replies (previous `@Linear` cards contain title + URL — grep those for the same keywords). Record which path was used in the session log so the human can see dedupe coverage.
|
||||
|
||||
### 3. Link an existing issue (dedupe: `L` response)
|
||||
|
||||
```text
|
||||
mcp__plugin_slack_slack__slack_send_message({
|
||||
channel_id: "C0A4XMHANP3",
|
||||
thread_ts: "<parent-ts>",
|
||||
text: "@Linear link FE-4521"
|
||||
})
|
||||
```
|
||||
|
||||
The bot replies with the linked issue card. The skill then posts its own `:white_check_mark: Linked to Linear: <URL>` confirmation reply (see SKILL.md § Slack Thread Reply).
|
||||
|
||||
### 4. Add labels to an existing issue
|
||||
|
||||
```text
|
||||
mcp__plugin_slack_slack__slack_send_message({
|
||||
channel_id: "C0A4XMHANP3",
|
||||
thread_ts: "<parent-ts>",
|
||||
text: "@Linear FE-4521 add-labels pr-open"
|
||||
})
|
||||
```
|
||||
|
||||
Used when an open PR is discovered after ticket creation and the Linear issue should flip to `pr-open`.
|
||||
|
||||
### 5. Change status
|
||||
|
||||
```text
|
||||
mcp__plugin_slack_slack__slack_send_message({
|
||||
channel_id: "C0A4XMHANP3",
|
||||
thread_ts: "<parent-ts>",
|
||||
text: "@Linear FE-4521 status In Progress"
|
||||
})
|
||||
```
|
||||
|
||||
Rarely used by the skill directly — usually status changes come from the `red-green-fix` PR lifecycle (Linear auto-moves to `In Review` when a PR references `Fixes FE-4521`).
|
||||
|
||||
## Description body template
|
||||
|
||||
The text that follows the command headers is rendered verbatim as the Linear issue description (markdown). Use this template — see `reference/schema.md` for field-by-field extraction notes:
|
||||
|
||||
```markdown
|
||||
**Reporter:** <slack-display-name>
|
||||
**Env:** cloud prod / local / electron / ...
|
||||
**Severity (proposed):** high/medium/low
|
||||
**Area:** ui / node-system / workflow / cloud / templates
|
||||
|
||||
## Repro
|
||||
|
||||
1. ...
|
||||
2. ...
|
||||
|
||||
## Expected
|
||||
|
||||
...
|
||||
|
||||
## Actual
|
||||
|
||||
...
|
||||
|
||||
## Attachments (in Slack thread)
|
||||
|
||||
- image.png (png, 315 KB)
|
||||
- Screen Recording.mov (mov, 37 MB)
|
||||
|
||||
## Source
|
||||
|
||||
Slack: <permalink>
|
||||
Thread summary: <1-3 bullets if thread adds context>
|
||||
```
|
||||
|
||||
The Slack permalink is load-bearing — it's the canonical route to attachments, reporter, and any follow-up discussion. Do NOT embed Slack file IDs (`F0AT...`) directly; they're permissioned.
|
||||
|
||||
## Parsing the bot's reply
|
||||
|
||||
After each `slack_send_message` that mentions `@Linear`, poll `slack_read_thread` (with `channel_id=C0A4XMHANP3`, `thread_ts=<parent-ts>`) up to 3 times, ~3s apart. Scan replies authored by the Linear Slack app user for:
|
||||
|
||||
- Any `https://linear.app/<org>/issue/FE-\d+` URL → capture as the issue URL.
|
||||
- The `FE-NNNN` identifier pattern → capture as the issue identifier.
|
||||
- An error phrase (`couldn't`, `failed`, `not found`, `no team matched`) → treat as failure; surface the full bot text to the human.
|
||||
|
||||
Record the bot reply's `ts` alongside the captured URL and identifier in the session log.
|
||||
|
||||
## Failure modes & handling
|
||||
|
||||
| Symptom | Likely cause | Handling |
|
||||
| ------------------------------------------------- | -------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| No bot reply within 10s | Linear app not in channel, or bot outage | Halt the batch, surface to human, do NOT fabricate a Linear URL. Remaining approved candidates stay queued for re-run. |
|
||||
| Bot replies with "no team matched" | Team name typo or Linear workspace drift | Re-send with the exact team name from the Linear workspace (default: `Frontend Engineering`). If it still fails, ask the human to verify. |
|
||||
| Bot replies with "couldn't parse labels" | One of the labels has syntax the bot rejects | Drop the offending label, re-send; log the partial-label failure so the human can patch after. |
|
||||
| Bot creates the issue but reply lacks the URL | Rare bot format change | Re-fetch the thread after ~5s; if URL still absent, open Linear search via `@Linear search <title>` and recover the identifier + URL. |
|
||||
| Multiple `@Linear` replies match (duplicate card) | The skill retried without polling first | Keep the earliest card's URL; log the extras. Never re-issue `@Linear create` for the same candidate without confirming the first card failed. |
|
||||
|
||||
Never retry `@Linear create` without first running `@Linear search` for the same title keywords — a duplicate card is worse than an initial failure because the human has to close one of them manually.
|
||||
|
||||
## Why no direct API path
|
||||
|
||||
- The Linear MCP (official or community) would require either OAuth setup or `LINEAR_API_KEY` in env — both are per-machine hurdles the skill should not depend on.
|
||||
- Direct GraphQL against `api.linear.app` has the same key-management cost and bypasses the Slack thread as the audit trail.
|
||||
- Routing every action through `@Linear` in the thread gives humans full visibility in the channel (the bot's card is the receipt) and Processed Detection becomes a simple Slack thread read.
|
||||
|
||||
If a future need requires capabilities the `@Linear` Slack app doesn't expose (bulk operations, private field edits, webhooks), stop and surface the limitation to the human rather than quietly adding an API-key path — the "Slack-only" constraint is intentional.
|
||||
@@ -1,94 +0,0 @@
|
||||
# Ticket Schema — Extraction Notes
|
||||
|
||||
Field-by-field guidance for normalizing a Slack #bug-dump message into a ticket.
|
||||
|
||||
## `slack_ts`
|
||||
|
||||
The top-level message timestamp from `slack_read_channel` response (`Message TS:` field). Always store the dotted form (`1776510375.473579`). This is the ingestion identity used in `processed.json`.
|
||||
|
||||
## `slack_permalink`
|
||||
|
||||
Construct:
|
||||
|
||||
```text
|
||||
https://comfy-organization.slack.com/archives/C0A4XMHANP3/p<ts-without-dot>
|
||||
```
|
||||
|
||||
Example: `1776510375.473579` → `.../p1776510375473579`.
|
||||
|
||||
## `reporter`
|
||||
|
||||
The display name + parenthetical nickname if present. Examples from the channel:
|
||||
|
||||
- `Ali Ranjah (wavey)`
|
||||
- `Denys Puziak`
|
||||
- `Christian Byrne`
|
||||
|
||||
Do NOT use the Slack user ID (`U087MJCDHHC`) in Linear — names are more readable.
|
||||
|
||||
## `title`
|
||||
|
||||
Rules:
|
||||
|
||||
- Start with a verb or noun phrase describing the observed defect, not the reporter.
|
||||
- ≤ 80 chars.
|
||||
- Include env qualifier ("cloud prod", "local dev", "electron") only if ambiguous.
|
||||
- Strip emoji and reactions from the original message when extracting.
|
||||
|
||||
Transformations:
|
||||
|
||||
| Slack message (excerpt) | Title |
|
||||
| ----------------------------------------------------------------------- | --------------------------------------------------- |
|
||||
| "unet model dropdown doesnt display all available models..." | Unet dropdown missing selected model |
|
||||
| "Dates are broken on Settings -> Secrets. Cloud Prod" | Settings → Secrets dates broken on cloud prod |
|
||||
| "LTX-2: Audio to VIdeo template results in the "RuntimeError..." error" | LTX-2 Audio-to-Video template RuntimeError on cloud |
|
||||
|
||||
## `description`
|
||||
|
||||
Structure — see `linear-api.md` § "Description body template". Key rules:
|
||||
|
||||
- Lead with **Repro** numbered list. Extract from the message body; if no steps are given, write "Repro: [Slack message body quoted verbatim]" and flag for human in approval.
|
||||
- Preserve the reporter's own words in the Repro section when they include "step 1 / step 2" markers.
|
||||
- Collapse multi-paragraph asides into "Notes" at the end.
|
||||
|
||||
## `env`
|
||||
|
||||
Detect from message text using these terms:
|
||||
|
||||
| Text in message | Tag |
|
||||
| -------------------------- | ---------------------- |
|
||||
| `cloud prod`, `prod cloud` | `cloud prod` |
|
||||
| `cloud dev` | `cloud dev` |
|
||||
| `cloud` | `cloud` (unqual.) |
|
||||
| `local`, `localhost` | `local` |
|
||||
| `electron`, `desktop` | `electron` |
|
||||
| `nodes 2.0`, `LG` | (feature tag, not env) |
|
||||
|
||||
A message can have multiple env tags. If none are detectable, set `env: []` and flag "env unclear" in the approval row.
|
||||
|
||||
## `severity`
|
||||
|
||||
Heuristics in SKILL.md. When uncertain, mark `medium` and note in approval table: `Sev: medium (flag)`.
|
||||
|
||||
## `area`
|
||||
|
||||
Single tag. Use the one that best fits; tiebreak toward the more actionable team:
|
||||
|
||||
- `cloud` > `workflow` when the reported behavior is specific to cloud-hosted features (billing, queue, jobs)
|
||||
- `node-system` > `ui` when the defect is canvas interaction, not just visual
|
||||
- `templates` only when a named template is the subject
|
||||
|
||||
## `attachments`
|
||||
|
||||
From `slack_read_channel` message `Files:` field. Parse name, ID, type. Never include the Slack file ID in the Linear description — those are permissioned — just the filename and type.
|
||||
|
||||
## `thread_resolution`
|
||||
|
||||
Fetch via `slack_read_thread`. Scan replies for:
|
||||
|
||||
- `solved`, `resolved`, `fixed`, `no action needed` → `solved`
|
||||
- A `:done:` reaction from the reporter → `solved`
|
||||
- A `https://github.com/Comfy-Org/ComfyUI_frontend/pull/` URL in a reply → `pr-open` (keep but note in description)
|
||||
- Otherwise → `open`
|
||||
|
||||
If `solved` and no PR merged, flag in approval table: reporter marked solved — confirm before filing.
|
||||
@@ -1,99 +0,0 @@
|
||||
# Verify Commands Cookbook
|
||||
|
||||
One-shot commands for each False-Defect Verification class. Keep each under ~30s.
|
||||
|
||||
## 1. Check for existing fix PR
|
||||
|
||||
```bash
|
||||
# By keyword in title
|
||||
gh search prs --repo Comfy-Org/ComfyUI_frontend "<keyword>" --state merged --limit 5
|
||||
|
||||
# By keyword in body
|
||||
gh pr list --repo Comfy-Org/ComfyUI_frontend --search "<keyword>" --state all --limit 5
|
||||
|
||||
# Recent closing PRs near the reported date
|
||||
gh pr list --repo Comfy-Org/ComfyUI_frontend --state merged \
|
||||
--search "merged:>=<YYYY-MM-DD> <keyword>" --limit 10
|
||||
```
|
||||
|
||||
Verify tag: `fixed` if a merged PR explicitly matches; `pr-open` if an open PR matches.
|
||||
|
||||
## 2. Check for existing open Linear issue
|
||||
|
||||
```text
|
||||
# Primary: @Linear search in the candidate's bug-dump thread
|
||||
# mcp__plugin_slack_slack__slack_send_message({
|
||||
# channel_id: "C0A4XMHANP3",
|
||||
# thread_ts: "<parent-ts>",
|
||||
# text: "@Linear search <keyword-1> <keyword-2>\nTeam: Frontend Engineering\nStatus: open"
|
||||
# })
|
||||
# → poll slack_read_thread, parse the Linear app's reply card for FE-NNNN matches.
|
||||
#
|
||||
# Fallback: grep past @Linear bot replies in the channel for prior ingested titles
|
||||
# mcp__plugin_slack_slack__slack_search_public({
|
||||
# query: "in:<#C0A4XMHANP3> from:@Linear <keyword-1> <keyword-2>"
|
||||
# })
|
||||
```
|
||||
|
||||
Verify tag: `dedupe` with the `FE-NNNN` identifier in the approval row. See `reference/linear-api.md` § "Search existing open issues (dedupe)" for full handling.
|
||||
|
||||
## 3. Feature actually exists in codebase
|
||||
|
||||
```bash
|
||||
# Find the component / feature mentioned
|
||||
rg -l "<ComponentOrFeatureName>" src/ apps/ --type vue --type ts
|
||||
|
||||
# Find a setting key
|
||||
rg "<setting-key>" src/locales/en/ src/stores/settingStore.ts
|
||||
|
||||
# Find a store action
|
||||
rg "<actionName>" src/stores/ --type ts
|
||||
```
|
||||
|
||||
Verify tag: `stale` if 0 hits AND the feature name is specific (not a generic word).
|
||||
|
||||
## 4. Intended behavior check
|
||||
|
||||
```bash
|
||||
# Check docs and release notes
|
||||
rg -l "<feature keyword>" docs/ CHANGELOG.md
|
||||
|
||||
# Check if behavior is asserted in an existing test (green today)
|
||||
rg "<observed behavior>" src/**/*.test.ts browser_tests/
|
||||
```
|
||||
|
||||
Verify tag: `expected` if docs describe this as the intended behavior, or a test asserts it.
|
||||
|
||||
## 5. Reporter self-resolution
|
||||
|
||||
Already gathered via `slack_read_thread`. Look for reporter's own replies containing:
|
||||
|
||||
- "solved", "resolved", "fixed", "no action needed", "nvm", "my bad"
|
||||
- A `:done:` reaction from the reporter
|
||||
- A `:white_check_mark:` reaction
|
||||
|
||||
Verify tag: `resolved`.
|
||||
|
||||
## 6. Env-specific / local setup
|
||||
|
||||
If the message mentions "my machine", "my proxy", "my docker", "my cache" AND no other reporter has confirmed in-thread:
|
||||
|
||||
```bash
|
||||
# Check thread for cross-user confirmations
|
||||
# slack_read_thread → count distinct users replying with "same", "repro'd", "+1"
|
||||
```
|
||||
|
||||
Verify tag: `env` if only the reporter is affected.
|
||||
|
||||
## 7. Cross-post (X posting)
|
||||
|
||||
If the top-level message is just a link + "X posting":
|
||||
|
||||
```bash
|
||||
# Follow the link — use slack_search_public to find the original thread
|
||||
# slack_search_public({ query: "<in:channel from:@reporter> <before:date>" })
|
||||
```
|
||||
|
||||
If the original is already ingestable, ingest from the original's permalink. If it's a GitHub issue, prefer linking that GitHub issue to the Linear ticket instead of creating two entries.
|
||||
|
||||
Verify tag: `cross-post` with the resolved source permalink.
|
||||
@@ -76,7 +76,7 @@ const executeTask = async (task: MaintenanceTask) => {
|
||||
|
||||
message = t('maintenance.error.taskFailed')
|
||||
} catch (error) {
|
||||
message = (error as Error)?.message
|
||||
message = error instanceof Error ? error.message : undefined
|
||||
}
|
||||
|
||||
toast.add({
|
||||
|
||||
@@ -66,7 +66,7 @@ class MaintenanceTaskRunner {
|
||||
this.error = undefined
|
||||
return true
|
||||
} catch (error) {
|
||||
this.error = (error as Error)?.message
|
||||
this.error = error instanceof Error ? error.message : String(error)
|
||||
throw error
|
||||
} finally {
|
||||
this.executing = false
|
||||
|
||||
@@ -88,7 +88,7 @@ const contactColumn = {
|
||||
{ label: t('footer.sales', locale), href: routes.contact },
|
||||
{
|
||||
label: t('footer.support', locale),
|
||||
href: externalLinks.discord,
|
||||
href: externalLinks.support,
|
||||
external: true
|
||||
},
|
||||
{ label: t('footer.press', locale), href: 'mailto:press@comfy.org' }
|
||||
|
||||
@@ -106,6 +106,11 @@ function onNavKeydown(event: KeyboardEvent) {
|
||||
navButtons()?.[next]?.focus({ preventScroll: true })
|
||||
}
|
||||
|
||||
function onCategoryHover(index: number) {
|
||||
if (isEnabled.value) return
|
||||
activeCategory.value = index
|
||||
}
|
||||
|
||||
function travelRange(el: HTMLElement) {
|
||||
if (window.matchMedia('(min-width: 1024px)').matches) return 150
|
||||
|
||||
@@ -116,31 +121,29 @@ function travelRange(el: HTMLElement) {
|
||||
}
|
||||
|
||||
const pinScrubEnd = `+=${categories.length * VH_PER_ITEM}%`
|
||||
const parallaxMediaQuery = '(max-width: 1023px)'
|
||||
useParallax([rightImgRef], {
|
||||
trigger: sectionRef,
|
||||
fromY: (el) => -travelRange(el),
|
||||
y: (el) => travelRange(el),
|
||||
start: 'top top',
|
||||
end: pinScrubEnd
|
||||
end: pinScrubEnd,
|
||||
mediaQuery: parallaxMediaQuery
|
||||
})
|
||||
useParallax([leftImgRef], {
|
||||
trigger: sectionRef,
|
||||
fromY: (el) => travelRange(el),
|
||||
y: (el) => -travelRange(el),
|
||||
start: 'top top',
|
||||
end: pinScrubEnd
|
||||
end: pinScrubEnd,
|
||||
mediaQuery: parallaxMediaQuery
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
ref="sectionRef"
|
||||
:class="
|
||||
cn(
|
||||
'bg-primary-comfy-ink relative isolate overflow-x-clip pt-20 lg:py-24',
|
||||
isEnabled && 'lg:h-[calc(100vh+60px)]'
|
||||
)
|
||||
"
|
||||
class="bg-primary-comfy-ink relative isolate overflow-x-clip pt-20 lg:h-[calc(100vh+60px)] lg:py-24"
|
||||
>
|
||||
<svg class="absolute size-0" width="0" height="0" aria-hidden="true">
|
||||
<defs>
|
||||
@@ -202,6 +205,8 @@ useParallax([leftImgRef], {
|
||||
"
|
||||
:aria-current="index === activeCategory ? 'true' : undefined"
|
||||
@click="scrollToIndex(index)"
|
||||
@mouseenter="onCategoryHover(index)"
|
||||
@focus="onCategoryHover(index)"
|
||||
>
|
||||
{{ category.label }}
|
||||
</button>
|
||||
|
||||
@@ -20,6 +20,9 @@ interface PinScrubOptions {
|
||||
/** Viewport-height percentage each category occupies in the scroll distance. */
|
||||
export const VH_PER_ITEM = 20
|
||||
|
||||
/** Pin/scrub is mobile-only — desktop uses hover-based category switching. */
|
||||
const PIN_SCRUB_MEDIA_QUERY = '(max-width: 1023px)'
|
||||
|
||||
function interpolateY(
|
||||
index: number,
|
||||
buttonCenters: number[],
|
||||
@@ -66,7 +69,8 @@ export function usePinScrub(refs: PinScrubRefs, options: PinScrubOptions) {
|
||||
!refs.section.value ||
|
||||
!refs.content.value ||
|
||||
!refs.nav.value ||
|
||||
prefersReducedMotion()
|
||||
prefersReducedMotion() ||
|
||||
!window.matchMedia(PIN_SCRUB_MEDIA_QUERY).matches
|
||||
)
|
||||
return
|
||||
const section: HTMLElement = refs.section.value
|
||||
|
||||
@@ -35,6 +35,7 @@ export const externalLinks = {
|
||||
docsApi: 'https://docs.comfy.org/api-reference/cloud',
|
||||
github: 'https://github.com/Comfy-Org/ComfyUI',
|
||||
platform: 'https://platform.comfy.org',
|
||||
support: 'https://support.comfy.org/hc/en-us',
|
||||
workflows: 'https://comfy.org/workflows',
|
||||
youtube: 'https://www.youtube.com/@ComfyOrg'
|
||||
} as const
|
||||
|
||||
@@ -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 {
|
||||
|
||||
34
browser_tests/fixtures/utils/groupHelpers.ts
Normal file
34
browser_tests/fixtures/utils/groupHelpers.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
const GROUP_TITLE_CLICK_OFFSET_X = 50
|
||||
const GROUP_TITLE_CLICK_OFFSET_Y = 15
|
||||
|
||||
/**
|
||||
* Returns the client-space position of a group's title bar (for clicking).
|
||||
*/
|
||||
export async function getGroupTitlePosition(
|
||||
comfyPage: ComfyPage,
|
||||
title: string
|
||||
): Promise<{ x: number; y: number }> {
|
||||
const pos = await comfyPage.page.evaluate(
|
||||
({ title, offsetX, offsetY }) => {
|
||||
const app = window.app!
|
||||
const group = app.graph.groups.find(
|
||||
(g: { title: string }) => g.title === title
|
||||
)
|
||||
if (!group) return null
|
||||
const clientPos = app.canvasPosToClientPos([
|
||||
group.pos[0] + offsetX,
|
||||
group.pos[1] + offsetY
|
||||
])
|
||||
return { x: clientPos[0], y: clientPos[1] }
|
||||
},
|
||||
{
|
||||
title,
|
||||
offsetX: GROUP_TITLE_CLICK_OFFSET_X,
|
||||
offsetY: GROUP_TITLE_CLICK_OFFSET_Y
|
||||
}
|
||||
)
|
||||
if (!pos) throw new Error(`Group "${title}" not found`)
|
||||
return pos
|
||||
}
|
||||
20
browser_tests/fixtures/utils/selectionToolbox.ts
Normal file
20
browser_tests/fixtures/utils/selectionToolbox.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { Locator } from '@playwright/test'
|
||||
|
||||
import { comfyExpect as expect } from '@e2e/fixtures/ComfyPage'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
/**
|
||||
* Opens the selection toolbox "More Options" menu and returns the menu
|
||||
* locator so callers can scope follow-up queries to it.
|
||||
*/
|
||||
export async function openMoreOptions(comfyPage: ComfyPage): Promise<Locator> {
|
||||
await expect(comfyPage.selectionToolbox).toBeVisible()
|
||||
|
||||
const moreOptionsBtn = comfyPage.page.getByTestId('more-options-button')
|
||||
await expect(moreOptionsBtn).toBeVisible()
|
||||
await moreOptionsBtn.click()
|
||||
|
||||
const menu = comfyPage.page.locator('.p-contextmenu')
|
||||
await expect(menu.getByText('Copy', { exact: true })).toBeVisible()
|
||||
return menu
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { getGroupTitlePosition } from '@e2e/fixtures/utils/groupHelpers'
|
||||
|
||||
test.describe('Group Copy Paste', { tag: ['@canvas'] }, () => {
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
@@ -12,15 +13,7 @@ test.describe('Group Copy Paste', { tag: ['@canvas'] }, () => {
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('groups/single_group_only')
|
||||
|
||||
const titlePos = await comfyPage.page.evaluate(() => {
|
||||
const app = window.app!
|
||||
const group = app.graph.groups[0]
|
||||
const clientPos = app.canvasPosToClientPos([
|
||||
group.pos[0] + 50,
|
||||
group.pos[1] + 15
|
||||
])
|
||||
return { x: clientPos[0], y: clientPos[1] }
|
||||
})
|
||||
const titlePos = await getGroupTitlePosition(comfyPage, 'Group')
|
||||
await comfyPage.canvas.click({ position: titlePos })
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
|
||||
@@ -2,29 +2,7 @@ import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
/**
|
||||
* Returns the client-space position of a group's title bar (for clicking).
|
||||
*/
|
||||
async function getGroupTitlePosition(
|
||||
comfyPage: ComfyPage,
|
||||
title: string
|
||||
): Promise<{ x: number; y: number }> {
|
||||
const pos = await comfyPage.page.evaluate((title) => {
|
||||
const app = window.app!
|
||||
const group = app.graph.groups.find(
|
||||
(g: { title: string }) => g.title === title
|
||||
)
|
||||
if (!group) return null
|
||||
const clientPos = app.canvasPosToClientPos([
|
||||
group.pos[0] + 50,
|
||||
group.pos[1] + 15
|
||||
])
|
||||
return { x: clientPos[0], y: clientPos[1] }
|
||||
}, title)
|
||||
if (!pos) throw new Error(`Group "${title}" not found`)
|
||||
return pos
|
||||
}
|
||||
import { getGroupTitlePosition } from '@e2e/fixtures/utils/groupHelpers'
|
||||
|
||||
/**
|
||||
* Returns {selectedNodeCount, selectedGroupCount, selectedItemCount}
|
||||
|
||||
@@ -13,45 +13,35 @@ test.describe('Keyboard shortcut actions', { tag: '@keyboard' }, () => {
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
test('Ctrl+Z undoes the last graph change', async ({ comfyPage }) => {
|
||||
test('Ctrl+Z undoes and Ctrl+Shift+Z redoes the last graph change', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const initialNodeCount = await comfyPage.nodeOps.getNodeCount()
|
||||
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const node = window.LiteGraph!.createNode('Note')
|
||||
window.app!.graph!.add(node)
|
||||
await test.step('Ctrl+Z undoes the last graph change', async () => {
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const node = window.LiteGraph!.createNode('Note')
|
||||
window.app!.graph!.add(node)
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getNodeCount())
|
||||
.toBe(initialNodeCount + 1)
|
||||
|
||||
await comfyPage.canvas.click()
|
||||
await comfyPage.page.keyboard.press('ControlOrMeta+z')
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getNodeCount())
|
||||
.toBe(initialNodeCount)
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getNodeCount())
|
||||
.toBe(initialNodeCount + 1)
|
||||
|
||||
await comfyPage.canvas.click()
|
||||
await comfyPage.page.keyboard.press('ControlOrMeta+z')
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getNodeCount())
|
||||
.toBe(initialNodeCount)
|
||||
})
|
||||
|
||||
test('Ctrl+Shift+Z redoes after undo', async ({ comfyPage }) => {
|
||||
const initialNodeCount = await comfyPage.nodeOps.getNodeCount()
|
||||
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const node = window.LiteGraph!.createNode('Note')
|
||||
window.app!.graph!.add(node)
|
||||
await test.step('Ctrl+Shift+Z redoes after undo', async () => {
|
||||
await comfyPage.page.keyboard.press('ControlOrMeta+Shift+z')
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getNodeCount())
|
||||
.toBe(initialNodeCount + 1)
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.canvas.click()
|
||||
await comfyPage.page.keyboard.press('ControlOrMeta+z')
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getNodeCount())
|
||||
.toBe(initialNodeCount)
|
||||
|
||||
await comfyPage.page.keyboard.press('ControlOrMeta+Shift+z')
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getNodeCount())
|
||||
.toBe(initialNodeCount + 1)
|
||||
})
|
||||
|
||||
test('Ctrl+S opens save dialog', async ({ comfyPage }) => {
|
||||
@@ -62,25 +52,23 @@ test.describe('Keyboard shortcut actions', { tag: '@keyboard' }, () => {
|
||||
await expect(saveDialog).toBeVisible()
|
||||
})
|
||||
|
||||
test('Ctrl+, opens settings dialog', async ({ comfyPage }) => {
|
||||
await comfyPage.page.keyboard.down('ControlOrMeta')
|
||||
await comfyPage.page.keyboard.press(',')
|
||||
await comfyPage.page.keyboard.up('ControlOrMeta')
|
||||
|
||||
test('Ctrl+, opens and Escape closes settings dialog', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const settingsDialog = comfyPage.page.getByTestId('settings-dialog')
|
||||
await expect(settingsDialog).toBeVisible()
|
||||
})
|
||||
|
||||
test('Escape closes settings dialog', async ({ comfyPage }) => {
|
||||
await comfyPage.page.keyboard.down('ControlOrMeta')
|
||||
await comfyPage.page.keyboard.press(',')
|
||||
await comfyPage.page.keyboard.up('ControlOrMeta')
|
||||
await test.step('Ctrl+, opens settings dialog', async () => {
|
||||
await comfyPage.page.keyboard.down('ControlOrMeta')
|
||||
await comfyPage.page.keyboard.press(',')
|
||||
await comfyPage.page.keyboard.up('ControlOrMeta')
|
||||
|
||||
const settingsDialog = comfyPage.page.getByTestId('settings-dialog')
|
||||
await expect(settingsDialog).toBeVisible()
|
||||
await expect(settingsDialog).toBeVisible()
|
||||
})
|
||||
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await expect(settingsDialog).toBeHidden()
|
||||
await test.step('Escape closes settings dialog', async () => {
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await expect(settingsDialog).toBeHidden()
|
||||
})
|
||||
})
|
||||
|
||||
test('Delete key removes selected nodes', async ({ comfyPage }) => {
|
||||
|
||||
@@ -2,21 +2,7 @@ import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
async function openMoreOptions(comfyPage: ComfyPage) {
|
||||
await expect(comfyPage.selectionToolbox).toBeVisible()
|
||||
|
||||
const moreOptionsBtn = comfyPage.page.getByTestId('more-options-button')
|
||||
await expect(moreOptionsBtn).toBeVisible()
|
||||
await moreOptionsBtn.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Wait for the context menu to appear by checking for 'Copy', which is
|
||||
// always present regardless of single or multi-node selection.
|
||||
const menu = comfyPage.page.locator('.p-contextmenu')
|
||||
await expect(menu.getByText('Copy', { exact: true })).toBeVisible()
|
||||
}
|
||||
import { openMoreOptions } from '@e2e/fixtures/utils/selectionToolbox'
|
||||
|
||||
test.describe('Selection Toolbox - More Options', { tag: '@ui' }, () => {
|
||||
test.describe('Single node actions', () => {
|
||||
@@ -34,14 +20,14 @@ test.describe('Selection Toolbox - More Options', { tag: '@ui' }, () => {
|
||||
|
||||
await expect(nodeRef).not.toBePinned()
|
||||
|
||||
await openMoreOptions(comfyPage)
|
||||
await comfyPage.page.getByText('Pin', { exact: true }).click()
|
||||
let menu = await openMoreOptions(comfyPage)
|
||||
await menu.getByText('Pin', { exact: true }).click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(nodeRef).toBePinned()
|
||||
|
||||
await openMoreOptions(comfyPage)
|
||||
await comfyPage.page.getByText('Unpin', { exact: true }).click()
|
||||
menu = await openMoreOptions(comfyPage)
|
||||
await menu.getByText('Unpin', { exact: true }).click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(nodeRef).not.toBePinned()
|
||||
@@ -57,14 +43,14 @@ test.describe('Selection Toolbox - More Options', { tag: '@ui' }, () => {
|
||||
|
||||
await expect(nodeRef).not.toBeCollapsed()
|
||||
|
||||
await openMoreOptions(comfyPage)
|
||||
await comfyPage.page.getByText('Minimize Node', { exact: true }).click()
|
||||
let menu = await openMoreOptions(comfyPage)
|
||||
await menu.getByText('Minimize Node', { exact: true }).click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(nodeRef).toBeCollapsed()
|
||||
|
||||
await openMoreOptions(comfyPage)
|
||||
await comfyPage.page.getByText('Expand Node', { exact: true }).click()
|
||||
menu = await openMoreOptions(comfyPage)
|
||||
await menu.getByText('Expand Node', { exact: true }).click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(nodeRef).not.toBeCollapsed()
|
||||
@@ -78,8 +64,8 @@ test.describe('Selection Toolbox - More Options', { tag: '@ui' }, () => {
|
||||
|
||||
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
|
||||
|
||||
await openMoreOptions(comfyPage)
|
||||
await comfyPage.page.getByText('Copy', { exact: true }).click()
|
||||
const menu = await openMoreOptions(comfyPage)
|
||||
await menu.getByText('Copy', { exact: true }).click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Paste the copied node
|
||||
@@ -99,8 +85,8 @@ test.describe('Selection Toolbox - More Options', { tag: '@ui' }, () => {
|
||||
|
||||
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
|
||||
|
||||
await openMoreOptions(comfyPage)
|
||||
await comfyPage.page.getByText('Duplicate', { exact: true }).click()
|
||||
const menu = await openMoreOptions(comfyPage)
|
||||
await menu.getByText('Duplicate', { exact: true }).click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect
|
||||
|
||||
96
browser_tests/tests/selectionToolboxRename.spec.ts
Normal file
96
browser_tests/tests/selectionToolboxRename.spec.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import { getGroupTitlePosition } from '@e2e/fixtures/utils/groupHelpers'
|
||||
import { openMoreOptions } from '@e2e/fixtures/utils/selectionToolbox'
|
||||
|
||||
test.describe('Selection toolbox rename', { tag: '@ui' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.Canvas.SelectionToolbox', true)
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
await comfyPage.nextFrame()
|
||||
})
|
||||
|
||||
test.describe('Single rename', () => {
|
||||
test('Rename via More Options opens title editor for single node', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const nodeRef = (
|
||||
await comfyPage.nodeOps.getNodeRefsByTitle('KSampler')
|
||||
)[0]
|
||||
await comfyPage.nodeOps.selectNodeWithPan(nodeRef)
|
||||
|
||||
const menu = await openMoreOptions(comfyPage)
|
||||
await menu.getByText('Rename', { exact: true }).click()
|
||||
|
||||
await expect(comfyPage.page.getByTestId('node-title-input')).toHaveValue(
|
||||
'KSampler'
|
||||
)
|
||||
})
|
||||
|
||||
test('Rename shows prompt dialog for group', async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'LiteGraph.Group.SelectChildrenOnClick',
|
||||
false
|
||||
)
|
||||
await comfyPage.workflow.loadWorkflow('groups/nested-groups-1-inner-node')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const outerGroupPos = await getGroupTitlePosition(
|
||||
comfyPage,
|
||||
'Outer Group'
|
||||
)
|
||||
await comfyPage.canvas.click({ position: outerGroupPos })
|
||||
|
||||
const menu = await openMoreOptions(comfyPage)
|
||||
await menu.getByText('Rename', { exact: true }).click()
|
||||
|
||||
await expect(comfyPage.nodeOps.promptDialogInput).toBeVisible()
|
||||
await comfyPage.nodeOps.promptDialogInput.fill('Renamed Group')
|
||||
await comfyPage.page.keyboard.press('Enter')
|
||||
await expect(comfyPage.nodeOps.promptDialogInput).toBeHidden()
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
comfyPage.page.evaluate(() => {
|
||||
return window.app!.graph.groups.some(
|
||||
(g) => g.title === 'Renamed Group'
|
||||
)
|
||||
})
|
||||
)
|
||||
.toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Batch rename', () => {
|
||||
test('Batch rename multiple selected nodes', async ({ comfyPage }) => {
|
||||
const ksampler = (
|
||||
await comfyPage.nodeOps.getNodeRefsByTitle('KSampler')
|
||||
)[0]
|
||||
const emptyLatent = (
|
||||
await comfyPage.nodeOps.getNodeRefsByTitle('Empty Latent Image')
|
||||
)[0]
|
||||
|
||||
await comfyPage.nodeOps.selectNodes(['KSampler', 'Empty Latent Image'])
|
||||
|
||||
const menu = await openMoreOptions(comfyPage)
|
||||
await menu.getByText('Rename', { exact: true }).click()
|
||||
|
||||
await expect(comfyPage.nodeOps.promptDialogInput).toBeVisible()
|
||||
await comfyPage.nodeOps.promptDialogInput.fill('TestNode')
|
||||
await comfyPage.page.keyboard.press('Enter')
|
||||
await expect(comfyPage.nodeOps.promptDialogInput).toBeHidden()
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const titles = await Promise.all([
|
||||
ksampler.getProperty<string>('title'),
|
||||
emptyLatent.getProperty<string>('title')
|
||||
])
|
||||
return [...titles].sort()
|
||||
})
|
||||
.toEqual(['TestNode 1', 'TestNode 2'])
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -22,44 +22,35 @@ test.describe('Topbar menu commands', { tag: '@ui' }, () => {
|
||||
await expect.poll(() => topbar.getTabNames()).toHaveLength(2)
|
||||
})
|
||||
|
||||
test('Edit > Undo undoes the last action', async ({ comfyPage }) => {
|
||||
test('Edit > Undo undoes and Edit > Redo restores the last action', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const initialNodeCount = await comfyPage.nodeOps.getNodeCount()
|
||||
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const node = window.LiteGraph!.createNode('Note')
|
||||
window.app!.graph!.add(node)
|
||||
await test.step('Edit > Undo undoes the last action', async () => {
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const node = window.LiteGraph!.createNode('Note')
|
||||
window.app!.graph!.add(node)
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getNodeCount())
|
||||
.toBe(initialNodeCount + 1)
|
||||
|
||||
await comfyPage.menu.topbar.triggerTopbarCommand(['Edit', 'Undo'])
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getNodeCount())
|
||||
.toBe(initialNodeCount)
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getNodeCount())
|
||||
.toBe(initialNodeCount + 1)
|
||||
|
||||
await comfyPage.menu.topbar.triggerTopbarCommand(['Edit', 'Undo'])
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getNodeCount())
|
||||
.toBe(initialNodeCount)
|
||||
})
|
||||
|
||||
test('Edit > Redo restores an undone action', async ({ comfyPage }) => {
|
||||
const initialNodeCount = await comfyPage.nodeOps.getNodeCount()
|
||||
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const node = window.LiteGraph!.createNode('Note')
|
||||
window.app!.graph!.add(node)
|
||||
await test.step('Edit > Redo restores an undone action', async () => {
|
||||
await comfyPage.menu.topbar.triggerTopbarCommand(['Edit', 'Redo'])
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getNodeCount())
|
||||
.toBe(initialNodeCount + 1)
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.menu.topbar.triggerTopbarCommand(['Edit', 'Undo'])
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getNodeCount())
|
||||
.toBe(initialNodeCount)
|
||||
|
||||
await comfyPage.menu.topbar.triggerTopbarCommand(['Edit', 'Redo'])
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getNodeCount())
|
||||
.toBe(initialNodeCount + 1)
|
||||
})
|
||||
|
||||
test('File > Save opens save dialog', async ({ comfyPage }) => {
|
||||
|
||||
@@ -230,6 +230,31 @@ export default defineConfig([
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'comfy/no-unsafe-error-assertion',
|
||||
files: [
|
||||
'src/**/*.ts',
|
||||
'src/**/*.tsx',
|
||||
'src/**/*.vue',
|
||||
'apps/*/src/**/*.ts',
|
||||
'apps/*/src/**/*.tsx',
|
||||
'apps/*/src/**/*.vue'
|
||||
],
|
||||
ignores: ['**/*.test.ts', '**/*.spec.ts'],
|
||||
rules: {
|
||||
'no-restricted-syntax': [
|
||||
'error',
|
||||
{
|
||||
// Bans `value as Error` and `value as Error & { ... }`.
|
||||
// Use `error instanceof Error` narrowing or `toError()` from
|
||||
// @/utils/errorUtil instead — see issue #11429.
|
||||
selector: "TSAsExpression TSTypeReference[typeName.name='Error']",
|
||||
message:
|
||||
'Do not use `as Error` assertions. Use `instanceof Error` narrowing or `toError()` from @/utils/errorUtil instead. See issue #11429.'
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
files: ['**/*.spec.ts'],
|
||||
ignores: ['browser_tests/tests/**/*.spec.ts', 'apps/*/e2e/**/*.spec.ts'],
|
||||
|
||||
134
packages/registry-types/src/comfyRegistryTypes.ts
generated
134
packages/registry-types/src/comfyRegistryTypes.ts
generated
@@ -4014,6 +4014,26 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/proxy/seedance/visual-validate/groups": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* List the caller's completed visual-validation groups
|
||||
* @description Returns the caller's completed visual-validation groups (real-person H5 verification). Used to power the group selector in client UIs. Excludes virtual-library (AIGC) groups, which are not part of the public API surface.
|
||||
*/
|
||||
get: operations["seedanceListVisualValidationGroups"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/proxy/seedance/visual-validate/sessions/{session_id}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -4037,7 +4057,11 @@ export interface paths {
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
/**
|
||||
* List the caller's assets across all owned groups
|
||||
* @description Fans out to BytePlus ListAssets across the caller's completed verification groups, denormalizes the group label into each row, and returns a single flat list. Result is post-filtered by asset_type. Optional group_id narrows to one group. Hard caps: 5 pages × 100 assets per group, 1000 total assets.
|
||||
*/
|
||||
get: operations["seedanceListUserAssets"];
|
||||
put?: never;
|
||||
post: operations["seedanceCreateAsset"];
|
||||
delete?: never;
|
||||
@@ -13569,7 +13593,7 @@ export interface components {
|
||||
stream: boolean | null;
|
||||
};
|
||||
/** @enum {string} */
|
||||
OpenAIModels: "gpt-4" | "gpt-4-0314" | "gpt-4-0613" | "gpt-4-32k" | "gpt-4-32k-0314" | "gpt-4-32k-0613" | "gpt-4-0125-preview" | "gpt-4-turbo" | "gpt-4-turbo-2024-04-09" | "gpt-4-turbo-preview" | "gpt-4-1106-preview" | "gpt-4-vision-preview" | "gpt-3.5-turbo" | "gpt-3.5-turbo-16k" | "gpt-3.5-turbo-0301" | "gpt-3.5-turbo-0613" | "gpt-3.5-turbo-1106" | "gpt-3.5-turbo-0125" | "gpt-3.5-turbo-16k-0613" | "gpt-4.1" | "gpt-4.1-mini" | "gpt-4.1-nano" | "gpt-4.1-2025-04-14" | "gpt-4.1-mini-2025-04-14" | "gpt-4.1-nano-2025-04-14" | "o1" | "o1-mini" | "o1-preview" | "o1-pro" | "o1-2024-12-17" | "o1-preview-2024-09-12" | "o1-mini-2024-09-12" | "o1-pro-2025-03-19" | "o3" | "o3-mini" | "o3-2025-04-16" | "o3-mini-2025-01-31" | "o4-mini" | "o4-mini-2025-04-16" | "gpt-4o" | "gpt-4o-mini" | "gpt-4o-2024-11-20" | "gpt-4o-2024-08-06" | "gpt-4o-2024-05-13" | "gpt-4o-mini-2024-07-18" | "gpt-4o-audio-preview" | "gpt-4o-audio-preview-2024-10-01" | "gpt-4o-audio-preview-2024-12-17" | "gpt-4o-mini-audio-preview" | "gpt-4o-mini-audio-preview-2024-12-17" | "gpt-4o-search-preview" | "gpt-4o-mini-search-preview" | "gpt-4o-search-preview-2025-03-11" | "gpt-4o-mini-search-preview-2025-03-11" | "computer-use-preview" | "computer-use-preview-2025-03-11" | "gpt-5" | "gpt-5-mini" | "gpt-5-nano" | "chatgpt-4o-latest";
|
||||
OpenAIModels: "gpt-4" | "gpt-4-0314" | "gpt-4-0613" | "gpt-4-32k" | "gpt-4-32k-0314" | "gpt-4-32k-0613" | "gpt-4-0125-preview" | "gpt-4-turbo" | "gpt-4-turbo-2024-04-09" | "gpt-4-turbo-preview" | "gpt-4-1106-preview" | "gpt-4-vision-preview" | "gpt-3.5-turbo" | "gpt-3.5-turbo-16k" | "gpt-3.5-turbo-0301" | "gpt-3.5-turbo-0613" | "gpt-3.5-turbo-1106" | "gpt-3.5-turbo-0125" | "gpt-3.5-turbo-16k-0613" | "gpt-4.1" | "gpt-4.1-mini" | "gpt-4.1-nano" | "gpt-4.1-2025-04-14" | "gpt-4.1-mini-2025-04-14" | "gpt-4.1-nano-2025-04-14" | "o1" | "o1-mini" | "o1-preview" | "o1-pro" | "o1-2024-12-17" | "o1-preview-2024-09-12" | "o1-mini-2024-09-12" | "o1-pro-2025-03-19" | "o3" | "o3-mini" | "o3-2025-04-16" | "o3-mini-2025-01-31" | "o4-mini" | "o4-mini-2025-04-16" | "gpt-4o" | "gpt-4o-mini" | "gpt-4o-2024-11-20" | "gpt-4o-2024-08-06" | "gpt-4o-2024-05-13" | "gpt-4o-mini-2024-07-18" | "gpt-4o-audio-preview" | "gpt-4o-audio-preview-2024-10-01" | "gpt-4o-audio-preview-2024-12-17" | "gpt-4o-mini-audio-preview" | "gpt-4o-mini-audio-preview-2024-12-17" | "gpt-4o-search-preview" | "gpt-4o-mini-search-preview" | "gpt-4o-search-preview-2025-03-11" | "gpt-4o-mini-search-preview-2025-03-11" | "computer-use-preview" | "computer-use-preview-2025-03-11" | "gpt-5" | "gpt-5-mini" | "gpt-5-nano" | "gpt-5.5" | "gpt-5.5-pro" | "chatgpt-4o-latest";
|
||||
MoonvalleyTextToVideoInferenceParams: {
|
||||
/**
|
||||
* @description Height of the generated video in pixels
|
||||
@@ -14442,6 +14466,10 @@ export interface components {
|
||||
total_tokens?: number;
|
||||
};
|
||||
};
|
||||
SeedanceCreateVisualValidateSessionRequest: {
|
||||
/** @description Optional human-readable label for the asset group that will be created by this verification. Stored locally and returned by seedanceListVisualValidationGroups so users can identify their groups in selectors. */
|
||||
name?: string;
|
||||
};
|
||||
SeedanceCreateVisualValidateSessionResponse: {
|
||||
/**
|
||||
* Format: uuid
|
||||
@@ -14451,6 +14479,37 @@ export interface components {
|
||||
/** @description BytePlus-issued H5 liveness link. Open in a browser with camera access. Valid for ~120 seconds. */
|
||||
h5_link: string;
|
||||
};
|
||||
SeedanceListVisualValidationGroupsResponse: {
|
||||
groups: components["schemas"]["SeedanceVisualValidationGroup"][];
|
||||
};
|
||||
SeedanceListUserAssetsResponse: {
|
||||
assets: components["schemas"]["SeedanceUserAsset"][];
|
||||
/** @description True if the global per-request asset cap was hit and older results were dropped. */
|
||||
truncated: boolean;
|
||||
};
|
||||
SeedanceUserAsset: {
|
||||
asset_id: string;
|
||||
name?: string | null;
|
||||
/** @description BytePlus access URL (~12h validity). Refreshed on each list call. */
|
||||
url?: string | null;
|
||||
group_id: string;
|
||||
/** @description Display label of the source group, denormalized for client-side search. */
|
||||
group_name: string;
|
||||
/** @enum {string} */
|
||||
asset_type: "Image" | "Video" | "Audio";
|
||||
/** @enum {string} */
|
||||
status: "Active" | "Processing" | "Failed";
|
||||
/** Format: date-time */
|
||||
create_time: string;
|
||||
};
|
||||
SeedanceVisualValidationGroup: {
|
||||
/** @description BytePlus-issued asset group id. */
|
||||
group_id: string;
|
||||
/** @description Display label. Caller-supplied at creation time when available; otherwise a server-generated fallback derived from the creation date. */
|
||||
name: string;
|
||||
/** Format: date-time */
|
||||
created_at: string;
|
||||
};
|
||||
SeedanceGetVisualValidateSessionResponse: {
|
||||
/** Format: uuid */
|
||||
session_id: string;
|
||||
@@ -14458,6 +14517,8 @@ export interface components {
|
||||
status: "pending" | "completed" | "failed";
|
||||
/** @description Populated only when status == completed. This is the BytePlus Asset Group ID the user will upload assets into. */
|
||||
group_id?: string | null;
|
||||
/** @description Optional human-readable label provided when the session was created. */
|
||||
name?: string | null;
|
||||
error_code?: string | null;
|
||||
error_message?: string | null;
|
||||
};
|
||||
@@ -30275,7 +30336,11 @@ export interface operations {
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
requestBody?: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["SeedanceCreateVisualValidateSessionRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Verification session created */
|
||||
201: {
|
||||
@@ -30297,6 +30362,35 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
seedanceListVisualValidationGroups: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Visual-validation groups owned by the caller */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["SeedanceListVisualValidationGroupsResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Error 4xx/5xx */
|
||||
default: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ErrorResponse"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
seedanceGetVisualValidateSession: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -30329,6 +30423,40 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
seedanceListUserAssets: {
|
||||
parameters: {
|
||||
query: {
|
||||
/** @description Asset type to return. */
|
||||
asset_type: "Image" | "Video";
|
||||
/** @description Narrow the listing to one group. Caller must own it. */
|
||||
group_id?: string;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Assets owned by the caller */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["SeedanceListUserAssetsResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Error 4xx/5xx */
|
||||
default: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ErrorResponse"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
seedanceCreateAsset: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
||||
@@ -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 [
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -23,8 +23,15 @@ import type {
|
||||
*/
|
||||
function isNotFoundError(error: unknown): boolean {
|
||||
if (!(error instanceof Error)) return false
|
||||
const withResponse = error as Error & { response?: { status?: number } }
|
||||
if (withResponse.response?.status === 404) return true
|
||||
if (
|
||||
'response' in error &&
|
||||
typeof error.response === 'object' &&
|
||||
error.response !== null &&
|
||||
'status' in error.response &&
|
||||
error.response.status === 404
|
||||
) {
|
||||
return true
|
||||
}
|
||||
return /\b404\b/.test(error.message)
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -2,6 +2,7 @@ import * as Sentry from '@sentry/vue'
|
||||
import { isEmpty } from 'es-toolkit/compat'
|
||||
|
||||
import { api } from '@/scripts/api'
|
||||
import { toError } from '@/utils/errorUtil'
|
||||
|
||||
interface UserCloudStatus {
|
||||
status: 'active'
|
||||
@@ -80,7 +81,7 @@ export async function getUserCloudStatus(): Promise<UserCloudStatus> {
|
||||
} catch (error) {
|
||||
// Only capture network errors (not HTTP errors we already captured)
|
||||
if (!isHttpError(error, 'Failed to get user:')) {
|
||||
captureApiError(error as Error, '/user', 'network_error')
|
||||
captureApiError(toError(error), '/user', 'network_error')
|
||||
}
|
||||
throw error
|
||||
}
|
||||
@@ -176,7 +177,7 @@ export async function submitSurvey(
|
||||
// Only capture network errors (not HTTP errors we already captured)
|
||||
if (!isHttpError(error, 'Failed to submit survey:')) {
|
||||
captureApiError(
|
||||
error as Error,
|
||||
toError(error),
|
||||
'/settings',
|
||||
'network_error',
|
||||
undefined,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { MediaRecorder as ExtendableMediaRecorder } from 'extendable-media-recor
|
||||
import { onUnmounted, ref } from 'vue'
|
||||
|
||||
import { useAudioService } from '@/services/audioService'
|
||||
import { toError } from '@/utils/errorUtil'
|
||||
|
||||
interface AudioRecorderOptions {
|
||||
onRecordingComplete?: (audioBlob: Blob) => Promise<void>
|
||||
@@ -62,7 +63,7 @@ export function useAudioRecorder(options: AudioRecorderOptions = {}) {
|
||||
isRecording.value = true
|
||||
} catch (err) {
|
||||
if (options.onError) {
|
||||
options.onError(err as Error)
|
||||
options.onError(toError(err))
|
||||
}
|
||||
throw err
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
NodePackSearchProvider,
|
||||
SearchPacksResult
|
||||
} from '@/types/searchServiceTypes'
|
||||
import { toError } from '@/utils/errorUtil'
|
||||
|
||||
type RegistryNodePack = components['schemas']['Node']
|
||||
|
||||
@@ -152,7 +153,7 @@ export const useRegistrySearchGateway = (): NodePackSearchProvider => {
|
||||
recordSuccess(providerState)
|
||||
return result
|
||||
} catch (error) {
|
||||
lastError = error as Error
|
||||
lastError = toError(error)
|
||||
const providerState = providers[activeProviderIndex]
|
||||
recordFailure(providerState, lastError)
|
||||
console.warn(
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
74
src/utils/errorUtil.test.ts
Normal file
74
src/utils/errorUtil.test.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { getErrorMessage, toError } from './errorUtil'
|
||||
|
||||
describe('toError', () => {
|
||||
it('returns the same Error instance when given an Error', () => {
|
||||
const err = new Error('boom')
|
||||
expect(toError(err)).toBe(err)
|
||||
})
|
||||
|
||||
it('preserves Error subclasses', () => {
|
||||
class CustomError extends Error {}
|
||||
const err = new CustomError('subclass')
|
||||
expect(toError(err)).toBe(err)
|
||||
expect(toError(err)).toBeInstanceOf(CustomError)
|
||||
})
|
||||
|
||||
it('wraps a string as an Error message', () => {
|
||||
const result = toError('plain string')
|
||||
expect(result).toBeInstanceOf(Error)
|
||||
expect(result.message).toBe('plain string')
|
||||
})
|
||||
|
||||
it('wraps a number by stringifying it', () => {
|
||||
const result = toError(42)
|
||||
expect(result).toBeInstanceOf(Error)
|
||||
expect(result.message).toBe('42')
|
||||
})
|
||||
|
||||
it('wraps an object via JSON.stringify', () => {
|
||||
const result = toError({ code: 'EBOOM', detail: 'nope' })
|
||||
expect(result).toBeInstanceOf(Error)
|
||||
expect(result.message).toBe('{"code":"EBOOM","detail":"nope"}')
|
||||
})
|
||||
|
||||
it('falls back to String() when JSON.stringify throws (circular)', () => {
|
||||
const obj: Record<string, unknown> = {}
|
||||
obj.self = obj
|
||||
const result = toError(obj)
|
||||
expect(result).toBeInstanceOf(Error)
|
||||
expect(result.message).toBe('[object Object]')
|
||||
})
|
||||
|
||||
it('handles null and undefined', () => {
|
||||
expect(toError(null).message).toBe('null')
|
||||
expect(toError(undefined).message).toBe('undefined')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getErrorMessage', () => {
|
||||
it('returns the message of an Error', () => {
|
||||
expect(getErrorMessage(new Error('boom'))).toBe('boom')
|
||||
})
|
||||
|
||||
it('returns the value when given a string', () => {
|
||||
expect(getErrorMessage('text')).toBe('text')
|
||||
})
|
||||
|
||||
it('returns the message field of a plain object', () => {
|
||||
expect(getErrorMessage({ message: 'from object' })).toBe('from object')
|
||||
})
|
||||
|
||||
it('returns undefined for objects without a string message', () => {
|
||||
expect(getErrorMessage({ code: 1 })).toBeUndefined()
|
||||
expect(getErrorMessage({ message: 42 })).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns undefined for null, undefined, numbers, booleans', () => {
|
||||
expect(getErrorMessage(null)).toBeUndefined()
|
||||
expect(getErrorMessage(undefined)).toBeUndefined()
|
||||
expect(getErrorMessage(42)).toBeUndefined()
|
||||
expect(getErrorMessage(true)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
37
src/utils/errorUtil.ts
Normal file
37
src/utils/errorUtil.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Narrow an unknown caught value to an Error.
|
||||
*
|
||||
* Replaces unsafe `value as Error` assertions. When `value` is not already
|
||||
* an Error instance, wraps it in a new Error whose message is the stringified
|
||||
* input so downstream consumers (loggers, Sentry, toasts) always receive a
|
||||
* usable Error object instead of `undefined.message`.
|
||||
*/
|
||||
export function toError(value: unknown): Error {
|
||||
if (value instanceof Error) return value
|
||||
if (typeof value === 'string') return new Error(value)
|
||||
if (value === undefined) return new Error('undefined')
|
||||
try {
|
||||
const serialised = JSON.stringify(value)
|
||||
return new Error(serialised ?? String(value))
|
||||
} catch {
|
||||
return new Error(String(value))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a message from an unknown caught value without asserting its type.
|
||||
* Returns `undefined` when the value carries no usable message.
|
||||
*/
|
||||
export function getErrorMessage(value: unknown): string | undefined {
|
||||
if (value instanceof Error) return value.message
|
||||
if (typeof value === 'string') return value
|
||||
if (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
'message' in value &&
|
||||
typeof (value as { message: unknown }).message === 'string'
|
||||
) {
|
||||
return (value as { message: string }).message
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
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]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user