diff --git a/.claude/skills/bug-dump-ingest/SKILL.md b/.claude/skills/bug-dump-ingest/SKILL.md deleted file mode 100644 index 413737835f..0000000000 --- a/.claude/skills/bug-dump-ingest/SKILL.md +++ /dev/null @@ -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=`, 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: ` 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:` (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 " --state merged --limit 3` | `fixed` | -| Fix PR open (already-filed) | `gh search prs "" --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: ` 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 "" --repo Comfy-Org/ComfyUI_frontend --limit 5 - -# 2. Grep for the feature / component mentioned -rg -l "" src/ apps/ - -# 3. Check if it's a known i18n / documented setting -rg "" 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: ". 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: "", - text: "@Linear search \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 " -}) -``` - -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 " " --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 " merged:>=" --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: "", - text: "@Linear create\nTeam: Frontend Engineering\nTitle: \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. diff --git a/.claude/skills/bug-dump-ingest/reference/examples.md b/.claude/skills/bug-dump-ingest/reference/examples.md deleted file mode 100644 index 4fc54a4f14..0000000000 --- a/.claude/skills/bug-dump-ingest/reference/examples.md +++ /dev/null @@ -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. diff --git a/.claude/skills/bug-dump-ingest/reference/linear-api.md b/.claude/skills/bug-dump-ingest/reference/linear-api.md deleted file mode 100644 index 57986740df..0000000000 --- a/.claude/skills/bug-dump-ingest/reference/linear-api.md +++ /dev/null @@ -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. diff --git a/.claude/skills/bug-dump-ingest/reference/schema.md b/.claude/skills/bug-dump-ingest/reference/schema.md deleted file mode 100644 index 84db1a5818..0000000000 --- a/.claude/skills/bug-dump-ingest/reference/schema.md +++ /dev/null @@ -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. diff --git a/.claude/skills/bug-dump-ingest/reference/verify-commands.md b/.claude/skills/bug-dump-ingest/reference/verify-commands.md deleted file mode 100644 index a2c99a43a0..0000000000 --- a/.claude/skills/bug-dump-ingest/reference/verify-commands.md +++ /dev/null @@ -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. diff --git a/.claude/skills/perf-fix-with-proof/SKILL.md b/.claude/skills/perf-fix-with-proof/SKILL.md index 56ba55d2e5..6ffba72aa5 100644 --- a/.claude/skills/perf-fix-with-proof/SKILL.md +++ b/.claude/skills/perf-fix-with-proof/SKILL.md @@ -171,7 +171,7 @@ test('canvas text rendering with many nodes', async ({ comfyPage }) => { | ----------------- | ----------------------------------------------------- | | Perf test file | `browser_tests/tests/performance.spec.ts` | | PerformanceHelper | `browser_tests/fixtures/helpers/PerformanceHelper.ts` | -| Perf reporter | `browser_tests/helpers/perfReporter.ts` | +| Perf reporter | `browser_tests/fixtures/utils/perfReporter.ts` | | CI workflow | `.github/workflows/ci-perf-report.yaml` | | Report generator | `scripts/perf-report.ts` | | Stats utilities | `scripts/perf-stats.ts` | diff --git a/.claude/skills/reviewing-unit-tests/SKILL.md b/.claude/skills/reviewing-unit-tests/SKILL.md new file mode 100644 index 0000000000..dcf8a1499d --- /dev/null +++ b/.claude/skills/reviewing-unit-tests/SKILL.md @@ -0,0 +1,156 @@ +--- +name: reviewing-unit-tests +description: Use when reviewing Vitest unit-test diffs in ComfyUI_frontend, especially new mocks, store tests, component tests, or bugfix regression tests. +--- + +# Reviewing Unit Tests for ComfyUI_frontend + +## Overview + +Review for behavior and current repo rules, not motion. Compare to authoritative rules, not prior diffs or legacy snippets. + +## Review Workflow + +1. Identify the test type: component, store, composable, util, or bugfix regression. +2. Name the behavior the test proves. If you cannot say it in one sentence, request changes. +3. Open the authoritative doc section before judging structure. +4. Scan the red flags below. +5. State the verdict first. Name the failure mode. Cite the doc or rule. + +## Source of Truth / Precedence + +When docs and examples conflict, use this order: + +1. Explicit repo rules, lint rules, and note blocks. +2. [`docs/testing/vitest-patterns.md`](../../../docs/testing/vitest-patterns.md) +3. Rule sections in [`docs/testing/unit-testing.md`](../../../docs/testing/unit-testing.md), [`docs/testing/store-testing.md`](../../../docs/testing/store-testing.md), and [`docs/testing/component-testing.md`](../../../docs/testing/component-testing.md) +4. Example snippets +5. Prior diffs + +Apply these repo-specific clarifications: + +- [`docs/testing/component-testing.md`](../../../docs/testing/component-testing.md) starts with the authoritative rule: new component tests use `@testing-library/vue` with `@testing-library/user-event`. The `@vue/test-utils` snippets below it are legacy examples. +- [`docs/testing/store-testing.md`](../../../docs/testing/store-testing.md) still contains `as any` examples. Treat them as legacy snippets, not approval for new or edited test code. +- If docs conflict, prefer the stricter newer rule and call out the doc ambiguity. Do not approve through it. +- Motion != fix. + +## 30-Second Red Flags + +| If you see... | Failure mode | Default action | +| ----------------------------------------------------------------------------------------- | ------------------------------- | ------------------------------------------------------------- | +| New `@vue/test-utils` import in a new component test | legacy test API | Request changes | +| `vi.mock('vue-i18n', ...)` | mocked i18n | Request changes | +| `as any`, `@ts-expect-error`, `as Mock`, `as ReturnType<typeof vi.fn>`, `as unknown as X` | unnecessary cast or type escape | Request changes unless the author proves no safer type exists | +| `getXMock()`, renamed wrapper, or helper that only returns a mocked value | alias-by-renaming | Request changes | +| `beforeEach` recreates the return object for a module-mocked composable or service | shared mock setup drift | Request changes | +| Assertions only check defaults, mock plumbing, or CSS hooks | non-behavioral test | Request changes | +| Bugfix test has no proof it fails on pre-fix code | unproven regression | Request changes | + +## Rationalization Table + +| Excuse | Reality | +| ----------------------------------- | ------------------------------------------------------------------------------------------------------ | +| "I restructured the mocks" | If the indirection stayed, nothing improved. Flag `alias-by-renaming`. | +| "The docs do it" | Rule, note, and lint beat legacy snippet. Compare to the current rule, not the nearest example. | +| "TypeScript required the cast" | `vi.mocked()` usually narrows mock methods. Assertion-only references need no cast. | +| "Putting it in `beforeEach` is DRY" | Recreating module mock state in hooks hides singleton behavior and drifts from the documented pattern. | +| "It is only a nit" | Explicit repo-rule violations are never nits. | +| "No behavior changed, just cleanup" | Motion != fix. Ask what behavior got stronger. | +| "Mental revert is enough" | For bugfix tests, establish red on pre-fix code or ask the author to show it. | + +## Mocking Rules + +- Fail helpers that do not remove repeated setup, encode domain meaning, or simplify assertions. Barely earning the abstraction is not enough. +- For composables with reactive or singleton state, define stable mock state inside the `vi.mock()` factory. Access it per test via the composable itself. See [`docs/testing/unit-testing.md`](../../../docs/testing/unit-testing.md) "Mocking Composables with Reactive State". +- This does not ban local test data builders or per-test `vi.spyOn(...)`. +- Mock seams, not the project-owned module you are trying to exercise. For store tests, prefer real Pinia plus `createTestingPinia({ stubActions: false })` per [`docs/testing/vitest-patterns.md`](../../../docs/testing/vitest-patterns.md) and [`docs/testing/store-testing.md`](../../../docs/testing/store-testing.md). + +### Alias-by-Renaming + +```ts +// Before +const mockAdd = vi.hoisted(() => vi.fn()) + +// After: same indirection, new name +function getToastAddMock() { + return useToast().add +} +``` + +If the wrapper only renames or relays a mocked value, fail it. Inline the lookup at the call site or fetch the singleton mock via the documented pattern. + +### `vi.mocked()` Scope + +| Use case | `vi.mocked()` required? | +| --------------------------------------------------------------- | ----------------------- | +| `.mockReturnValue`, `.mockResolvedValue`, `.mockImplementation` | Yes | +| `.mock.calls`, `.mock.results` | Yes | +| `expect(fn).toHaveBeenCalled()` | No | +| `expect(fn).toHaveBeenCalledWith(...)` | No | + +- Flag casts whenever `vi.mocked()` would narrow correctly. +- Do not add `vi.mocked()` around assertion-only references just for style. + +### Reset Hygiene + +- Flag per-mock `mockClear()` or `mockReset()` when `vi.clearAllMocks()` or `vi.resetAllMocks()` already runs in the relevant hook chain. +- Review for redundancy or broken state management. Do not bikeshed `clearAllMocks` vs `resetAllMocks` unless behavior depends on it. + +### Third-Party Seams + +- Distinguish trivial hooks from behavior-rich APIs. +- Mocking single-method third-party hooks like `primevue/usetoast` is usually acceptable. +- That exception does not justify mocking behavior-rich third-party modules. + +### `vue-i18n` + +- Never mock `vue-i18n` in component tests. +- Use real `createI18n` per [`docs/testing/vitest-patterns.md`](../../../docs/testing/vitest-patterns.md) and the shared [`testI18n`](../../../src/components/searchbox/v2/__test__/testUtils.ts) setup. + +## Test-Body Rules + +| Smell | Review bar | +| ----------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | +| Change-detector test | Reject. Default values alone prove nothing. | +| Mock-only assertion | Accept collaborator-call assertions only when the call is the meaningful external effect and the test also exercises the triggering behavior. | +| Non-behavioral assertion | Reject tests that only check classes, utility hooks, or styling internals. | +| New component test using `@vue/test-utils` | Request changes. Use `@testing-library/vue` plus `@testing-library/user-event`. | +| `any`, `as any`, or `@ts-expect-error` in new or edited test code | Request changes unless the author proves no safer type exists. Legacy doc snippets do not authorize it. | + +## Bugfix Regression Proof + +For `fix:` PRs or bugfix diffs: + +1. Identify the production change that fixes the bug. +2. Verify the new test fails on pre-fix code, or ask the author to show it. +3. If the test passes on broken code, request changes. + +A regression test that never proves red does not pin the bug. + +## Review Output Rules + +- State verdict before procedural questions. +- Do not lead with approval language like `LGTM, just one nit` or `approve and move on?`. +- Name the failure mode directly: `alias-by-renaming`, `unnecessary cast`, `mocked i18n`, `mock-only assertion`, `unproven regression`. +- Link the authoritative doc section in the review comment. +- If an explicit repo rule, lint rule, or authoritative doc note is violated, do not downgrade it to "minor deviation" or "nit". + +## Quick Reference + +| When you see... | Read this | +| ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| New `vi.mock(...)` for a composable | [`docs/testing/unit-testing.md`](../../../docs/testing/unit-testing.md) -> "Mocking Composables with Reactive State" | +| New store test or store mock | [`docs/testing/vitest-patterns.md`](../../../docs/testing/vitest-patterns.md) setup + [`docs/testing/store-testing.md`](../../../docs/testing/store-testing.md) | +| New component test | Top note in [`docs/testing/component-testing.md`](../../../docs/testing/component-testing.md) | +| `vue-i18n` in a component test | [`docs/testing/vitest-patterns.md`](../../../docs/testing/vitest-patterns.md) + [`src/components/searchbox/v2/__test__/testUtils.ts`](../../../src/components/searchbox/v2/__test__/testUtils.ts) | +| Cast around a mock | [`docs/guidance/typescript.md`](../../../docs/guidance/typescript.md) -> "Type Assertion Hierarchy" | + +## Key Files to Read + +| Purpose | Path | +| ------------------------------------ | ----------------------------------------------------------------------------------------------------------------- | +| Composable mocking patterns | [`docs/testing/unit-testing.md`](../../../docs/testing/unit-testing.md) | +| Store testing patterns | [`docs/testing/store-testing.md`](../../../docs/testing/store-testing.md) | +| Repo-wide Vitest setup defaults | [`docs/testing/vitest-patterns.md`](../../../docs/testing/vitest-patterns.md) | +| Component testing rule for new tests | [`docs/testing/component-testing.md`](../../../docs/testing/component-testing.md) | +| Real i18n setup | [`src/components/searchbox/v2/__test__/testUtils.ts`](../../../src/components/searchbox/v2/__test__/testUtils.ts) | diff --git a/.github/actions/ashby-pull/action.yaml b/.github/actions/ashby-pull/action.yaml new file mode 100644 index 0000000000..d1b27f6c67 --- /dev/null +++ b/.github/actions/ashby-pull/action.yaml @@ -0,0 +1,23 @@ +name: Ashby Pull +description: 'Refresh the apps/website Ashby roles snapshot from the Ashby job board API' +inputs: + api_key: + description: 'Ashby API key (WEBSITE_ASHBY_API_KEY).' + required: true + job_board_name: + description: 'Ashby job board name (WEBSITE_ASHBY_JOB_BOARD_NAME).' + required: true +runs: + using: 'composite' + steps: + # Note: this action assumes the frontend repo is checked out at the workspace root. + + - name: Setup frontend + uses: ./.github/actions/setup-frontend + + - name: Refresh Ashby snapshot + shell: bash + env: + WEBSITE_ASHBY_API_KEY: ${{ inputs.api_key }} + WEBSITE_ASHBY_JOB_BOARD_NAME: ${{ inputs.job_board_name }} + run: pnpm --filter @comfyorg/website ashby:refresh-snapshot diff --git a/.github/actions/changes-filter/action.yaml b/.github/actions/changes-filter/action.yaml new file mode 100644 index 0000000000..3a37ca4a32 --- /dev/null +++ b/.github/actions/changes-filter/action.yaml @@ -0,0 +1,87 @@ +# Outputs default to 'true' for non-pull_request events (push, merge_group): +# granular path filtering is a PR-only optimization. This avoids the silent +# skip footgun where a job gated on e.g. `app-website-changes == 'true'` +# would never run on push. +# +# Shared dependency files (root package.json, pnpm-lock.yaml, +# pnpm-workspace.yaml) are folded into every app-* and packages-changes +# output so a lockfile bump correctly invalidates each granular gate. They +# are NOT folded into docs-changes. +# +# Two paths-filter steps are needed because predicate-quantifier=every is +# required for the negated globs in `should-run` but breaks multi-pattern +# OR filters like `docs:` and `deps:`. +# +# Requires the caller to have checked out the repository. + +name: 'Detect Path Changes' +description: > + Computes typed *-changes outputs and a back-compat should-run for + path-gated CI jobs. + +outputs: + should-run: + description: 'Any file outside `apps/`, `docs/`, `.storybook/`, or `**/*.md` changed.' + value: ${{ github.event_name != 'pull_request' || steps.relevant.outputs.relevant == 'true' }} + app-website-changes: + description: 'Shared deps or `apps/website/**` changed.' + value: ${{ github.event_name != 'pull_request' || steps.filter.outputs.deps == 'true' || steps.filter.outputs.app_website == 'true' }} + app-desktop-changes: + description: 'Shared deps or `apps/desktop-ui/**` changed.' + value: ${{ github.event_name != 'pull_request' || steps.filter.outputs.deps == 'true' || steps.filter.outputs.app_desktop == 'true' }} + app-frontend-changes: + description: 'Shared deps or `src/**` changed.' + value: ${{ github.event_name != 'pull_request' || steps.filter.outputs.deps == 'true' || steps.filter.outputs.app_frontend == 'true' }} + packages-changes: + description: 'Shared deps or `packages/**` changed.' + value: ${{ github.event_name != 'pull_request' || steps.filter.outputs.deps == 'true' || steps.filter.outputs.packages == 'true' }} + storybook-changes: + description: 'Shared deps or `.storybook/**` changed.' + value: ${{ github.event_name != 'pull_request' || steps.filter.outputs.deps == 'true' || steps.filter.outputs.storybook == 'true' }} + docs-changes: + description: '`docs/**` or any `**/*.md` changed (deps NOT folded in).' + value: ${{ github.event_name != 'pull_request' || steps.filter.outputs.docs == 'true' }} + dependency-changes: + description: 'Root `package.json`, `pnpm-lock.yaml`, or `pnpm-workspace.yaml` changed.' + value: ${{ github.event_name != 'pull_request' || steps.filter.outputs.deps == 'true' }} + +runs: + using: composite + steps: + - name: Filter typed changes + if: ${{ github.event_name == 'pull_request' }} + id: filter + uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2 + with: + filters: | + app_website: + - 'apps/website/**' + app_desktop: + - 'apps/desktop-ui/**' + app_frontend: + - 'src/**' + packages: + - 'packages/**' + storybook: + - '.storybook/**' + docs: + - 'docs/**' + - '**/*.md' + deps: + - 'package.json' + - 'pnpm-lock.yaml' + - 'pnpm-workspace.yaml' + + - name: Filter relevant changes + if: ${{ github.event_name == 'pull_request' }} + id: relevant + uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2 + with: + predicate-quantifier: 'every' + filters: | + relevant: + - '**' + - '!apps/**' + - '!docs/**' + - '!.storybook/**' + - '!**/*.md' diff --git a/.github/pr-images/fe-237-before-after.png b/.github/pr-images/fe-237-before-after.png new file mode 100644 index 0000000000..a104802cdc Binary files /dev/null and b/.github/pr-images/fe-237-before-after.png differ diff --git a/.github/workflows/ci-dist-telemetry-scan.yaml b/.github/workflows/ci-dist-telemetry-scan.yaml index 1821efd95d..c5e53d13fd 100644 --- a/.github/workflows/ci-dist-telemetry-scan.yaml +++ b/.github/workflows/ci-dist-telemetry-scan.yaml @@ -12,17 +12,30 @@ permissions: contents: read jobs: + changes: + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + should-run: ${{ steps.changes.outputs.should-run }} + steps: + - uses: actions/checkout@v6 + - id: changes + uses: ./.github/actions/changes-filter + scan: + needs: changes + if: ${{ needs.changes.outputs.should-run == 'true' }} runs-on: ubuntu-latest steps: - - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + - uses: actions/checkout@v6 - name: Install pnpm uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0 - name: Use Node.js - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 + uses: actions/setup-node@v6 with: node-version-file: '.nvmrc' cache: 'pnpm' diff --git a/.github/workflows/ci-oss-assets-validation.yaml b/.github/workflows/ci-oss-assets-validation.yaml index a145ca04f1..c05399d4dc 100644 --- a/.github/workflows/ci-oss-assets-validation.yaml +++ b/.github/workflows/ci-oss-assets-validation.yaml @@ -14,16 +14,29 @@ permissions: contents: read jobs: + changes: + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + should-run: ${{ steps.changes.outputs.should-run }} + steps: + - uses: actions/checkout@v6 + - id: changes + uses: ./.github/actions/changes-filter + validate-fonts: + needs: changes + if: ${{ needs.changes.outputs.should-run == 'true' }} runs-on: ubuntu-latest steps: - - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + - uses: actions/checkout@v6 - name: Install pnpm uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0 - name: Use Node.js - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 + uses: actions/setup-node@v6 with: node-version-file: '.nvmrc' cache: 'pnpm' @@ -68,15 +81,17 @@ jobs: echo '✅ No proprietary fonts found in dist' validate-licenses: + needs: changes + if: ${{ needs.changes.outputs.should-run == 'true' }} runs-on: ubuntu-latest steps: - - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + - uses: actions/checkout@v6 - name: Install pnpm uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0 - name: Use Node.js - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 + uses: actions/setup-node@v6 with: node-version-file: '.nvmrc' cache: 'pnpm' diff --git a/.github/workflows/ci-perf-report.yaml b/.github/workflows/ci-perf-report.yaml index b265e0ebb7..bd6c286f37 100644 --- a/.github/workflows/ci-perf-report.yaml +++ b/.github/workflows/ci-perf-report.yaml @@ -3,10 +3,8 @@ name: 'CI: Performance Report' on: push: branches: [main, core/*] - paths-ignore: ['**/*.md'] pull_request: branches-ignore: [wip/*, draft/*, temp/*] - paths-ignore: ['**/*.md'] concurrency: group: perf-${{ github.ref }} @@ -16,12 +14,24 @@ permissions: contents: read jobs: + changes: + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + should-run: ${{ steps.changes.outputs.should-run }} + steps: + - uses: actions/checkout@v6 + - id: changes + uses: ./.github/actions/changes-filter + perf-tests: - if: github.repository == 'Comfy-Org/ComfyUI_frontend' + needs: changes + if: ${{ needs.changes.outputs.should-run == 'true' && github.repository == 'Comfy-Org/ComfyUI_frontend' }} runs-on: ubuntu-latest timeout-minutes: 30 container: - image: ghcr.io/comfy-org/comfyui-ci-container:0.0.12 + image: ghcr.io/comfy-org/comfyui-ci-container:0.0.17 credentials: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/ci-size-data.yaml b/.github/workflows/ci-size-data.yaml index b3e4598fce..ad8e7266c7 100644 --- a/.github/workflows/ci-size-data.yaml +++ b/.github/workflows/ci-size-data.yaml @@ -16,9 +16,22 @@ permissions: contents: read jobs: - collect: + changes: if: github.repository == 'Comfy-Org/ComfyUI_frontend' runs-on: ubuntu-latest + permissions: + contents: read + outputs: + should-run: ${{ steps.changes.outputs.should-run }} + steps: + - uses: actions/checkout@v6 + - id: changes + uses: ./.github/actions/changes-filter + + collect: + needs: changes + if: ${{ needs.changes.outputs.should-run == 'true' }} + runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 diff --git a/.github/workflows/ci-tests-e2e.yaml b/.github/workflows/ci-tests-e2e.yaml index 97785d254e..76ff79d873 100644 --- a/.github/workflows/ci-tests-e2e.yaml +++ b/.github/workflows/ci-tests-e2e.yaml @@ -4,7 +4,6 @@ name: 'CI: Tests E2E' on: push: branches: [main, master, core/*, desktop/*] - paths-ignore: ['**/*.md'] pull_request: branches-ignore: [wip/*, draft/*, temp/*] merge_group: @@ -15,36 +14,20 @@ concurrency: cancel-in-progress: true jobs: - # Detect whether e2e-relevant files changed. Required checks see "skipped" - # (which counts as passing) when only docs/apps/storybook files are touched, - # avoiding the stall that paths-ignore would cause. changes: runs-on: ubuntu-latest permissions: contents: read outputs: - should_run: ${{ github.event_name != 'pull_request' || steps.filter.outputs.e2e }} + should-run: ${{ steps.changes.outputs.should-run }} steps: - - name: Checkout repository - if: ${{ github.event_name == 'pull_request' }} - uses: actions/checkout@v6 - - name: Check for e2e-relevant changes - if: ${{ github.event_name == 'pull_request' }} - id: filter - uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2 - with: - predicate-quantifier: 'every' - filters: | - e2e: - - '**' - - '!apps/**' - - '!docs/**' - - '!.storybook/**' - - '!**/*.md' + - uses: actions/checkout@v6 + - id: changes + uses: ./.github/actions/changes-filter setup: needs: changes - if: ${{ needs.changes.outputs.should_run == 'true' }} + if: ${{ needs.changes.outputs.should-run == 'true' }} runs-on: ubuntu-latest steps: - name: Checkout repository @@ -82,7 +65,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 60 container: - image: ghcr.io/comfy-org/comfyui-ci-container:0.0.16 + image: ghcr.io/comfy-org/comfyui-ci-container:0.0.17 credentials: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} @@ -140,7 +123,7 @@ jobs: needs: setup runs-on: ubuntu-latest container: - image: ghcr.io/comfy-org/comfyui-ci-container:0.0.16 + image: ghcr.io/comfy-org/comfyui-ci-container:0.0.17 credentials: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} @@ -194,7 +177,7 @@ jobs: merge-reports: needs: [changes, playwright-tests-chromium-sharded] runs-on: ubuntu-latest - if: ${{ !cancelled() && needs.changes.outputs.should_run == 'true' }} + if: ${{ !cancelled() && needs.changes.outputs.should-run == 'true' }} steps: - name: Install pnpm uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0 @@ -233,7 +216,7 @@ jobs: steps: - name: Check E2E results env: - SHOULD_RUN: ${{ needs.changes.outputs.should_run }} + SHOULD_RUN: ${{ needs.changes.outputs.should-run }} SHARDED: ${{ needs.playwright-tests-chromium-sharded.result }} BROWSERS: ${{ needs.playwright-tests.result }} run: | @@ -251,7 +234,7 @@ jobs: runs-on: ubuntu-latest if: >- ${{ - needs.changes.outputs.should_run == 'true' && + needs.changes.outputs.should-run == 'true' && github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false }} @@ -278,7 +261,7 @@ jobs: if: >- ${{ always() && - needs.changes.outputs.should_run == 'true' && + needs.changes.outputs.should-run == 'true' && github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false }} diff --git a/.github/workflows/ci-tests-storybook.yaml b/.github/workflows/ci-tests-storybook.yaml index d2fa26826c..f53a254cf9 100644 --- a/.github/workflows/ci-tests-storybook.yaml +++ b/.github/workflows/ci-tests-storybook.yaml @@ -8,10 +8,29 @@ on: branches: [main] jobs: + changes: + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + storybook-changes: ${{ steps.changes.outputs.storybook-changes }} + app-frontend-changes: ${{ steps.changes.outputs.app-frontend-changes }} + packages-changes: ${{ steps.changes.outputs.packages-changes }} + steps: + - uses: actions/checkout@v6 + - id: changes + uses: ./.github/actions/changes-filter + # Post starting comment for non-forked PRs comment-on-pr-start: + needs: changes runs-on: ubuntu-latest - if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false + if: | + github.event_name == 'pull_request' + && github.event.pull_request.head.repo.fork == false + && (needs.changes.outputs.storybook-changes == 'true' + || needs.changes.outputs.app-frontend-changes == 'true' + || needs.changes.outputs.packages-changes == 'true') permissions: pull-requests: write steps: @@ -30,8 +49,13 @@ jobs: # Build Storybook for all PRs (free Cloudflare deployment) storybook-build: + needs: changes runs-on: ubuntu-latest - if: github.event_name == 'pull_request' + if: | + github.event_name == 'pull_request' + && (needs.changes.outputs.storybook-changes == 'true' + || needs.changes.outputs.app-frontend-changes == 'true' + || needs.changes.outputs.packages-changes == 'true') outputs: conclusion: ${{ steps.job-status.outputs.conclusion }} workflow-url: ${{ steps.workflow-url.outputs.url }} @@ -67,8 +91,15 @@ jobs: # Chromatic deployment only for version-bump-* branches or manual triggers chromatic-deployment: + needs: changes runs-on: ubuntu-latest - if: github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && startsWith(github.head_ref, 'version-bump-')) + if: | + github.event_name == 'workflow_dispatch' + || (github.event_name == 'pull_request' + && startsWith(github.head_ref, 'version-bump-') + && (needs.changes.outputs.storybook-changes == 'true' + || needs.changes.outputs.app-frontend-changes == 'true' + || needs.changes.outputs.packages-changes == 'true')) outputs: conclusion: ${{ steps.job-status.outputs.conclusion }} workflow-url: ${{ steps.workflow-url.outputs.url }} @@ -107,9 +138,15 @@ jobs: # Deploy and comment for non-forked PRs only deploy-and-comment: - needs: [storybook-build] + needs: [changes, storybook-build] runs-on: ubuntu-latest - if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false && always() + if: | + always() + && github.event_name == 'pull_request' + && github.event.pull_request.head.repo.fork == false + && (needs.changes.outputs.storybook-changes == 'true' + || needs.changes.outputs.app-frontend-changes == 'true' + || needs.changes.outputs.packages-changes == 'true') permissions: pull-requests: write contents: read diff --git a/.github/workflows/ci-tests-unit.yaml b/.github/workflows/ci-tests-unit.yaml index 3fc3095bd6..352eb8a49d 100644 --- a/.github/workflows/ci-tests-unit.yaml +++ b/.github/workflows/ci-tests-unit.yaml @@ -4,10 +4,8 @@ name: 'CI: Tests Unit' on: push: branches: [main, master, dev*, core/*, desktop/*] - paths-ignore: ['**/*.md'] pull_request: branches-ignore: [wip/*, draft/*, temp/*] - paths-ignore: ['**/*.md'] merge_group: concurrency: @@ -15,7 +13,20 @@ concurrency: cancel-in-progress: true jobs: + changes: + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + should-run: ${{ steps.changes.outputs.should-run }} + steps: + - uses: actions/checkout@v6 + - id: changes + uses: ./.github/actions/changes-filter + test: + needs: changes + if: ${{ needs.changes.outputs.should-run == 'true' }} runs-on: ubuntu-latest steps: diff --git a/.github/workflows/ci-vercel-website-preview.yaml b/.github/workflows/ci-vercel-website-preview.yaml index 7a26fd178d..3588cfc2bf 100644 --- a/.github/workflows/ci-vercel-website-preview.yaml +++ b/.github/workflows/ci-vercel-website-preview.yaml @@ -52,6 +52,9 @@ jobs: run: vercel pull --yes --environment=preview - name: Build project artifacts + env: + WEBSITE_ASHBY_API_KEY: ${{ secrets.WEBSITE_ASHBY_API_KEY }} + WEBSITE_ASHBY_JOB_BOARD_NAME: ${{ secrets.WEBSITE_ASHBY_JOB_BOARD_NAME }} run: vercel build - name: Fetch head commit metadata @@ -146,6 +149,9 @@ jobs: run: vercel pull --yes --environment=production - name: Build project artifacts + env: + WEBSITE_ASHBY_API_KEY: ${{ secrets.WEBSITE_ASHBY_API_KEY }} + WEBSITE_ASHBY_JOB_BOARD_NAME: ${{ secrets.WEBSITE_ASHBY_JOB_BOARD_NAME }} run: vercel build --prod - name: Deploy project artifacts to Vercel diff --git a/.github/workflows/ci-website-build.yaml b/.github/workflows/ci-website-build.yaml index 832854c2eb..211ee86960 100644 --- a/.github/workflows/ci-website-build.yaml +++ b/.github/workflows/ci-website-build.yaml @@ -4,23 +4,29 @@ name: 'CI: Website Build' on: push: branches: [main, master, website/*] - paths: - - 'apps/website/**' - - 'packages/design-system/**' - - 'pnpm-lock.yaml' pull_request: branches-ignore: [wip/*, draft/*, temp/*] - paths: - - 'apps/website/**' - - 'packages/design-system/**' - - 'pnpm-lock.yaml' concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: + changes: + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + app-website-changes: ${{ steps.changes.outputs.app-website-changes }} + packages-changes: ${{ steps.changes.outputs.packages-changes }} + steps: + - uses: actions/checkout@v6 + - id: changes + uses: ./.github/actions/changes-filter + build: + needs: changes + if: ${{ needs.changes.outputs.app-website-changes == 'true' || needs.changes.outputs.packages-changes == 'true' }} runs-on: ubuntu-latest steps: @@ -30,4 +36,7 @@ jobs: uses: ./.github/actions/setup-frontend - name: Build website + env: + WEBSITE_ASHBY_API_KEY: ${{ secrets.WEBSITE_ASHBY_API_KEY }} + WEBSITE_ASHBY_JOB_BOARD_NAME: ${{ secrets.WEBSITE_ASHBY_JOB_BOARD_NAME }} run: pnpm --filter @comfyorg/website build diff --git a/.github/workflows/ci-website-e2e.yaml b/.github/workflows/ci-website-e2e.yaml index 3e56e1dba9..ea8e7f0592 100644 --- a/.github/workflows/ci-website-e2e.yaml +++ b/.github/workflows/ci-website-e2e.yaml @@ -2,26 +2,30 @@ name: 'CI: Website E2E' on: push: - branches: [main, website/*] - paths: - - 'apps/website/**' - - 'packages/design-system/**' - - 'packages/tailwind-utils/**' - - 'pnpm-lock.yaml' + branches: [main] pull_request: branches-ignore: [wip/*, draft/*, temp/*] - paths: - - 'apps/website/**' - - 'packages/design-system/**' - - 'packages/tailwind-utils/**' - - 'pnpm-lock.yaml' concurrency: - group: ${{ github.workflow }}-${{ github.ref }} + group: ${{ github.workflow }}-${{ github.repository }}-${{ github.head_ref || github.ref }} cancel-in-progress: true jobs: + changes: + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + app-website-changes: ${{ steps.changes.outputs.app-website-changes }} + packages-changes: ${{ steps.changes.outputs.packages-changes }} + steps: + - uses: actions/checkout@v6 + - id: changes + uses: ./.github/actions/changes-filter + website-e2e: + needs: changes + if: ${{ needs.changes.outputs.app-website-changes == 'true' || needs.changes.outputs.packages-changes == 'true' }} runs-on: ubuntu-latest container: image: mcr.microsoft.com/playwright:v1.58.1-noble @@ -45,6 +49,8 @@ jobs: run: pnpm install --frozen-lockfile - name: Build website + env: + WEBSITE_GITHUB_STARS_OVERRIDE: 110000 run: pnpm --filter @comfyorg/website build - name: Run Playwright tests @@ -161,7 +167,11 @@ jobs: post-starting-comment: # Safe to comment from pull_request trigger: fork PRs are excluded by the guard below. # This avoids a ci-*/pr-* workflow_run split for a comment that must appear immediately. - if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false + needs: changes + if: | + github.event_name == 'pull_request' + && github.event.pull_request.head.repo.fork == false + && (needs.changes.outputs.app-website-changes == 'true' || needs.changes.outputs.packages-changes == 'true') runs-on: ubuntu-latest permissions: pull-requests: write diff --git a/.github/workflows/pr-update-playwright-expectations.yaml b/.github/workflows/pr-update-playwright-expectations.yaml index 308c54b74f..3291d45767 100644 --- a/.github/workflows/pr-update-playwright-expectations.yaml +++ b/.github/workflows/pr-update-playwright-expectations.yaml @@ -77,7 +77,7 @@ jobs: needs: setup runs-on: ubuntu-latest container: - image: ghcr.io/comfy-org/comfyui-ci-container:0.0.16 + image: ghcr.io/comfy-org/comfyui-ci-container:0.0.17 credentials: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/pr-update-website-screenshots.yaml b/.github/workflows/pr-update-website-screenshots.yaml index 87f8163996..9e8d58e564 100644 --- a/.github/workflows/pr-update-website-screenshots.yaml +++ b/.github/workflows/pr-update-website-screenshots.yaml @@ -18,6 +18,7 @@ jobs: timeout-minutes: 15 permissions: contents: write + issues: write pull-requests: read # Trigger: (1) label, (2) /slash-command, or (3) checkbox in E2E status comment # ⚠️ This condition is duplicated on `post-starting-comment` — keep them in sync. @@ -86,6 +87,8 @@ jobs: run: pnpm install --frozen-lockfile - name: Build website + env: + WEBSITE_GITHUB_STARS_OVERRIDE: 110000 run: pnpm --filter @comfyorg/website build - name: Update screenshots @@ -137,7 +140,10 @@ jobs: name: 'Update Website Screenshots' }) } catch (e) { - // Label may already be removed + if (e.status !== 404) { + throw e + } + core.info('Label "Update Website Screenshots" was already removed') } post-starting-comment: diff --git a/.github/workflows/release-website.yaml b/.github/workflows/release-website.yaml new file mode 100644 index 0000000000..8ec080bddd --- /dev/null +++ b/.github/workflows/release-website.yaml @@ -0,0 +1,59 @@ +# Description: Manual workflow to refresh the apps/website Ashby roles snapshot +# and open a PR. Merging the PR triggers the existing Vercel website production +# deploy via ci-vercel-website-preview.yaml. +name: 'Release: Website' + +on: + workflow_dispatch: + +concurrency: + group: release-website + cancel-in-progress: true + +jobs: + refresh-snapshot: + if: github.repository == 'Comfy-Org/ComfyUI_frontend' + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + ref: main + persist-credentials: false + + - name: Refresh Ashby snapshot + uses: ./.github/actions/ashby-pull + with: + api_key: ${{ secrets.WEBSITE_ASHBY_API_KEY }} + job_board_name: ${{ secrets.WEBSITE_ASHBY_JOB_BOARD_NAME }} + + - name: Create Pull Request + uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0 + with: + token: ${{ secrets.PR_GH_TOKEN }} + commit-message: 'chore(website): refresh Ashby roles snapshot' + title: 'chore(website): refresh Ashby roles snapshot' + body: | + Automated refresh of `apps/website/src/data/ashby-roles.snapshot.json` + from the Ashby job board API. + + **Flow:** + 1. `Release: Website` workflow ran (manual trigger). + 2. This PR opens with the regenerated snapshot. + 3. `CI: Vercel Website Preview` deploys a preview for review. + 4. Merging to `main` triggers the production Vercel deploy. + + The snapshot fallback in `apps/website/src/utils/ashby.ts` remains + intact: builds without `WEBSITE_ASHBY_API_KEY` continue to use the + committed snapshot. + + Triggered by workflow run `${{ github.run_id }}`. + branch: chore/refresh-ashby-snapshot-${{ github.run_id }} + base: main + labels: | + Release:Website + delete-branch: true diff --git a/.husky/pre-push b/.husky/pre-push index dcb5564518..642dd8ac88 100755 --- a/.husky/pre-push +++ b/.husky/pre-push @@ -1,5 +1,13 @@ #!/usr/bin/env bash +# Skip in CI: the canonical knip check runs in ci-lint-format on every +# PR, and bot workflows (e.g. i18n-update-core) populate ComfyUI/ via +# setup-comfyui-server, which contaminates knip's project glob with the +# devtools copy under custom_nodes and produces false-positive failures. +if [ -n "${CI-}" ]; then + exit 0 +fi + # Run Knip with cache via package script pnpm knip 1>&2 diff --git a/apps/desktop-ui/src/assets/css/style.css b/apps/desktop-ui/src/assets/css/style.css index 1ff895f064..af95d1cd53 100644 --- a/apps/desktop-ui/src/assets/css/style.css +++ b/apps/desktop-ui/src/assets/css/style.css @@ -20,15 +20,15 @@ } .p-button-danger { - background-color: var(--color-coral-red-600); + background-color: var(--color-coral-700); } .p-button-danger:hover { - background-color: var(--color-coral-red-500); + background-color: var(--color-coral-600); } .p-button-danger:active { - background-color: var(--color-coral-red-400); + background-color: var(--color-coral-500); } .task-div .p-card { diff --git a/apps/desktop-ui/src/components/maintenance/TaskListPanel.vue b/apps/desktop-ui/src/components/maintenance/TaskListPanel.vue index 5021aa0a15..f667cacb11 100644 --- a/apps/desktop-ui/src/components/maintenance/TaskListPanel.vue +++ b/apps/desktop-ui/src/components/maintenance/TaskListPanel.vue @@ -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({ diff --git a/apps/desktop-ui/src/stores/maintenanceTaskStore.ts b/apps/desktop-ui/src/stores/maintenanceTaskStore.ts index a371327ed5..235d370ccf 100644 --- a/apps/desktop-ui/src/stores/maintenanceTaskStore.ts +++ b/apps/desktop-ui/src/stores/maintenanceTaskStore.ts @@ -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 diff --git a/apps/website/README.md b/apps/website/README.md index a20338aecb..047940ed02 100644 --- a/apps/website/README.md +++ b/apps/website/README.md @@ -113,6 +113,31 @@ git commit apps/website/src/data/ashby-roles.snapshot.json The script exits non-zero on any non-fresh outcome so stale/empty snapshots can't be accidentally committed. +## HubSpot contact form + +The contact page uses HubSpot's hosted form embed for the interest form: + +```html +<script + src="https://js-na2.hsforms.net/forms/embed/developer/244637579.js" + defer +></script> +<div + class="hs-form-html" + data-region="na2" + data-form-id="94e05eab-1373-47f7-ab5e-d84f9e6aa262" + data-portal-id="244637579" +></div> +``` + +The localized `/zh-CN/contact` page uses the same portal and script with form +ID `6885750c-02ef-4aa2-ba0d-213be9cccf93`. + +This keeps submission handling, validation, anti-spam updates, and field +configuration in HubSpot. The local implementation in +`src/components/contact/HubspotFormEmbed.vue` only loads the hosted script and +renders the documented embed container. + ## Scripts - `pnpm dev` — Astro dev server diff --git a/apps/website/e2e/homepage.spec.ts b/apps/website/e2e/homepage.spec.ts index 3797cd20ea..111c8ca5ee 100644 --- a/apps/website/e2e/homepage.spec.ts +++ b/apps/website/e2e/homepage.spec.ts @@ -69,6 +69,50 @@ test.describe('Homepage @smoke', () => { ).toBeVisible() }) + test('CaseStudySpotlight CTA sizes to its content, not the column', async ({ + page + }) => { + const contentColumn = page.getByTestId('case-study-content') + const cta = contentColumn.getByRole('link', { + name: /see all case studies/i + }) + + await cta.scrollIntoViewIfNeeded() + await expect(cta).toBeVisible() + + const [columnBox, ctaBox] = await Promise.all([ + contentColumn.boundingBox(), + cta.boundingBox() + ]) + + expect(columnBox).not.toBeNull() + expect(ctaBox).not.toBeNull() + expect(ctaBox!.width).toBeLessThan(columnBox!.width * 0.7) + }) + + test('CaseStudySpotlight CTA has breathing room above it on mobile @mobile', async ({ + page + }) => { + const contentColumn = page.getByTestId('case-study-content') + const subheading = contentColumn.getByText( + /Videos & case studies from teams/i + ) + const cta = contentColumn.getByRole('link', { + name: /see all case studies/i + }) + + await cta.scrollIntoViewIfNeeded() + + const [subBox, ctaBox] = await Promise.all([ + subheading.boundingBox(), + cta.boundingBox() + ]) + + expect(subBox).not.toBeNull() + expect(ctaBox).not.toBeNull() + expect(ctaBox!.y - (subBox!.y + subBox!.height)).toBeGreaterThanOrEqual(24) + }) + test('BuildWhatSection is visible', async ({ page }) => { // "DOESN'T EXIST" is the actual badge text rendered in the Build What section await expect(page.getByText("DOESN'T EXIST")).toBeVisible() diff --git a/apps/website/e2e/visual-responsive.spec.ts-snapshots/contact-form-1-sm-visual-linux.png b/apps/website/e2e/visual-responsive.spec.ts-snapshots/contact-form-1-sm-visual-linux.png index 65a242f834..a0f2f36815 100644 Binary files a/apps/website/e2e/visual-responsive.spec.ts-snapshots/contact-form-1-sm-visual-linux.png and b/apps/website/e2e/visual-responsive.spec.ts-snapshots/contact-form-1-sm-visual-linux.png differ diff --git a/apps/website/package.json b/apps/website/package.json index 1f72fc97e3..5a4aa57c3b 100644 --- a/apps/website/package.json +++ b/apps/website/package.json @@ -26,6 +26,7 @@ "cva": "catalog:", "gsap": "catalog:", "lenis": "catalog:", + "posthog-js": "catalog:", "vue": "catalog:", "zod": "catalog:" }, diff --git a/apps/website/public/llms.txt b/apps/website/public/llms.txt new file mode 100644 index 0000000000..8f95cf6645 --- /dev/null +++ b/apps/website/public/llms.txt @@ -0,0 +1,58 @@ +# Comfy + +> Comfy is the AI creation engine for visual professionals who demand control over every model, every parameter, and every output. Built around ComfyUI — the open-source node-graph runtime with 60,000+ community nodes and thousands of shared workflows — Comfy ships as a free local app, a managed cloud, an API, and an enterprise platform. + +The Comfy ecosystem spans four surfaces: + +- **ComfyUI (local)** — the open-source node-graph runtime that runs models on your own hardware. +- **Comfy Cloud** — managed ComfyUI in the browser, with hosted models and storage. +- **Comfy API** — a REST API for triggering workflows from your own apps and pipelines. +- **Comfy Enterprise** — single-tenant deployments, BYO keys, data ownership, and orchestration for teams. + +Studios building with Comfy include Series Entertainment, Moment Factory, Open Story Movement, and Ubisoft (La Forge). Use cases concentrate in VFX & animation, advertising & creative studios, gaming, and eCommerce/fashion. + +## Product + +- [Homepage](https://comfy.org/): Overview of Comfy and the four product surfaces (Local, Cloud, API, Enterprise). +- [Download Comfy (Local)](https://comfy.org/download/): Free desktop app for macOS, Windows, and Linux — runs ComfyUI on your own GPU. +- [Comfy Cloud](https://comfy.org/cloud/): Managed ComfyUI in the browser with hosted models and storage; no local install required. +- [Comfy Cloud Pricing](https://comfy.org/cloud/pricing/): Plans and per-credit pricing for individuals and teams using Comfy Cloud. +- [Comfy API](https://comfy.org/api/): REST API for triggering ComfyUI workflows programmatically from external apps. +- [Comfy Enterprise](https://comfy.org/cloud/enterprise/): Single-tenant ComfyUI deployments with BYO keys, orchestration, and data-ownership guarantees. + +## Workflows and Gallery + +- [Workflow Gallery](https://comfy.org/gallery/): Curated showcase of ComfyUI outputs — images, video, and 3D — produced by the community. +- [Community Workflows](https://www.comfy.org/workflows/): Browseable library of community-shared ComfyUI workflows you can load and remix. + +## Customers and Case Studies + +- [Customer Stories](https://comfy.org/customers/): Index of named customers and how they use ComfyUI in production. +- [Series Entertainment](https://comfy.org/customers/series-entertainment/): How Series Entertainment rebuilt game and video production around ComfyUI. +- [Moment Factory](https://comfy.org/customers/moment-factory/): Architectural-scale 3D projection mapping reimagined with ComfyUI at Moment Factory. +- [Ubisoft — Chord](https://comfy.org/customers/ubisoft-chord/): Ubisoft La Forge open-sourcing the Chord model and its ComfyUI integration. +- [Open Story Movement](https://comfy.org/customers/open-story-movement/): How an open-source movement around AI storytelling builds on ComfyUI. + +## Developers and Documentation + +- [ComfyUI Docs](https://docs.comfy.org/): Official documentation for installing, configuring, and extending ComfyUI. +- [ComfyUI on GitHub](https://github.com/comfyanonymous/ComfyUI): Source repository for the open-source ComfyUI runtime. +- [Comfy-Org on GitHub](https://github.com/Comfy-Org): Organization-wide repositories — frontend, registry, manager, docs, and tooling. +- [Comfy Registry](https://registry.comfy.org/): Public registry of ComfyUI custom nodes and extensions, with versioning and search. + +## Company + +- [About Comfy](https://comfy.org/about/): Company background, mission, and the team behind ComfyUI. +- [Careers](https://comfy.org/careers/): Open roles across engineering, design, product, and go-to-market. +- [Contact](https://comfy.org/contact/): Sales, partnership, and general contact form. +- [Blog](https://blog.comfy.org/): Product announcements, technical deep-dives, and customer stories. +- [Privacy Policy](https://comfy.org/privacy-policy/): How Comfy collects, uses, and protects personal information. +- [Terms of Service](https://comfy.org/terms-of-service/): Terms governing use of ComfyUI and related Comfy services. + +## Optional + +- [简体中文 / Chinese homepage](https://comfy.org/zh-CN/): Simplified Chinese localization of the main site. +- [Series Entertainment — long-form case study](https://comfy.org/cloud/enterprise-case-studies/how-series-entertainment-rebuilt-game-and-video-production-with-comfyui): Extended write-up of the Series Entertainment deployment. +- [Moment Factory — long-form case study](https://comfy.org/cloud/enterprise-case-studies/comfyui-at-architectural-scale-how-moment-factory-reimagined-3d-projection-mapping): Extended write-up of Moment Factory's projection-mapping pipeline. +- [Ubisoft Chord announcement (blog)](https://blog.comfy.org/p/ubisoft-open-sources-the-chord-model): Original blog post announcing Ubisoft's open-source Chord model. +- [Open-source storytelling (blog)](https://blog.comfy.org/p/how-open-source-is-fueling-the-open): Blog post on how open source is fueling the Open Story Movement. diff --git a/apps/website/public/robots.txt b/apps/website/public/robots.txt index b1931edbd8..5e6114b55e 100644 --- a/apps/website/public/robots.txt +++ b/apps/website/public/robots.txt @@ -1,4 +1,33 @@ +# robots.txt for comfy.org +# Open to all crawlers — including AI/LLM bots — for maximum visibility +# in AI-powered search, chat-based answer engines, and traditional search. +# Granular UAs are listed explicitly to signal intent; rules are shared +# via stacked user-agent records (RFC 9309 §2.2). + User-agent: * +User-agent: Googlebot +User-agent: Bingbot +User-agent: DuckDuckBot +User-agent: GPTBot +User-agent: ChatGPT-User +User-agent: OAI-SearchBot +User-agent: Google-Extended +User-agent: ClaudeBot +User-agent: Claude-Web +User-agent: anthropic-ai +User-agent: PerplexityBot +User-agent: Perplexity-User +User-agent: Applebot +User-agent: Applebot-Extended +User-agent: Bytespider +User-agent: Amazonbot +User-agent: CCBot +User-agent: Meta-ExternalAgent +User-agent: Meta-ExternalFetcher +User-agent: Diffbot Allow: / +Disallow: /_astro/ +Disallow: /_website/ +Disallow: /_vercel/ Sitemap: https://comfy.org/sitemap-index.xml diff --git a/apps/website/scripts/README.md b/apps/website/scripts/README.md new file mode 100644 index 0000000000..3420245b11 --- /dev/null +++ b/apps/website/scripts/README.md @@ -0,0 +1,83 @@ +# Website Scripts + +## `refresh-ashby-snapshot.ts` + +Pulls the latest job postings from Ashby and writes +`src/data/ashby-roles.snapshot.json`. Invoked by the `Release: Website` +GitHub Actions workflow; also runnable locally via +`pnpm --filter @comfyorg/website ashby:refresh-snapshot`. + +## `process-videos.sh` + +Generates multi-resolution VP9/WebM + H.264/MP4 variants and a poster +frame for marketing videos using `ffmpeg`. Run **locally** before +uploading the outputs to `media.comfy.org`; this is not wired into CI. + +```sh +apps/website/scripts/process-videos.sh \ + ./video-sources \ + ./dist/videos \ + "640 960 1280 1920" +``` + +### Output + +For each source video at `./video-sources/foo.mp4`, you get: + +```text +foo-640.webm foo-640.mp4 +foo-960.webm foo-960.mp4 +foo-1280.webm foo-1280.mp4 +foo-1920.webm foo-1920.mp4 +foo-poster.jpg +``` + +The naming convention is enforced by `buildVideoSources()` in +`src/utils/video.ts`, which the `<SiteVideo>` Vue component uses to +emit `<source>` URLs. + +### Pairing with `<SiteVideo>` + +Once the assets are uploaded, render them with: + +```vue +<SiteVideo + name="foo" + base-url="https://media.comfy.org/website/marketing" + :width="1280" + :formats="['webm', 'mp4']" + poster="https://media.comfy.org/website/marketing/foo-poster.jpg" + autoplay + loop +/> +``` + +### `<SiteVideo>` vs `<VideoPlayer>` + +- **`SiteVideo`** — lightweight multi-source `<video>` for decorative or + autoplay marketing clips. No custom controls, no captions UI. +- **`VideoPlayer`** — full-featured player with custom scrubber, mute, + fullscreen, and caption toggles. Use this for content with subtitles or + user-driven playback. + +If you need both responsive sources and the rich `VideoPlayer` chrome, the +two are not yet combined; either pick one or extend `VideoPlayer` to accept +a source list. + +### Encoder choices + +- **VP9/WebM** at CRF 32 — preferred by Chrome and Firefox; smaller files. +- **H.264/MP4** at CRF 23, High profile, `+faststart` — universal fallback, + required for Safari iOS. +- **Poster JPG** at q4 — extracted from t=1s when the clip is long enough, + otherwise t=0; scaled to 1280w. Use this as the `poster` attribute so + the video shows something while loading. + +### Why a single resolution per video + +`<source media="...">` inside `<video>` is unreliable across browsers +(Safari ignores it). The simplest correct strategy is to ship one +well-sized resolution and let CSS scale it down on smaller viewports. +The script generates multiple widths so you can pick a different one +per page (e.g. 1280w for a hero, 640w for a thumbnail), or wire up +JavaScript-based selection later if metrics demand it. diff --git a/apps/website/scripts/process-videos.sh b/apps/website/scripts/process-videos.sh new file mode 100755 index 0000000000..ae2ef4d305 --- /dev/null +++ b/apps/website/scripts/process-videos.sh @@ -0,0 +1,110 @@ +#!/usr/bin/env bash +# +# Generate multi-resolution VP9/WebM + H.264/MP4 variants and a poster frame +# for every source video in a given directory. Intended to be run locally +# before uploading the outputs to media.comfy.org. +# +# Usage: +# apps/website/scripts/process-videos.sh <input-dir> <output-dir> [widths] +# +# Example: +# apps/website/scripts/process-videos.sh \ +# ./video-sources \ +# ./dist/videos \ +# "640 960 1280 1920" +# +# Defaults to widths "1280" if omitted. +# +# Output naming matches buildVideoSources() in src/utils/video.ts: +# <name>-<width>.webm +# <name>-<width>.mp4 +# <name>-poster.jpg (single 1280w poster, suitable for SiteVideo) +# +# Requires ffmpeg and ffprobe on PATH. Tested with ffmpeg 6.x and 7.x. + +set -euo pipefail + +if [[ $# -lt 2 ]]; then + cat <<USAGE >&2 +Usage: $0 <input-dir> <output-dir> [widths] + widths: space-separated list, e.g. "640 1280 1920" (default: "1280") +USAGE + exit 64 +fi + +input_dir=$1 +output_dir=$2 +widths=${3:-1280} + +for tool in ffmpeg ffprobe; do + if ! command -v "$tool" >/dev/null 2>&1; then + echo "error: $tool not found on PATH" >&2 + exit 127 + fi +done + +if [[ ! -d $input_dir ]]; then + echo "error: input dir not found: $input_dir" >&2 + exit 66 +fi + +mkdir -p "$output_dir" + +shopt -s nullglob nocaseglob +sources=("$input_dir"/*.{mp4,mov,webm,mkv}) +shopt -u nullglob nocaseglob + +if [[ ${#sources[@]} -eq 0 ]]; then + echo "error: no source videos in $input_dir (looked for .mp4 .mov .webm .mkv)" >&2 + exit 66 +fi + +for src in "${sources[@]}"; do + name=$(basename "$src") + name=${name%.*} + echo "==> $name" + + for w in $widths; do + webm_out="$output_dir/${name}-${w}.webm" + mp4_out="$output_dir/${name}-${w}.mp4" + + echo " encoding ${w}w VP9/WebM -> $webm_out" + ffmpeg -y -hide_banner -loglevel error \ + -i "$src" \ + -vf "scale=${w}:-2:flags=lanczos" \ + -c:v libvpx-vp9 -b:v 0 -crf 32 -row-mt 1 -tile-columns 2 \ + -c:a libopus -b:a 96k \ + -f webm "$webm_out" + + echo " encoding ${w}w H.264/MP4 -> $mp4_out" + ffmpeg -y -hide_banner -loglevel error \ + -i "$src" \ + -vf "scale=${w}:-2:flags=lanczos" \ + -c:v libx264 -crf 23 -preset slow -profile:v high -pix_fmt yuv420p \ + -c:a aac -b:a 128k \ + -movflags +faststart \ + "$mp4_out" + done + + poster_out="$output_dir/${name}-poster.jpg" + duration_raw=$(ffprobe -v error -show_entries format=duration \ + -of default=noprint_wrappers=1:nokey=1 "$src" 2>/dev/null || true) + if [[ $duration_raw =~ ^[0-9]+([.][0-9]+)?$ ]]; then + duration="$duration_raw" + else + duration=0 + fi + if awk -v d="$duration" 'BEGIN { exit !(d >= 1.0) }'; then + poster_seek=1 + else + poster_seek=0 + fi + echo " extracting poster (t=${poster_seek}s) -> $poster_out" + ffmpeg -y -hide_banner -loglevel error \ + -ss "$poster_seek" -i "$src" \ + -vframes 1 -vf "scale=1280:-2:flags=lanczos" \ + -q:v 4 \ + "$poster_out" +done + +echo "done. upload contents of $output_dir to media.comfy.org." diff --git a/apps/website/src/assets/marketing/README.md b/apps/website/src/assets/marketing/README.md new file mode 100644 index 0000000000..5638b82e83 --- /dev/null +++ b/apps/website/src/assets/marketing/README.md @@ -0,0 +1,51 @@ +# Marketing Assets + +Source images committed here are processed by Astro at build time and emitted +as multiple formats (AVIF, WebP) at multiple widths (640w, 960w, 1280w, 1920w). + +## Usage + +Drop a high-resolution source image (PNG or JPG) here, then render it with +Astro's built-in `<Picture>` component plus the shared defaults: + +```astro +--- +import { Picture } from 'astro:assets' +import { + MARKETING_FORMATS, + MARKETING_WIDTHS +} from '../utils/marketingImage' +import hero from '../assets/marketing/hero.png' +--- +<Picture + src={hero} + alt="ComfyUI workflow preview" + formats={[...MARKETING_FORMATS]} + widths={[...MARKETING_WIDTHS]} + sizes="(max-width: 768px) 100vw, 50vw" +/> +``` + +The component generates a `<picture>` element with `<source>` tags for AVIF +and WebP, plus an `<img>` fallback. Output files are hashed and emitted under +`dist/_website/` for long-term caching. + +A custom Astro wrapper component is intentionally not provided: Astro's +discriminated union `LocalImageProps | RemoteImageProps` for `<Picture>` makes +a thin wrapper that mutates `widths` / `formats` impractical to type safely +without `as` casts. The shared constants give us the same consistency benefit +without that cost. + +## When to use this vs. `media.comfy.org` + +- **Use `src/assets/marketing/`** for static marketing images that are part of + page content (hero shots, product imagery, illustrations). Build-time + processing gives you AVIF/WebP variants automatically. +- **Use `media.comfy.org`** for video content, large/changing image libraries + (gallery), and anything shared across properties. + +## Source image guidelines + +- Provide the largest size you'll ever need (≥1920px wide). +- PNG for screenshots/illustrations with sharp edges; JPG for photographs. +- Astro will downscale; it will not upscale. Always supply at least 1920w. diff --git a/apps/website/src/components/common/SiteFooter.vue b/apps/website/src/components/common/SiteFooter.vue index 6377fbaef7..ce930a7109 100644 --- a/apps/website/src/components/common/SiteFooter.vue +++ b/apps/website/src/components/common/SiteFooter.vue @@ -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' } diff --git a/apps/website/src/components/common/SiteVideo.vue b/apps/website/src/components/common/SiteVideo.vue new file mode 100644 index 0000000000..ea149d0cfc --- /dev/null +++ b/apps/website/src/components/common/SiteVideo.vue @@ -0,0 +1,68 @@ +<script setup lang="ts"> +import { cn } from '@comfyorg/tailwind-utils' +import { computed } from 'vue' + +import { buildVideoSources, videoKey } from '../../utils/video' +import type { VideoFormat } from '../../utils/video' + +const { + name, + baseUrl, + width = 1280, + formats = ['webm', 'mp4'], + poster, + alt, + autoplay = false, + loop = false, + muted = autoplay, + controls = false, + preload = autoplay ? 'auto' : 'metadata', + containerClass, + videoClass +} = defineProps<{ + name: string + baseUrl: string + width?: number + formats?: VideoFormat[] + poster?: string + alt?: string + autoplay?: boolean + loop?: boolean + muted?: boolean + controls?: boolean + preload?: 'auto' | 'metadata' | 'none' + containerClass?: string + videoClass?: string +}>() + +const sources = computed(() => + buildVideoSources({ name, baseUrl, width, formats }) +) +const remountKey = computed(() => videoKey(sources.value)) +const decorative = computed(() => !alt && !controls) +</script> + +<template> + <div :class="cn('relative', containerClass)"> + <video + :key="remountKey" + :class="cn('size-full', videoClass)" + :poster + :preload + :autoplay + :loop + :muted + :controls + :aria-label="alt" + :aria-hidden="decorative ? true : undefined" + playsinline + > + <source + v-for="source in sources" + :key="source.src" + :src="source.src" + :type="source.type" + /> + </video> + </div> +</template> diff --git a/apps/website/src/components/contact/FormSection.vue b/apps/website/src/components/contact/FormSection.vue index dbefd4b80c..114a16ae34 100644 --- a/apps/website/src/components/contact/FormSection.vue +++ b/apps/website/src/components/contact/FormSection.vue @@ -1,13 +1,12 @@ <script setup lang="ts"> -import { cn } from '@comfyorg/tailwind-utils' import { ref } from 'vue' import type { Locale, TranslationKey } from '../../i18n/translations' import { useHeroAnimation } from '../../composables/useHeroAnimation' import { t } from '../../i18n/translations' -import BrandButton from '../common/BrandButton.vue' import SectionLabel from '../common/SectionLabel.vue' +import HubspotFormEmbed from './HubspotFormEmbed.vue' const { locale = 'en' } = defineProps<{ locale?: Locale @@ -17,30 +16,6 @@ function tk(suffix: string): TranslationKey { return `contact.form.${suffix}` as TranslationKey } -const firstName = ref('') -const lastName = ref('') -const company = ref('') -const phone = ref('') -const selectedPackage = ref('') -const comfyUsage = ref('') -const lookingFor = ref('') - -const packageOptions = [ - 'packageIndividual', - 'packageTeams', - 'packageEnterprise' -] as const - -const usageOptions = [ - 'usingYesProduction', - 'usingYesTesting', - 'usingNotYet', - 'usingOtherTools' -] as const - -const inputClass = - 'text-primary-comfy-canvas placeholder:text-primary-comfy-canvas/30 border-primary-warm-gray/20 focus:border-primary-comfy-yellow mt-2 w-full rounded-2xl border bg-transparency-white-t4 p-4 text-sm transition-colors outline-none' - const sectionRef = ref<HTMLElement>() const badgeRef = ref<HTMLElement>() const headingRef = ref<HTMLElement>() @@ -55,10 +30,6 @@ useHeroAnimation({ video: formRef, parallax: false }) - -function handleSubmit() { - // TODO: implement form submission -} </script> <template> @@ -105,160 +76,7 @@ function handleSubmit() { <!-- Right column: form --> <div ref="formRef" class="mt-12 lg:mt-0 lg:w-1/2"> - <form class="space-y-6" @submit.prevent="handleSubmit"> - <!-- First Name + Last Name --> - <div class="lg:grid lg:grid-cols-2 lg:gap-4"> - <div> - <label class="text-primary-comfy-canvas text-xs"> - {{ t(tk('firstName'), locale) }}* - </label> - <input - v-model="firstName" - type="text" - required - :placeholder="t(tk('firstNamePlaceholder'), locale)" - :class="inputClass" - /> - </div> - <div class="mt-6 lg:mt-0"> - <label class="text-primary-comfy-canvas text-xs"> - {{ t(tk('lastName'), locale) }}* - </label> - <input - v-model="lastName" - type="text" - required - :placeholder="t(tk('lastNamePlaceholder'), locale)" - :class="inputClass" - /> - </div> - </div> - - <!-- Company + Phone --> - <div class="lg:grid lg:grid-cols-2 lg:gap-4"> - <div> - <label class="text-primary-comfy-canvas text-xs"> - {{ t(tk('company'), locale) }}* - </label> - <input - v-model="company" - type="text" - required - :placeholder="t(tk('companyPlaceholder'), locale)" - :class="inputClass" - /> - </div> - <div class="mt-6 lg:mt-0"> - <label class="text-primary-comfy-canvas text-xs"> - {{ t(tk('phone'), locale) }} - </label> - <input v-model="phone" type="tel" :class="inputClass" /> - </div> - </div> - - <!-- Package selection --> - <div> - <p class="text-primary-comfy-canvas text-xs"> - {{ t(tk('packageQuestion'), locale) }} - </p> - <div class="mt-3 flex flex-wrap gap-3"> - <label - v-for="opt in packageOptions" - :key="opt" - :class=" - cn( - 'bg-transparency-white-t4 flex cursor-pointer items-center gap-2 rounded-lg border px-6 py-2 text-xs font-bold tracking-wider transition-colors', - selectedPackage === opt - ? 'border-primary-comfy-yellow text-primary-comfy-yellow' - : 'text-primary-comfy-canvas border-(--site-border-subtle)' - ) - " - > - <input - v-model="selectedPackage" - type="radio" - name="package" - :value="opt" - class="sr-only" - /> - <span - :class=" - cn( - 'flex size-4 shrink-0 items-center justify-center rounded-full border', - selectedPackage === opt - ? 'border-primary-comfy-yellow' - : 'border-primary-warm-gray/40' - ) - " - > - <span - v-if="selectedPackage === opt" - class="bg-primary-comfy-yellow size-2 rounded-full" - /> - </span> - {{ t(tk(opt), locale) }} - </label> - </div> - </div> - - <!-- Comfy usage --> - <div> - <p class="text-primary-comfy-canvas text-xs"> - {{ t(tk('usingComfy'), locale) }} - </p> - <div class="mt-3 space-y-3"> - <label - v-for="opt in usageOptions" - :key="opt" - class="flex cursor-pointer items-center gap-3" - > - <span - :class=" - cn( - 'flex size-4 shrink-0 items-center justify-center rounded-full border', - comfyUsage === opt - ? 'border-primary-comfy-yellow' - : 'border-(--site-border-subtle)' - ) - " - > - <span - v-if="comfyUsage === opt" - class="bg-primary-comfy-yellow size-2 rounded-full" - /> - </span> - <input - v-model="comfyUsage" - type="radio" - :value="opt" - class="sr-only" - /> - <span class="text-primary-comfy-canvas text-sm"> - {{ t(tk(opt), locale) }} - </span> - </label> - </div> - </div> - - <!-- Looking for --> - <div> - <label class="text-primary-comfy-canvas text-xs"> - {{ t(tk('lookingFor'), locale) }} - </label> - <textarea - v-model="lookingFor" - :placeholder="t(tk('lookingForPlaceholder'), locale)" - :class="cn(inputClass, 'min-h-24 resize-y')" - /> - </div> - - <!-- Submit --> - <div> - <BrandButton type="submit" variant="outline" size="sm"> - {{ t(tk('submit'), locale) }} - </BrandButton> - </div> - </form> + <HubspotFormEmbed :locale /> </div> </section> </template> diff --git a/apps/website/src/components/contact/HubspotFormEmbed.vue b/apps/website/src/components/contact/HubspotFormEmbed.vue new file mode 100644 index 0000000000..6f4c604f85 --- /dev/null +++ b/apps/website/src/components/contact/HubspotFormEmbed.vue @@ -0,0 +1,126 @@ +<script setup lang="ts"> +import { computed, onMounted, ref } from 'vue' + +import type { Locale } from '../../i18n/translations' + +import { t } from '../../i18n/translations' + +const { locale = 'en' } = defineProps<{ + locale?: Locale +}>() + +const HUBSPOT_CONTACT_PORTAL_ID = '244637579' +const HUBSPOT_CONTACT_REGION = 'na2' +const HUBSPOT_CONTACT_SCRIPT_ID = 'hubspot-contact-form-embed' +const HUBSPOT_CONTACT_SCRIPT_SRC = `https://js-${HUBSPOT_CONTACT_REGION}.hsforms.net/forms/embed/developer/${HUBSPOT_CONTACT_PORTAL_ID}.js` + +const hubspotContactFormIds: Record<Locale, string> = { + en: '94e05eab-1373-47f7-ab5e-d84f9e6aa262', + 'zh-CN': '6885750c-02ef-4aa2-ba0d-213be9cccf93' +} + +const hasEmbedLoadError = ref(false) +const hubspotContactFormId = computed(() => hubspotContactFormIds[locale]) + +const hubspotFormStyles: Record<`--${string}`, string> = { + '--hsf-global__font-family': "'PP Formula', sans-serif", + '--hsf-global__color': '#c2bfb9', + '--hsf-background__background-color': '#211927', + '--hsf-background__border-width': '0', + '--hsf-background__padding': '0', + '--hsf-button__font-family': "'PP Formula', sans-serif", + '--hsf-button__font-size': '14px', + '--hsf-button__color': '#211927', + '--hsf-button__background-color': '#f2ff59', + '--hsf-button__border-radius': '16px', + '--hsf-button__padding': '10px 24px', + '--hsf-richtext__font-family': "'PP Formula', sans-serif", + '--hsf-richtext__color': '#c2bfb9', + '--hsf-heading__font-family': "'PP Formula', sans-serif", + '--hsf-heading__color': '#c2bfb9', + '--hsf-field-label__font-family': "'PP Formula', sans-serif", + '--hsf-field-label__font-size': '12px', + '--hsf-field-label__color': '#c2bfb9', + '--hsf-field-description__font-family': "'PP Formula', sans-serif", + '--hsf-field-description__color': '#c2bfb9', + '--hsf-field-footer__font-family': "'PP Formula', sans-serif", + '--hsf-field-footer__color': '#c2bfb9', + '--hsf-field-input__font-family': "'PP Formula', sans-serif", + '--hsf-field-input__color': '#c2bfb9', + '--hsf-field-input__background-color': '#2a2230', + '--hsf-field-input__placeholder-color': '#585159', + '--hsf-field-input__border-color': '#3b3539', + '--hsf-field-input__border-width': '1px', + '--hsf-field-input__border-style': 'solid', + '--hsf-field-input__border-radius': '16px', + '--hsf-field-input__padding': '16px', + '--hsf-field-textarea__font-family': "'PP Formula', sans-serif", + '--hsf-field-textarea__color': '#c2bfb9', + '--hsf-field-textarea__background-color': '#2a2230', + '--hsf-field-textarea__placeholder-color': '#585159', + '--hsf-field-textarea__border-color': '#3b3539', + '--hsf-field-textarea__border-width': '1px', + '--hsf-field-textarea__border-style': 'solid', + '--hsf-field-textarea__border-radius': '16px', + '--hsf-field-textarea__padding': '16px', + '--hsf-field-checkbox__color': '#c2bfb9', + '--hsf-field-checkbox__background-color': '#2a2230', + '--hsf-field-checkbox__border-color': '#464147', + '--hsf-field-checkbox__border-width': '1px', + '--hsf-field-checkbox__border-style': 'solid', + '--hsf-field-radio__color': '#c2bfb9', + '--hsf-field-radio__background-color': '#2a2230', + '--hsf-field-radio__border-color': '#464147', + '--hsf-field-radio__border-width': '1px', + '--hsf-field-radio__border-style': 'solid', + '--hsf-erroralert__font-family': "'PP Formula', sans-serif", + '--hsf-infoalert__font-family': "'PP Formula', sans-serif" +} + +onMounted(() => { + if (document.getElementById(HUBSPOT_CONTACT_SCRIPT_ID)) return + + const script = document.createElement('script') + script.id = HUBSPOT_CONTACT_SCRIPT_ID + script.src = HUBSPOT_CONTACT_SCRIPT_SRC + script.defer = true + script.addEventListener( + 'error', + () => { + hasEmbedLoadError.value = true + script.remove() + }, + { once: true } + ) + + document.head.append(script) +}) +</script> + +<template> + <div class="min-h-[640px] w-full"> + <p + v-if="hasEmbedLoadError" + class="text-primary-comfy-canvas text-sm/6" + role="status" + > + {{ t('contact.form.embedLoadErrorPrefix', locale) }} + <a + class="text-primary-comfy-yellow underline" + href="mailto:hello@comfy.org" + > + hello@comfy.org + </a> + {{ t('contact.form.embedLoadErrorSuffix', locale) }} + </p> + <div + v-else + :key="hubspotContactFormId" + class="hs-form-html" + :style="hubspotFormStyles" + :data-region="HUBSPOT_CONTACT_REGION" + :data-form-id="hubspotContactFormId" + :data-portal-id="HUBSPOT_CONTACT_PORTAL_ID" + /> + </div> +</template> diff --git a/apps/website/src/components/home/CaseStudySpotlightSection.vue b/apps/website/src/components/home/CaseStudySpotlightSection.vue index 7dd46c9a4a..9b033a1906 100644 --- a/apps/website/src/components/home/CaseStudySpotlightSection.vue +++ b/apps/website/src/components/home/CaseStudySpotlightSection.vue @@ -35,7 +35,10 @@ const routes = getRoutes(locale) </div> <!-- Right: content --> - <div class="flex flex-col justify-between p-6 lg:flex-1"> + <div + data-testid="case-study-content" + class="flex flex-col justify-between p-6 lg:flex-1" + > <div class="flex flex-col gap-8"> <p class="text-primary-comfy-yellow text-sm font-bold tracking-widest uppercase" @@ -52,12 +55,8 @@ const routes = getRoutes(locale) </p> </div> - <div class="flex flex-col gap-3 sm:flex-row"> - <BrandButton - :href="routes.customers" - variant="outline" - class="flex-1 text-center" - > + <div class="mt-8 flex flex-col items-start gap-3 sm:flex-row lg:mt-0"> + <BrandButton :href="routes.customers" variant="outline"> {{ t('caseStudy.seeAll', locale) }} </BrandButton> </div> diff --git a/apps/website/src/components/home/HeroSection.vue b/apps/website/src/components/home/HeroSection.vue index 2a520c0b90..7cb4978442 100644 --- a/apps/website/src/components/home/HeroSection.vue +++ b/apps/website/src/components/home/HeroSection.vue @@ -1,6 +1,8 @@ <script setup lang="ts"> import type { Locale } from '../../i18n/translations' +import { externalLinks } from '../../config/routes' import { t } from '../../i18n/translations' +import BrandButton from '../common/BrandButton.vue' const { locale = 'en' } = defineProps<{ locale?: Locale }>() </script> @@ -32,6 +34,15 @@ const { locale = 'en' } = defineProps<{ locale?: Locale }>() > {{ t('hero.subtitle', locale) }} </p> + + <BrandButton + :href="externalLinks.workflows" + variant="outline" + size="lg" + class="mt-8 w-full p-4 uppercase lg:w-auto lg:min-w-60" + > + {{ t('hero.runFirstWorkflow', locale) }} + </BrandButton> </div> </section> </template> diff --git a/apps/website/src/components/home/UseCaseSection.vue b/apps/website/src/components/home/UseCaseSection.vue index ea1f921587..efa7c18492 100644 --- a/apps/website/src/components/home/UseCaseSection.vue +++ b/apps/website/src/components/home/UseCaseSection.vue @@ -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> diff --git a/apps/website/src/components/pricing/WhatsIncludedSection.vue b/apps/website/src/components/pricing/WhatsIncludedSection.vue index ed809b84ee..f67a9b94e2 100644 --- a/apps/website/src/components/pricing/WhatsIncludedSection.vue +++ b/apps/website/src/components/pricing/WhatsIncludedSection.vue @@ -101,17 +101,9 @@ const features: IncludedFeature[] = [ class="mt-0.5 size-4 shrink-0" aria-hidden="true" /> - <div> - <p class="text-primary-comfy-canvas text-sm font-medium"> - {{ t(feature.titleKey, locale) }} - </p> - <span - v-if="feature.isComingSoon" - class="text-primary-comfy-yellow mt-1 inline-block text-xs" - > - {{ t('pricing.included.comingSoon', locale) }} - </span> - </div> + <p class="text-primary-comfy-canvas text-sm font-medium"> + {{ t(feature.titleKey, locale) }} + </p> </div> <!-- Description --> diff --git a/apps/website/src/composables/usePinScrub.ts b/apps/website/src/composables/usePinScrub.ts index 14091fe60d..0eb68904be 100644 --- a/apps/website/src/composables/usePinScrub.ts +++ b/apps/website/src/composables/usePinScrub.ts @@ -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 diff --git a/apps/website/src/config/customerStories.ts b/apps/website/src/config/customerStories.ts index 5042cc2269..0f5449967e 100644 --- a/apps/website/src/config/customerStories.ts +++ b/apps/website/src/config/customerStories.ts @@ -52,6 +52,15 @@ export const customerStories: CustomerStory[] = [ detailPrefix: 'customers.detail.ubisoft-chord', readMoreHref: 'https://blog.comfy.org/p/ubisoft-open-sources-the-chord-model' + }, + { + slug: 'groove-jones', + image: + 'https://media.comfy.org/website/customers/groove-jones/crocs-nfl-dicks-sporting-goods-fooh.webp', + category: 'customers.story.groove-jones.category', + title: 'customers.story.groove-jones.title', + body: 'customers.story.groove-jones.body', + detailPrefix: 'customers.detail.groove-jones' } ] diff --git a/apps/website/src/config/routes.ts b/apps/website/src/config/routes.ts index 97d3455731..8fa008e3b9 100644 --- a/apps/website/src/config/routes.ts +++ b/apps/website/src/config/routes.ts @@ -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 diff --git a/apps/website/src/data/ashby-roles.snapshot.json b/apps/website/src/data/ashby-roles.snapshot.json index 86220f97fc..b738e98407 100644 --- a/apps/website/src/data/ashby-roles.snapshot.json +++ b/apps/website/src/data/ashby-roles.snapshot.json @@ -1,24 +1,10 @@ { - "fetchedAt": "2026-04-24T18:59:03.989Z", + "fetchedAt": "2026-05-02T20:15:18.321Z", "departments": [ { "name": "DESIGN", "key": "design", "roles": [ - { - "id": "4c5d6afb78652df7", - "title": "Freelance Motion Designer", - "department": "Design", - "location": "San Francisco", - "applyUrl": "https://jobs.ashbyhq.com/comfy-org/a7ccc2b4-4d9d-4e04-b39c-28a711995b5b/application" - }, - { - "id": "0f5256cf302e552b", - "title": "Creative Artist", - "department": "Design", - "location": "San Francisco", - "applyUrl": "https://jobs.ashbyhq.com/comfy-org/19ba10aa-4961-45e8-8473-66a8a7a8079d/application" - }, { "id": "e915f2c78b17f93b", "title": "Senior Product Designer", @@ -33,13 +19,6 @@ "location": "San Francisco", "applyUrl": "https://jobs.ashbyhq.com/comfy-org/abc787b9-ad85-421c-8218-debd23bea096/application" }, - { - "id": "5746486d87874937", - "title": "Graphic Designer", - "department": "Design", - "location": "San Francisco", - "applyUrl": "https://jobs.ashbyhq.com/comfy-org/49fa0b07-3fa1-4a3a-b2c6-d2cc684ad63f/application" - }, { "id": "547b6ba622c800a5", "title": "Senior Product Designer - Craft", @@ -115,6 +94,13 @@ "department": "Engineering", "location": "San Francisco", "applyUrl": "https://jobs.ashbyhq.com/comfy-org/9e4b9029-c3e9-436b-82c4-a1a9f1b8c16e/application" + }, + { + "id": "2eb53e8943cc9396", + "title": "Growth Engineer", + "department": "Engineering", + "location": "San Francisco", + "applyUrl": "https://jobs.ashbyhq.com/comfy-org/f1fdde76-84ae-48c1-b0f9-9654dd8e7de5/application" } ] }, @@ -122,6 +108,27 @@ "name": "MARKETING", "key": "marketing", "roles": [ + { + "id": "4c5d6afb78652df7", + "title": "Freelance Motion Designer", + "department": "Marketing", + "location": "San Francisco", + "applyUrl": "https://jobs.ashbyhq.com/comfy-org/a7ccc2b4-4d9d-4e04-b39c-28a711995b5b/application" + }, + { + "id": "0f5256cf302e552b", + "title": "Creative Artist", + "department": "Marketing", + "location": "San Francisco", + "applyUrl": "https://jobs.ashbyhq.com/comfy-org/19ba10aa-4961-45e8-8473-66a8a7a8079d/application" + }, + { + "id": "5746486d87874937", + "title": "Graphic Designer", + "department": "Marketing", + "location": "San Francisco", + "applyUrl": "https://jobs.ashbyhq.com/comfy-org/49fa0b07-3fa1-4a3a-b2c6-d2cc684ad63f/application" + }, { "id": "b5803a0d4785d406", "title": "Lifecycle Growth Marketer", @@ -144,7 +151,7 @@ "roles": [ { "id": "ec68ae44dd5943c9", - "title": "Senior Technical Recruiter", + "title": "Talent Lead", "department": "Operations", "location": "San Francisco", "applyUrl": "https://jobs.ashbyhq.com/comfy-org/d5008532-c45d-46e6-ba2c-20489d364362/application" diff --git a/apps/website/src/i18n/translations.ts b/apps/website/src/i18n/translations.ts index cc74b42bdc..06342ace68 100644 --- a/apps/website/src/i18n/translations.ts +++ b/apps/website/src/i18n/translations.ts @@ -11,6 +11,10 @@ const translations = { 'zh-CN': 'Comfy 是面向专业视觉人士的 AI 创作引擎。您可以精确掌控每个模型、每个参数和每个输出。' }, + 'hero.runFirstWorkflow': { + en: 'Run your first workflow', + 'zh-CN': '运行你的第一个工作流' + }, // ProductShowcaseSection 'showcase.subtitle1': { @@ -910,9 +914,9 @@ const translations = { 'zh-CN': '我应该选择 Comfy Cloud 还是本地 ComfyUI(自托管)?' }, 'cloud.faq.3.a': { - en: "Comfy Cloud (beta) has zero setup, is easy to share with your team, and is faster than most GPUs you can run on a desktop workstation. You can immediately run the best models and workflows from the community on Comfy Cloud.\nLocal ComfyUI is infinitely customizable, works offline, and you don't need to worry about queue times. However, depending on what you want to create, you might need to have a good GPU and some amount of technical knowledge to install community-created custom nodes.", + en: "Comfy Cloud has zero setup, is easy to share with your team, and is faster than most GPUs you can run on a desktop workstation. You can immediately run the best models and workflows from the community on Comfy Cloud.\nLocal ComfyUI is infinitely customizable, works offline, and you don't need to worry about queue times. However, depending on what you want to create, you might need to have a good GPU and some amount of technical knowledge to install community-created custom nodes.", 'zh-CN': - 'Comfy Cloud(测试版)无需任何设置,方便与团队共享,比大多数桌面工作站 GPU 更快。您可以立即在 Comfy Cloud 上运行社区中最好的模型和工作流。\n本地 ComfyUI 可以无限定制,支持离线工作,无需担心排队时间。但根据您的创作需求,可能需要一块好的 GPU 以及一定的技术知识来安装社区创建的自定义节点。' + 'Comfy Cloud 无需任何设置,方便与团队共享,比大多数桌面工作站 GPU 更快。您可以立即在 Comfy Cloud 上运行社区中最好的模型和工作流。\n本地 ComfyUI 可以无限定制,支持离线工作,无需担心排队时间。但根据您的创作需求,可能需要一块好的 GPU 以及一定的技术知识来安装社区创建的自定义节点。' }, 'cloud.faq.4.q': { en: 'Do I need a GPU or a strong computer to use Comfy Cloud?', @@ -1276,10 +1280,6 @@ const translations = { en: 'Run multiple workflows in parallel to speed up your pipeline.', 'zh-CN': '并行运行多个工作流,加速你的流程。' }, - 'pricing.included.comingSoon': { - en: 'coming soon', - 'zh-CN': '即将推出' - }, // VideoPlayer 'player.play': { en: 'Play', 'zh-CN': '播放' }, @@ -2243,6 +2243,20 @@ const translations = { 'zh-CN': '育碧 La Forge 开源了 CHORD PBR 材质估算模型及 ComfyUI 自定义节点,为 AAA 游戏制作实现了端到端的纹理生成工作流。' }, + 'customers.story.groove-jones.category': { + en: 'CASE STUDY', + 'zh-CN': '案例研究' + }, + 'customers.story.groove-jones.title': { + en: "How Groove Jones Delivered a Holiday FOOH Campaign for Dick's Sporting Goods with Comfy", + 'zh-CN': + "Groove Jones 如何借助 Comfy 为 Dick's Sporting Goods 打造节日 FOOH 营销" + }, + 'customers.story.groove-jones.body': { + en: 'Groove Jones, a Dallas-based creative studio, used Comfy to deliver a hyper-realistic FOOH holiday campaign for the Crocs x NFL collection on a fast-approaching deadline.', + 'zh-CN': + '达拉斯创意工作室 Groove Jones 借助 Comfy,在紧迫的节日档期内为 Crocs x NFL 联名系列交付了超写实的 FOOH 营销内容。' + }, 'customers.story.readMore': { en: 'READ MORE ON THIS TOPIC', 'zh-CN': '阅读更多相关内容' @@ -3276,6 +3290,227 @@ const translations = { 'zh-CN': 'ComfyUI 博客' }, + // Customer Detail: Groove Jones + // Topic 1: Intro + 'customers.detail.groove-jones.topic-1.label': { + en: 'INTRO', + 'zh-CN': '简介' + }, + 'customers.detail.groove-jones.topic-1.block.0': { + en: 'Groove Jones, a Dallas-based creative studio, builds AI-driven campaigns and immersive experiences for major brands where photoreal polish, creative ambition, and social-ready speed all have to land together. As their work expanded across AI Video, AR, VR, and WebGL for clients like Crocs, the NFL, and Dick\u2019s Sporting Goods, they faced a recurring challenge: delivering feature-film-quality VFX on commercial timelines and budgets.', + 'zh-CN': + '位于达拉斯的创意工作室 Groove Jones,为众多大牌客户打造由 AI 驱动的营销活动和沉浸式体验,需要同时兼顾照片级的精细度、创意野心,以及适配社交媒体的交付速度。随着他们为 Crocs、NFL 和 Dick\u2019s Sporting Goods 等客户的工作扩展到 AI 视频、AR、VR 和 WebGL,他们反复遇到同一个挑战:用商业项目的工期和预算,交付电影级的 VFX 质量。' + }, + 'customers.detail.groove-jones.topic-1.block.1': { + en: 'For the Crocs x NFL collection holiday launch, that challenge came to a head. The brief called for hyper-realistic video of giant NFL-licensed Crocs parachuting into real Dick\u2019s Sporting Goods parking lots, across multiple locations, delivered on a fast-approaching holiday deadline. A live-action shoot plus a traditional CG pipeline was off the table.', + 'zh-CN': + '在 Crocs x NFL 联名系列的节日上市项目中,这个挑战被推到了极致。Brief 要求制作超写实视频:巨型 NFL 授权 Crocs 鞋款跳伞落入多个真实的 Dick\u2019s Sporting Goods 停车场,并要在紧迫的节日档期前交付。实地拍摄加传统 CG 流水线的方案,已经完全行不通。' + }, + // Topic 2: The Output + 'customers.detail.groove-jones.topic-2.label': { + en: 'THE OUTPUT', + 'zh-CN': '交付成果' + }, + 'customers.detail.groove-jones.topic-2.title': { + en: 'The Output Groove Jones Achieved Using Comfy', + 'zh-CN': 'Groove Jones 借助 Comfy 实现的交付成果' + }, + 'customers.detail.groove-jones.topic-2.block.0': { + en: 'A full FOOH (faux out-of-home) social campaign delivered on a tight holiday deadline\nHyper-realistic videos of giant NFL-licensed Crocs parachuting onto Dick\u2019s Sporting Goods parking lots\nVertical 9:16 deliverables at 2K for Instagram Reels, TikTok, and YouTube Shorts\nSame-day iteration on client notes instead of week-long asset updates\nWinner, Aaron Awards 2024: Best AI Workflow for Production', + 'zh-CN': + '在紧迫的节日档期内交付完整的 FOOH(虚构户外广告)社媒营销活动\n超写实视频:巨型 NFL 授权 Crocs 鞋款跳伞落入 Dick\u2019s Sporting Goods 停车场\n面向 Instagram Reels、TikTok、YouTube Shorts 的 9:16 竖屏 2K 交付物\n客户反馈当天迭代,不再需要数周的资产更新周期\n荣获 2024 年 Aaron Awards:最佳 AI 制作工作流奖' + }, + // Topic 3: The Problem + 'customers.detail.groove-jones.topic-3.label': { + en: 'THE PROBLEM', + 'zh-CN': '挑战' + }, + 'customers.detail.groove-jones.topic-3.title': { + en: 'The Problem Groove Jones Was Trying to Solve', + 'zh-CN': 'Groove Jones 试图解决的问题' + }, + 'customers.detail.groove-jones.topic-3.block.0': { + en: 'A traditional pipeline for this creative meant a live-action shoot at multiple store locations plus a full CG build: high-res modeling of every team\u2019s clog, look development, lighting, rendering, compositing, and a new render every time the client wanted a variation. It also meant a large crew (modelers, texture artists, lighting artists, compositors) and a schedule measured in months. Neither the budget nor the holiday window supported that path.', + 'zh-CN': + '按照传统流水线做这个创意,意味着要在多家门店实地拍摄,加上完整的 CG 制作:每支球队鞋款的高精建模、look development、灯光、渲染、合成,客户每次想要新变体都要重新渲染。这也意味着庞大的团队(建模师、纹理师、灯光师、合成师),以及以"月"为单位的工期。无论是预算还是节日档期,都无法支撑这条路径。' + }, + // Topic 4: How Comfy Solved the Problem + 'customers.detail.groove-jones.topic-4.label': { + en: 'HOW COMFY SOLVED THE PROBLEM', + 'zh-CN': 'Comfy 如何解决问题' + }, + 'customers.detail.groove-jones.topic-4.title': { + en: 'How Groove Jones Used Comfy to Solve the Problem', + 'zh-CN': 'Groove Jones 如何用 Comfy 解决问题' + }, + 'customers.detail.groove-jones.topic-4.block.0': { + en: 'Groove Jones\u2019s Senior Creative Technologist, Doug Hogan, rebuilt the production process around Comfy\u2019s node-based workflow system, using their proprietary GrooveTech GenVFX pipeline. Custom LoRAs handled brand accuracy, a single Comfy graph orchestrated multiple generative models, and Nuke handled final polish. For a team with feature-film and commercial roots, the environment was immediately familiar.', + 'zh-CN': + 'Groove Jones 的高级创意技术总监 Doug Hogan 围绕 Comfy 的节点式工作流系统重新搭建了制作流程,并基于他们自研的 GrooveTech GenVFX 流水线展开。自定义 LoRA 负责保证品牌一致性,一张 Comfy 图编排多个生成模型,Nuke 负责最终精修。对于有电影和广告制作背景的团队,这套环境上手没有任何门槛。' + }, + 'customers.detail.groove-jones.topic-4.block.1.text': { + en: 'Comfy felt very similar to working inside a traditional CG and compositing pipeline. Node-based logic, clear data flow, modular builds. It felt natural to our artists already.', + 'zh-CN': + 'Comfy 用起来非常像传统 CG 和合成流水线:节点逻辑、清晰的数据流、模块化构建。我们的艺术家用起来毫无违和感。' + }, + 'customers.detail.groove-jones.topic-4.block.1.name': { + en: 'Doug Hogan | Senior Creative Technologist @ Groove Jones', + 'zh-CN': 'Doug Hogan | Groove Jones 高级创意技术总监' + }, + // Topic 5: Brand-Trained LoRAs + 'customers.detail.groove-jones.topic-5.label': { + en: 'BRAND-TRAINED LORAS', + 'zh-CN': '品牌定制 LORA' + }, + 'customers.detail.groove-jones.topic-5.title': { + en: 'Brand-Trained LoRAs for Hero Assets', + 'zh-CN': '为主视觉资产定制的品牌 LoRA' + }, + 'customers.detail.groove-jones.topic-5.block.0': { + en: 'Groove Jones trained custom LoRAs on the Crocs NFL Team Clogs and on Dick\u2019s Sporting Goods storefronts, so every generation came out anchored in brand-accurate references. Real team colorways, real product silhouettes, and real store exteriors stayed consistent across shots without per-frame correction, replacing what would normally take weeks of manual look development.', + 'zh-CN': + 'Groove Jones 基于 Crocs NFL 球队联名鞋款和 Dick\u2019s Sporting Goods 门店外景训练了定制 LoRA,让每一次生成都能锚定品牌精准的参考素材。真实的球队配色、产品轮廓和门店外观在不同镜头之间保持一致,不需要逐帧修正——而这通常意味着数周的 look development 工作量。' + }, + 'customers.detail.groove-jones.topic-5.block.1.src': { + en: 'https://media.comfy.org/website/customers/groove-jones/nfl-crocs-team-lineup.webp', + 'zh-CN': + 'https://media.comfy.org/website/customers/groove-jones/nfl-crocs-team-lineup.webp' + }, + 'customers.detail.groove-jones.topic-5.block.1.alt': { + en: 'Grid of brand-accurate NFL team Crocs generated via custom LoRAs', + 'zh-CN': '通过定制 LoRA 生成的多支 NFL 球队联名 Crocs 网格' + }, + 'customers.detail.groove-jones.topic-5.block.1.caption': { + en: 'Brand-accurate NFL team colorways generated through custom LoRAs.', + 'zh-CN': '通过定制 LoRA 生成的、与品牌精准一致的 NFL 球队配色。' + }, + // Topic 6: Multi-Model Orchestration + 'customers.detail.groove-jones.topic-6.label': { + en: 'MULTI-MODEL ORCHESTRATION', + 'zh-CN': '多模型编排' + }, + 'customers.detail.groove-jones.topic-6.title': { + en: 'Multi-Model Orchestration in a Single Graph', + 'zh-CN': '单张图内的多模型编排' + }, + 'customers.detail.groove-jones.topic-6.block.0': { + en: 'The creative required different generative models at different stages: Flux for key-frame still development, Gemini Flash 2.5 (Nano Banana) for fast ideation and variants, and Veo 3.1 plus Moonvalley\u2019s Marey for final video generation. Comfy routed between all four inside one graph, so outputs from one model fed directly into the next without ever leaving the environment.', + 'zh-CN': + '这个创意在不同阶段需要不同的生成模型:Flux 用于关键帧静帧开发,Gemini Flash 2.5(Nano Banana)用于快速构思和变体生成,Veo 3.1 加上 Moonvalley 的 Marey 用于最终的视频生成。Comfy 在一张图里就把这四个模型串起来,前一个模型的输出直接喂给下一个模型,全程无需切换环境。' + }, + 'customers.detail.groove-jones.topic-6.block.1.text': { + en: 'The Comfy community develops at an almost exponential curve, and we were able to leverage their existing nodes and tools to solve very specific production challenges instead of reinventing the wheel ourselves.', + 'zh-CN': + 'Comfy 社区几乎是指数级增长的,我们可以直接利用社区已有的节点和工具去解决非常具体的制作问题,而不必自己重新造轮子。' + }, + 'customers.detail.groove-jones.topic-6.block.1.name': { + en: 'Dale Carman | Co-founder @ Groove Jones', + 'zh-CN': 'Dale Carman | Groove Jones 联合创始人' + }, + // Topic 7: The Pipeline + 'customers.detail.groove-jones.topic-7.label': { + en: 'THE PIPELINE', + 'zh-CN': '流水线' + }, + 'customers.detail.groove-jones.topic-7.title': { + en: 'Storyboards to Previz to Final Shot in One Pipeline', + 'zh-CN': '从故事板到 Previz 再到成片,全部在一条流水线内' + }, + 'customers.detail.groove-jones.topic-7.block.0': { + en: 'The workflow opened with traditional storyboards for narrative approval, then moved into CGI blocking to lock composition, camera framing, and story beats. Comfy drove generation from there: the shoe drop, the parking lot reactions, the crowd coverage, and the environmental conversions that turned static summer storefronts into snow-covered holiday scenes, all inside the same graph.', + 'zh-CN': + '工作流从传统故事板开始用于叙事确认,再进入 CGI blocking,锁定构图、镜头取景和叙事节奏。从这里开始 Comfy 接管生成:鞋款空投、停车场反应镜头、人群覆盖、把夏季静态门店外景转换成被雪覆盖的节日场景——全部在同一张图里完成。' + }, + 'customers.detail.groove-jones.topic-7.block.1.src': { + en: 'https://media.comfy.org/website/customers/groove-jones/nfl-crocs-dicks-storyboards.webp', + 'zh-CN': + 'https://media.comfy.org/website/customers/groove-jones/nfl-crocs-dicks-storyboards.webp' + }, + 'customers.detail.groove-jones.topic-7.block.1.alt': { + en: 'Storyboard grid for the Crocs x NFL holiday campaign', + 'zh-CN': 'Crocs x NFL 节日营销的故事板网格' + }, + 'customers.detail.groove-jones.topic-7.block.1.caption': { + en: 'Grayscale storyboards used to lock narrative beats before generation.', + 'zh-CN': '在生成之前用于锁定叙事节奏的灰度故事板。' + }, + 'customers.detail.groove-jones.topic-7.block.2.src': { + en: 'https://media.comfy.org/website/customers/groove-jones/nfl-crocs-fooh-sequence.webp', + 'zh-CN': + 'https://media.comfy.org/website/customers/groove-jones/nfl-crocs-fooh-sequence.webp' + }, + 'customers.detail.groove-jones.topic-7.block.2.alt': { + en: 'Composition progression from blocking to mid-render to final shot', + 'zh-CN': '从 blocking 到中间渲染再到最终镜头的构图演进' + }, + 'customers.detail.groove-jones.topic-7.block.2.caption': { + en: 'Composition progression: wireframe blocking, mid-render, and final shot.', + 'zh-CN': '构图演进:线框 blocking、中间渲染、最终成片。' + }, + // Topic 8: Version Control + 'customers.detail.groove-jones.topic-8.label': { + en: 'VERSION CONTROL', + 'zh-CN': '版本管理' + }, + 'customers.detail.groove-jones.topic-8.title': { + en: 'Workflow Files as Version Control', + 'zh-CN': '把工作流文件当作版本管理' + }, + 'customers.detail.groove-jones.topic-8.block.0': { + en: 'Every variant of every shot lived as a Comfy workflow file, which doubled as version control. When notes came in requesting a different team colorway, store exterior, or time of day, the team duplicated a branch instead of rebuilding, which made same-day iteration possible. GPU usage and API credit burn were trackable inside the same environment as the work itself, giving Production real-time visibility into compute cost per iteration.', + 'zh-CN': + '每个镜头的每个变体都以 Comfy 工作流文件的形式存在,文件本身就是版本管理。当客户反馈要求换一支球队配色、换一个门店外景或者换一个时间段时,团队只需复制一个分支,而不是重建——这才让"当天迭代"成为可能。GPU 使用量和 API 额度消耗也都能在同一个环境里追踪到,让制作部门实时看到每次迭代的算力成本。' + }, + // Topic 9: Finishing in Nuke + 'customers.detail.groove-jones.topic-9.label': { + en: 'FINISHING IN NUKE', + 'zh-CN': 'Nuke 终修' + }, + 'customers.detail.groove-jones.topic-9.title': { + en: 'Finishing in Nuke', + 'zh-CN': '在 Nuke 中完成终修' + }, + 'customers.detail.groove-jones.topic-9.block.0': { + en: 'Generated shots moved into Nuke for final compositing: falling snow, camera shake, crowd ambience, holiday audio, and 2K mastering in 9:16 for Instagram Reels, TikTok, and YouTube Shorts. Because Comfy handled generation cleanly, Nuke focused on polish and motion enhancement rather than patching generative artifacts.', + 'zh-CN': + '生成的镜头进入 Nuke 完成最终合成:飘雪、镜头抖动、人群环境音、节日氛围音效,以及面向 Instagram Reels、TikTok、YouTube Shorts 的 9:16 2K 母带。由于 Comfy 把生成环节处理得很干净,Nuke 可以专注于精修和动态增强,而不是去修补生成模型留下的瑕疵。' + }, + // Topic 10: The Takeaway + 'customers.detail.groove-jones.topic-10.label': { + en: 'THE TAKEAWAY', + 'zh-CN': '总结' + }, + 'customers.detail.groove-jones.topic-10.title': { + en: 'Conclusion', + 'zh-CN': '结语' + }, + 'customers.detail.groove-jones.topic-10.block.0': { + en: 'By building the FOOH pipeline inside Comfy, Groove Jones turned a brief that would have required an expensive live-action shoot plus months of CG into a fast, iterative, single-environment workflow the client could direct in real time. The project recently won the Aaron Award for Best AI Workflow for Production.', + 'zh-CN': + '通过在 Comfy 中搭建整套 FOOH 流水线,Groove Jones 把一个原本需要昂贵实地拍摄加数月 CG 制作的项目,变成了一套高速迭代、单一环境、客户可以实时指挥的工作流。该项目近期还荣获 Aaron Award 的"最佳 AI 制作工作流"奖。' + }, + 'customers.detail.groove-jones.topic-10.block.1.text': { + en: 'At Groove Jones, we care deeply about delivering work that makes people say WOW! But we also care about delivering on time and on budget. VFX projects used to operate at razor thin margins. Comfy solved that for us.', + 'zh-CN': + '在 Groove Jones,我们非常在意交付让人说"WOW!"的作品,但我们同样在意按时按预算交付。VFX 项目以前的利润率薄得像刀刃,Comfy 帮我们彻底解决了这个问题。' + }, + 'customers.detail.groove-jones.topic-10.block.1.name': { + en: 'Dale Carman | Co-founder @ Groove Jones', + 'zh-CN': 'Dale Carman | Groove Jones 联合创始人' + }, + 'customers.detail.groove-jones.topic-10.block.2.label': { + en: 'GROOVE JONES CONTRIBUTORS', + 'zh-CN': 'GROOVE JONES 贡献者' + }, + 'customers.detail.groove-jones.topic-10.block.2.name': { + en: 'TBD', + 'zh-CN': '待补充' + }, + 'customers.detail.groove-jones.topic-10.block.2.role': { + en: 'TBD', + 'zh-CN': '待补充' + }, + // Contact – FormSection 'contact.form.badge': { en: 'CONTACT SALES', @@ -3298,82 +3533,13 @@ const translations = { en: 'Find your answer here', 'zh-CN': '在这里找到答案' }, - 'contact.form.firstName': { - en: 'First name', - 'zh-CN': '名' + 'contact.form.embedLoadErrorPrefix': { + en: 'Unable to load the contact form. Email us at', + 'zh-CN': '联系表单无法加载。请发送邮件至' }, - 'contact.form.lastName': { - en: 'Last Name', - 'zh-CN': '姓' - }, - 'contact.form.company': { - en: 'Company', - 'zh-CN': '公司' - }, - 'contact.form.phone': { - en: 'Phone Number (optional)', - 'zh-CN': '电话号码(可选)' - }, - 'contact.form.packageQuestion': { - en: 'Are you interested in learning more about our Enterprise Services, which start at $100K annually, our individual packages, or our team packages?', - 'zh-CN': - '您是否有兴趣了解更多关于我们的企业服务(年费起价 $100K)、个人套餐或团队套餐?' - }, - 'contact.form.packageIndividual': { - en: 'INDIVIDUAL', - 'zh-CN': '个人' - }, - 'contact.form.packageTeams': { - en: 'TEAMS', - 'zh-CN': '团队' - }, - 'contact.form.packageEnterprise': { - en: 'ENTERPRISE', - 'zh-CN': '企业' - }, - 'contact.form.usingComfy': { - en: 'Are you /your team currently using Comfy?', - 'zh-CN': '您/您的团队目前是否在使用 Comfy?' - }, - 'contact.form.usingYesProduction': { - en: 'Yes, in production', - 'zh-CN': '是,在生产环境中' - }, - 'contact.form.usingYesTesting': { - en: 'Yes, testing / experimenting', - 'zh-CN': '是,测试/实验中' - }, - 'contact.form.usingNotYet': { - en: 'Not yet, evaluating', - 'zh-CN': '尚未使用,评估中' - }, - 'contact.form.usingOtherTools': { - en: 'Not using Comfy yet, but using other GenAI tools', - 'zh-CN': '尚未使用 Comfy,但在使用其他 GenAI 工具' - }, - 'contact.form.lookingFor': { - en: 'What are you looking for?', - 'zh-CN': '您在寻找什么?' - }, - 'contact.form.lookingForPlaceholder': { - en: 'Tell us about your team needs, expected usage, or other specific requirements.', - 'zh-CN': '请告诉我们您的团队需求、预期使用情况或其他具体要求。' - }, - 'contact.form.submit': { - en: 'SUBMIT', - 'zh-CN': '提交' - }, - 'contact.form.firstNamePlaceholder': { - en: 'Jane', - 'zh-CN': 'Jane' - }, - 'contact.form.lastNamePlaceholder': { - en: 'Smith', - 'zh-CN': 'Smith' - }, - 'contact.form.companyPlaceholder': { - en: 'jane@acme.org', - 'zh-CN': 'jane@acme.org' + 'contact.form.embedLoadErrorSuffix': { + en: "and we'll route your request.", + 'zh-CN': '我们会为您处理请求。' }, 'customers.story.whatsNext': { diff --git a/apps/website/src/layouts/BaseLayout.astro b/apps/website/src/layouts/BaseLayout.astro index d845b915ef..77cf76131c 100644 --- a/apps/website/src/layouts/BaseLayout.astro +++ b/apps/website/src/layouts/BaseLayout.astro @@ -133,9 +133,15 @@ const websiteJsonLd = { <script> import { initSmoothScroll, cancelScroll } from '../scripts/smoothScroll' import { ScrollTrigger } from '../scripts/gsapSetup' + import { initPostHog, capturePageview } from '../scripts/posthog' initSmoothScroll() + if (import.meta.env.PROD) { + initPostHog() + document.addEventListener('astro:page-load', capturePageview) + } + document.addEventListener('astro:page-load', () => { ScrollTrigger.refresh() }) diff --git a/apps/website/src/scripts/posthog.ts b/apps/website/src/scripts/posthog.ts new file mode 100644 index 0000000000..00e1c69a28 --- /dev/null +++ b/apps/website/src/scripts/posthog.ts @@ -0,0 +1,36 @@ +import posthog from 'posthog-js' + +const POSTHOG_KEY = + import.meta.env.PUBLIC_POSTHOG_KEY ?? + 'phc_iKfK86id4xVYws9LybMje0h44eGtfwFgRPIBehmy8rO' +const POSTHOG_API_HOST = + import.meta.env.PUBLIC_POSTHOG_API_HOST ?? 'https://t.comfy.org' +const POSTHOG_UI_HOST = + import.meta.env.PUBLIC_POSTHOG_UI_HOST ?? 'https://us.posthog.com' + +let initialized = false + +export function initPostHog() { + if (initialized || typeof window === 'undefined' || !POSTHOG_KEY) return + try { + posthog.init(POSTHOG_KEY, { + api_host: POSTHOG_API_HOST, + ui_host: POSTHOG_UI_HOST, + capture_pageview: false, + capture_pageleave: true, + person_profiles: 'identified_only' + }) + initialized = true + } catch (error) { + console.error('PostHog init failed', error) + } +} + +export function capturePageview() { + if (!initialized) return + try { + posthog.capture('$pageview') + } catch (error) { + console.error('PostHog pageview capture failed', error) + } +} diff --git a/apps/website/src/utils/github.test.ts b/apps/website/src/utils/github.test.ts new file mode 100644 index 0000000000..24eca55340 --- /dev/null +++ b/apps/website/src/utils/github.test.ts @@ -0,0 +1,36 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { fetchGitHubStars, formatStarCount } from './github' + +describe('fetchGitHubStars', () => { + const savedOverride = process.env.WEBSITE_GITHUB_STARS_OVERRIDE + + afterEach(() => { + vi.restoreAllMocks() + if (savedOverride === undefined) + delete process.env.WEBSITE_GITHUB_STARS_OVERRIDE + else process.env.WEBSITE_GITHUB_STARS_OVERRIDE = savedOverride + }) + + it('uses the build-time override without calling GitHub', async () => { + process.env.WEBSITE_GITHUB_STARS_OVERRIDE = '110000' + const fetchMock = vi.spyOn(globalThis, 'fetch') + + await expect(fetchGitHubStars('Comfy-Org', 'ComfyUI')).resolves.toBe(110000) + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('fails fast when the build-time override is malformed', async () => { + process.env.WEBSITE_GITHUB_STARS_OVERRIDE = '110K' + + await expect(fetchGitHubStars('Comfy-Org', 'ComfyUI')).rejects.toThrow( + 'WEBSITE_GITHUB_STARS_OVERRIDE must be a non-negative integer' + ) + }) +}) + +describe('formatStarCount', () => { + it('formats the visual-test override to match committed snapshots', () => { + expect(formatStarCount(110000)).toBe('110K') + }) +}) diff --git a/apps/website/src/utils/github.ts b/apps/website/src/utils/github.ts index ee7c704ed5..1fe211e62d 100644 --- a/apps/website/src/utils/github.ts +++ b/apps/website/src/utils/github.ts @@ -2,6 +2,9 @@ export async function fetchGitHubStars( owner: string, repo: string ): Promise<number | null> { + const override = readGitHubStarsOverride() + if (override !== undefined) return override + try { const res = await fetch(`https://api.github.com/repos/${owner}/${repo}`, { headers: { Accept: 'application/vnd.github.v3+json' } @@ -25,3 +28,17 @@ export function formatStarCount(count: number): string { } return count.toString() } + +function readGitHubStarsOverride(): number | undefined { + const rawCount = process.env.WEBSITE_GITHUB_STARS_OVERRIDE + if (rawCount === undefined || rawCount === '') return undefined + + const count = Number(rawCount) + if (!Number.isSafeInteger(count) || count < 0) { + throw new Error( + 'WEBSITE_GITHUB_STARS_OVERRIDE must be a non-negative integer' + ) + } + + return count +} diff --git a/apps/website/src/utils/marketingImage.ts b/apps/website/src/utils/marketingImage.ts new file mode 100644 index 0000000000..9f7afaf835 --- /dev/null +++ b/apps/website/src/utils/marketingImage.ts @@ -0,0 +1,3 @@ +export const MARKETING_FORMATS = ['avif', 'webp'] as const + +export const MARKETING_WIDTHS = [640, 960, 1280, 1920] as const diff --git a/apps/website/src/utils/video.test.ts b/apps/website/src/utils/video.test.ts new file mode 100644 index 0000000000..114765cf51 --- /dev/null +++ b/apps/website/src/utils/video.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from 'vitest' + +import { buildVideoSources, videoKey } from './video' + +describe('buildVideoSources', () => { + it('builds a source per requested format', () => { + const sources = buildVideoSources({ + name: 'hero', + baseUrl: 'https://media.comfy.org/website/marketing', + width: 1280, + formats: ['webm', 'mp4'] + }) + + expect(sources).toEqual([ + { + src: 'https://media.comfy.org/website/marketing/hero-1280.webm', + type: 'video/webm', + format: 'webm' + }, + { + src: 'https://media.comfy.org/website/marketing/hero-1280.mp4', + type: 'video/mp4', + format: 'mp4' + } + ]) + }) + + it('preserves caller-supplied format order', () => { + const sources = buildVideoSources({ + name: 'clip', + baseUrl: 'https://cdn.example.com/v', + width: 960, + formats: ['mp4', 'webm'] + }) + + expect(sources.map((s) => s.format)).toEqual(['mp4', 'webm']) + }) + + it('strips a single trailing slash from baseUrl', () => { + const sources = buildVideoSources({ + name: 'reel', + baseUrl: 'https://media.comfy.org/website/marketing/', + width: 1920, + formats: ['webm'] + }) + + expect(sources[0]?.src).toBe( + 'https://media.comfy.org/website/marketing/reel-1920.webm' + ) + }) + + it('returns an empty list when no formats are requested', () => { + const sources = buildVideoSources({ + name: 'x', + baseUrl: 'https://example.com', + width: 640, + formats: [] + }) + + expect(sources).toEqual([]) + }) +}) + +describe('videoKey', () => { + it('changes when the source URL list changes', () => { + const at1280 = buildVideoSources({ + name: 'hero', + baseUrl: 'https://media.comfy.org/m', + width: 1280, + formats: ['webm', 'mp4'] + }) + const at640 = buildVideoSources({ + name: 'hero', + baseUrl: 'https://media.comfy.org/m', + width: 640, + formats: ['webm', 'mp4'] + }) + + expect(videoKey(at1280)).not.toBe(videoKey(at640)) + }) + + it('is stable across repeated calls with the same inputs', () => { + const args = { + name: 'hero', + baseUrl: 'https://media.comfy.org/m', + width: 1280, + formats: ['webm', 'mp4'] as const + } + + expect( + videoKey(buildVideoSources({ ...args, formats: [...args.formats] })) + ).toBe(videoKey(buildVideoSources({ ...args, formats: [...args.formats] }))) + }) + + it('reflects format-order changes', () => { + const webmFirst = buildVideoSources({ + name: 'hero', + baseUrl: 'https://media.comfy.org/m', + width: 1280, + formats: ['webm', 'mp4'] + }) + const mp4First = buildVideoSources({ + name: 'hero', + baseUrl: 'https://media.comfy.org/m', + width: 1280, + formats: ['mp4', 'webm'] + }) + + expect(videoKey(webmFirst)).not.toBe(videoKey(mp4First)) + }) +}) diff --git a/apps/website/src/utils/video.ts b/apps/website/src/utils/video.ts new file mode 100644 index 0000000000..c8c72c8e7f --- /dev/null +++ b/apps/website/src/utils/video.ts @@ -0,0 +1,49 @@ +/** @knipIgnoreUsedByStackedPR */ +export type VideoFormat = 'webm' | 'mp4' + +/** @knipIgnoreUsedByStackedPR */ +export type VideoSource = { + src: string + type: `video/${VideoFormat}` + format: VideoFormat +} + +const MIME_TYPES: Record<VideoFormat, VideoSource['type']> = { + webm: 'video/webm', + mp4: 'video/mp4' +} + +type BuildArgs = { + name: string + baseUrl: string + width: number + formats: VideoFormat[] +} + +/** + * Expects assets named `${name}-${width}.${format}` under `${baseUrl}/`, + * matching the output of `apps/website/scripts/process-videos.sh`. + */ +export function buildVideoSources({ + name, + baseUrl, + width, + formats +}: BuildArgs): VideoSource[] { + const base = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl + + return formats.map((format) => ({ + src: `${base}/${name}-${width}.${format}`, + type: MIME_TYPES[format], + format + })) +} + +/** + * Stable identifier for a list of video sources, suitable as a Vue `key`. + * Browsers do not reload a `<video>` when nested `<source>` children change; + * keying the parent forces a remount when the source set changes. + */ +export function videoKey(sources: VideoSource[]): string { + return sources.map((s) => s.src).join('|') +} diff --git a/apps/website/vercel.json b/apps/website/vercel.json index 3ed0a2ebd6..8b4b5876cb 100644 --- a/apps/website/vercel.json +++ b/apps/website/vercel.json @@ -7,6 +7,15 @@ "github": { "enabled": false }, + "headers": [ + { + "source": "/(.*)", + "has": [ + { "type": "host", "value": "website-frontend-comfyui.vercel.app" } + ], + "headers": [{ "key": "X-Robots-Tag", "value": "index, follow" }] + } + ], "redirects": [ { "source": "/pricing", diff --git a/browser_tests/AGENTS.md b/browser_tests/AGENTS.md index 3c367c548f..1f68c937f3 100644 --- a/browser_tests/AGENTS.md +++ b/browser_tests/AGENTS.md @@ -15,11 +15,15 @@ browser_tests/ │ ├── VueNodeHelpers.ts - Vue Nodes 2.0 helpers │ ├── selectors.ts - Centralized TestIds │ ├── data/ - Static test data (mock API responses, workflow JSONs, node definitions) -│ ├── components/ - Page object components (locators, user interactions) +│ ├── components/ - Page object classes (locators, user interactions) +│ │ ├── Actionbar.ts │ │ ├── ContextMenu.ts +│ │ ├── ManageGroupNode.ts │ │ ├── SettingDialog.ts │ │ ├── SidebarTab.ts -│ │ └── Topbar.ts +│ │ ├── Templates.ts +│ │ ├── Topbar.ts +│ │ └── ... │ ├── helpers/ - Focused helper classes (domain-specific actions) │ │ ├── CanvasHelper.ts │ │ ├── CommandHelper.ts @@ -28,17 +32,36 @@ browser_tests/ │ │ ├── SettingsHelper.ts │ │ ├── WorkflowHelper.ts │ │ └── ... -│ └── utils/ - Pure utility functions (no page dependency) -├── helpers/ - Test-specific utilities +│ └── utils/ - Standalone utility functions (used by tests or fixtures) +│ ├── builderTestUtils.ts +│ ├── clipboardSpy.ts +│ ├── fitToView.ts +│ ├── perfReporter.ts +│ └── ... └── tests/ - Test files (*.spec.ts) ``` ### Architectural Separation - **`fixtures/data/`** — Static test data only. Mock API responses, workflow JSONs, node definitions. No code, no imports from Playwright. -- **`fixtures/components/`** — Page object components. Encapsulate locators and user interactions for a specific UI area. -- **`fixtures/helpers/`** — Focused helper classes. Domain-specific actions that coordinate multiple page objects (e.g. canvas operations, workflow loading). -- **`fixtures/utils/`** — Pure utility functions. No `Page` dependency; stateless helpers that can be used anywhere. +- **`fixtures/components/`** — Page object components. Classes that own locators for a specific UI region (e.g. `Actionbar`, `ContextMenu`, `ManageGroupNode`). +- **`fixtures/helpers/`** — Helper classes that coordinate actions across multiple regions without owning a locator surface of their own (e.g. `CanvasHelper`, `WorkflowHelper`, `NodeOperationsHelper`). +- **`fixtures/utils/`** — Standalone utility functions. Exported functions (not classes) used by tests or fixtures (e.g. `fitToView`, `clipboardSpy`, `builderTestUtils`). + +### Placement Rule + +When adding a new file, use this decision tree: + +```mermaid +flowchart TD + A[New file in browser_tests/fixtures/] --> B{Has any code?} + B -- No, JSON/data only --> D[fixtures/data/] + B -- Yes --> C{Is it a class?} + C -- No, exported functions --> U[fixtures/utils/] + C -- Yes --> E{Owns locators for a<br/>specific UI region?} + E -- Yes --> P[fixtures/components/] + E -- No, coordinates actions<br/>across the app --> H[fixtures/helpers/] +``` ## Page Object Locator Style diff --git a/browser_tests/README.md b/browser_tests/README.md index d2726bd579..5bb29bcaa9 100644 --- a/browser_tests/README.md +++ b/browser_tests/README.md @@ -51,6 +51,7 @@ DISABLE_VUE_PLUGINS=true # Test against dev server (recommended) or backend directly PLAYWRIGHT_TEST_URL=http://localhost:5173 # Dev server # PLAYWRIGHT_TEST_URL=http://localhost:8188 # Direct backend +PLAYWRIGHT_SETUP_API_URL=http://localhost:8188 # Setup/auth API when using the dev server URL above # Path to ComfyUI for backing up user data/settings before tests TEST_COMFYUI_DIR=/path/to/your/ComfyUI @@ -95,6 +96,17 @@ pnpm test:browser:local # Run all tests pnpm test:browser:local widget.spec.ts # Run specific test file ``` +### Slowing the browser down for debugging + +When running with `--headed` (or `--ui`), set `SLOW_MO` to a millisecond delay +to slow every Playwright action down so you can watch what is happening. The +delay only applies when `PLAYWRIGHT_LOCAL` is set (the default for the +`pnpm test:browser:local` script). + +```bash +SLOW_MO=250 pnpm test:browser:local --headed widget.spec.ts +``` + ## Test Structure Browser tests in this project follow a specific organization pattern: @@ -139,12 +151,9 @@ Always check for existing helpers and fixtures before implementing new ones: - **ComfyPage**: Main fixture with methods for canvas interaction and node management - **ComfyMouse**: Helper for precise mouse operations on the canvas -- **Helpers**: Check `browser_tests/helpers/` for specialized helpers like: - - `actionbar.ts`: Interact with the action bar - - `manageGroupNode.ts`: Group node management operations - - `templates.ts`: Template workflows operations -- **Component Fixtures**: Check `browser_tests/fixtures/components/` for UI component helpers -- **Utility Functions**: Check `browser_tests/utils/` and `browser_tests/fixtures/utils/` for shared utilities +- **Component Fixtures**: Check `browser_tests/fixtures/components/` for UI component page objects (e.g. `Actionbar.ts`, `Templates.ts`, `ContextMenu.ts`) +- **Helper Classes**: Check `browser_tests/fixtures/helpers/` for domain-specific helper classes wired into ComfyPage (e.g. `CanvasHelper.ts`, `WorkflowHelper.ts`) +- **Utility Functions**: Check `browser_tests/fixtures/utils/` for standalone utilities (e.g. `fitToView.ts`, `clipboardSpy.ts`, `builderTestUtils.ts`) Most common testing needs are already addressed by these helpers, which will make your tests more consistent and reliable. diff --git a/browser_tests/assets/3d/load3d_missing_model.json b/browser_tests/assets/3d/load3d_missing_model.json new file mode 100644 index 0000000000..bf0b2704f2 --- /dev/null +++ b/browser_tests/assets/3d/load3d_missing_model.json @@ -0,0 +1,27 @@ +{ + "last_node_id": 1, + "last_link_id": 0, + "nodes": [ + { + "id": 1, + "type": "Preview3D", + "pos": [50, 50], + "size": [450, 600], + "flags": {}, + "order": 0, + "mode": 0, + "inputs": [], + "outputs": [], + "properties": { + "Node name for S&R": "Preview3D", + "Last Time Model File": "nonexistent_model.glb" + }, + "widgets_values": ["nonexistent_model.glb"] + } + ], + "links": [], + "groups": [], + "config": {}, + "extra": { "ds": { "offset": [0, 0], "scale": 1 } }, + "version": 0.4 +} diff --git a/browser_tests/assets/default.json b/browser_tests/assets/default.json index 9582c04f78..d8711bdcb2 100644 --- a/browser_tests/assets/default.json +++ b/browser_tests/assets/default.json @@ -119,7 +119,15 @@ { "name": "CLIP", "type": "CLIP", "links": [3, 5], "slot_index": 1 }, { "name": "VAE", "type": "VAE", "links": [8], "slot_index": 2 } ], - "properties": {}, + "properties": { + "models": [ + { + "name": "v1-5-pruned-emaonly-fp16.safetensors", + "url": "https://huggingface.co/Comfy-Org/stable-diffusion-v1-5-archive/resolve/main/v1-5-pruned-emaonly-fp16.safetensors", + "directory": "checkpoints" + } + ] + }, "widgets_values": ["v1-5-pruned-emaonly-fp16.safetensors"] } ], diff --git a/browser_tests/assets/subgraphs/subgraph-multi-instance-promoted-text-values.json b/browser_tests/assets/subgraphs/subgraph-multi-instance-promoted-text-values.json deleted file mode 100644 index 459cc73c32..0000000000 --- a/browser_tests/assets/subgraphs/subgraph-multi-instance-promoted-text-values.json +++ /dev/null @@ -1,284 +0,0 @@ -{ - "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": [210, 168], - "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": [210, 168], - "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": [210, 168], - "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 -} diff --git a/browser_tests/assets/widgets/painter_with_input.json b/browser_tests/assets/widgets/painter_with_input.json new file mode 100644 index 0000000000..f4bdee78f2 --- /dev/null +++ b/browser_tests/assets/widgets/painter_with_input.json @@ -0,0 +1,74 @@ +{ + "last_node_id": 2, + "last_link_id": 1, + "nodes": [ + { + "id": 2, + "type": "LoadImage", + "pos": [50, 50], + "size": [400, 314], + "flags": {}, + "order": 0, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "IMAGE", + "type": "IMAGE", + "links": [1] + }, + { + "name": "MASK", + "type": "MASK", + "links": null + } + ], + "properties": { + "Node name for S&R": "LoadImage" + }, + "widgets_values": ["example.png", "image"] + }, + { + "id": 1, + "type": "Painter", + "pos": [450, 50], + "size": [450, 550], + "flags": {}, + "order": 1, + "mode": 0, + "inputs": [ + { + "name": "image", + "type": "IMAGE", + "link": 1 + } + ], + "outputs": [ + { + "name": "IMAGE", + "type": "IMAGE", + "links": null + }, + { + "name": "MASK", + "type": "MASK", + "links": null + } + ], + "properties": { + "Node name for S&R": "Painter" + }, + "widgets_values": ["", 512, 512, "#000000"] + } + ], + "links": [[1, 2, 0, 1, 0, "IMAGE"]], + "groups": [], + "config": {}, + "extra": { + "ds": { + "offset": [0, 0], + "scale": 1 + } + }, + "version": 0.4 +} diff --git a/browser_tests/fixtures/ComfyMouse.ts b/browser_tests/fixtures/ComfyMouse.ts index 9f5b757518..0b3b122928 100644 --- a/browser_tests/fixtures/ComfyMouse.ts +++ b/browser_tests/fixtures/ComfyMouse.ts @@ -1,4 +1,4 @@ -import type { Mouse } from '@playwright/test' +import type { Locator, Mouse } from '@playwright/test' import type { ComfyPage } from '@e2e/fixtures/ComfyPage' import type { Position } from '@e2e/fixtures/types' @@ -72,6 +72,22 @@ export class ComfyMouse implements Omit<Mouse, 'move'> { await this.nextFrame() } + async resizeByDragging( + element: Locator, + { x, y }: { x?: number; y?: number } + ) { + const elementBox = await element.boundingBox() + if (!elementBox) throw new Error('element should have layout') + + const cx = elementBox.x + elementBox.width / 2 + const cy = elementBox.y + elementBox.height / 2 + + await this.dragAndDrop( + { x: cx, y: cy }, + { x: cx + (x ?? 0), y: cy + (y ?? 0) } + ) + } + //#region Pass-through async click(...args: Parameters<Mouse['click']>) { return await this.mouse.click(...args) diff --git a/browser_tests/fixtures/ComfyPage.ts b/browser_tests/fixtures/ComfyPage.ts index 1e93352988..7323933e99 100644 --- a/browser_tests/fixtures/ComfyPage.ts +++ b/browser_tests/fixtures/ComfyPage.ts @@ -5,8 +5,8 @@ import MCR from 'monocart-coverage-reports' import { COVERAGE_OUTPUT_DIR } from '@e2e/coverageConfig' import { NodeBadgeMode } from '@/types/nodeSource' -import { ComfyActionbar } from '@e2e/helpers/actionbar' -import { ComfyTemplates } from '@e2e/helpers/templates' +import { ComfyActionbar } from '@e2e/fixtures/components/Actionbar' +import { ComfyTemplates } from '@e2e/fixtures/components/Templates' import { ComfyMouse } from '@e2e/fixtures/ComfyMouse' import { TestIds } from '@e2e/fixtures/selectors' import { comfyExpect } from '@e2e/fixtures/utils/customMatchers' @@ -22,6 +22,7 @@ import { MediaLightbox } from '@e2e/fixtures/components/MediaLightbox' import { QueuePanel } from '@e2e/fixtures/components/QueuePanel' import { SettingDialog } from '@e2e/fixtures/components/SettingDialog' import { TemplatesDialog } from '@e2e/fixtures/components/TemplatesDialog' +import { TitleEditor } from '@e2e/fixtures/components/TitleEditor' import { AssetsSidebarTab, ModelLibrarySidebarTab, @@ -54,11 +55,13 @@ class ComfyPropertiesPanel { readonly root: Locator readonly panelTitle: Locator readonly searchBox: Locator + readonly titleEditor: TitleEditor constructor(readonly page: Page) { this.root = page.getByTestId(TestIds.propertiesPanel.root) this.panelTitle = this.root.locator('h3') this.searchBox = this.root.getByPlaceholder(/^Search/) + this.titleEditor = new TitleEditor(this.root) } } @@ -137,6 +140,7 @@ class ComfyMenu { export class ComfyPage { public readonly url: string + public readonly apiUrl: string // All canvas position operations are based on default view of canvas. public readonly canvas: Locator public readonly selectionToolbox: Locator @@ -159,6 +163,7 @@ export class ComfyPage { public readonly settingDialog: SettingDialog public readonly confirmDialog: ConfirmDialog public readonly templatesDialog: TemplatesDialog + public readonly titleEditor: TitleEditor public readonly mediaLightbox: MediaLightbox public readonly vueNodes: VueNodeHelpers public readonly appMode: AppModeHelper @@ -195,6 +200,7 @@ export class ComfyPage { public readonly request: APIRequestContext ) { this.url = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188' + this.apiUrl = process.env.PLAYWRIGHT_SETUP_API_URL || this.url this.canvas = page.locator('#graph-canvas') this.selectionToolbox = page.getByTestId(TestIds.selectionToolbox.root) this.widgetTextBox = page.getByPlaceholder('text').nth(1) @@ -204,13 +210,14 @@ export class ComfyPage { this.workflowUploadInput = page.locator('#comfy-file-input') this.searchBox = new ComfyNodeSearchBox(page) - this.searchBoxV2 = new ComfyNodeSearchBoxV2(page) + this.searchBoxV2 = new ComfyNodeSearchBoxV2(this) this.menu = new ComfyMenu(page) this.actionbar = new ComfyActionbar(page) this.templates = new ComfyTemplates(page) this.settingDialog = new SettingDialog(page, this) this.confirmDialog = new ConfirmDialog(page) this.templatesDialog = new TemplatesDialog(page) + this.titleEditor = new TitleEditor(page) this.mediaLightbox = new MediaLightbox(page) this.vueNodes = new VueNodeHelpers(page) this.appMode = new AppModeHelper(this) @@ -236,7 +243,7 @@ export class ComfyPage { } async setupUser(username: string) { - const res = await this.request.get(`${this.url}/api/users`) + const res = await this.request.get(`${this.apiUrl}/api/users`) if (res.status() !== 200) throw new Error(`Failed to retrieve users: ${await res.text()}`) @@ -250,7 +257,7 @@ export class ComfyPage { } async createUser(username: string) { - const resp = await this.request.post(`${this.url}/api/users`, { + const resp = await this.request.post(`${this.apiUrl}/api/users`, { data: { username } }) @@ -262,7 +269,7 @@ export class ComfyPage { async setupSettings(settings: Record<string, unknown>) { const resp = await this.request.post( - `${this.url}/api/devtools/set_settings`, + `${this.apiUrl}/api/devtools/set_settings`, { data: settings } diff --git a/browser_tests/fixtures/VueNodeHelpers.ts b/browser_tests/fixtures/VueNodeHelpers.ts index 9712618057..e7bdab272f 100644 --- a/browser_tests/fixtures/VueNodeHelpers.ts +++ b/browser_tests/fixtures/VueNodeHelpers.ts @@ -30,6 +30,13 @@ export class VueNodeHelpers { return this.page.locator(`[data-node-id="${nodeId}"]`) } + /** + * Get the inner wrapper element of a Vue node. + */ + getNodeInnerWrapper(nodeId: string): Locator { + return this.getNodeLocator(nodeId).getByTestId(TestIds.node.innerWrapper) + } + /** * Get locator for Vue nodes by the node's title (displayed name in the header). * Matches against the actual title element, not the full node body. @@ -119,10 +126,9 @@ export class VueNodeHelpers { } /** - * Return a DOM-focused VueNodeFixture for the first node matching the title. - * Resolves the node id up front so subsequent interactions survive title changes. + * Resolve the data-node-id of the first rendered node matching the title. */ - async getFixtureByTitle(title: string): Promise<VueNodeFixture> { + async getNodeIdByTitle(title: string): Promise<string> { const node = this.getNodeByTitle(title).first() await node.waitFor({ state: 'visible' }) @@ -133,6 +139,15 @@ export class VueNodeHelpers { ) } + return nodeId + } + + /** + * Return a DOM-focused VueNodeFixture for the first node matching the title. + * Resolves the node id up front so subsequent interactions survive title changes. + */ + async getFixtureByTitle(title: string): Promise<VueNodeFixture> { + const nodeId = await this.getNodeIdByTitle(title) return new VueNodeFixture(this.getNodeLocator(nodeId)) } diff --git a/browser_tests/helpers/actionbar.ts b/browser_tests/fixtures/components/Actionbar.ts similarity index 100% rename from browser_tests/helpers/actionbar.ts rename to browser_tests/fixtures/components/Actionbar.ts diff --git a/browser_tests/fixtures/components/ComfyNodeSearchBoxV2.ts b/browser_tests/fixtures/components/ComfyNodeSearchBoxV2.ts index 43b85e29ad..3fdb8377dd 100644 --- a/browser_tests/fixtures/components/ComfyNodeSearchBoxV2.ts +++ b/browser_tests/fixtures/components/ComfyNodeSearchBoxV2.ts @@ -1,6 +1,12 @@ -import type { Locator, Page } from '@playwright/test' +import type { Locator } from '@playwright/test' +import type { RootCategoryId } from '@/components/searchbox/v2/rootCategories' import type { ComfyPage } from '@e2e/fixtures/ComfyPage' +import { TestIds } from '@e2e/fixtures/selectors' + +const { searchBoxV2 } = TestIds + +export type { RootCategoryId } export class ComfyNodeSearchBoxV2 { readonly dialog: Locator @@ -8,24 +14,99 @@ export class ComfyNodeSearchBoxV2 { readonly filterSearch: Locator readonly results: Locator readonly filterOptions: Locator + readonly filterChips: Locator + readonly noResults: Locator + readonly nodeIdBadge: Locator + readonly sidebarToggle: Locator + readonly sidebarBackdrop: Locator + readonly filterChipsScroll: Locator - constructor(readonly page: Page) { + constructor(private comfyPage: ComfyPage) { + const page = comfyPage.page this.dialog = page.getByRole('search') this.input = this.dialog.getByRole('combobox') this.filterSearch = this.dialog.getByRole('textbox', { name: 'Search' }) - this.results = this.dialog.getByTestId('result-item') - this.filterOptions = this.dialog.getByTestId('filter-option') + this.results = this.dialog.getByTestId(searchBoxV2.resultItem) + this.filterOptions = this.dialog.getByTestId(searchBoxV2.filterOption) + this.filterChips = this.dialog.getByTestId(searchBoxV2.filterChip) + this.noResults = this.dialog.getByTestId(searchBoxV2.noResults) + this.nodeIdBadge = this.dialog.getByTestId(searchBoxV2.nodeIdBadge) + this.sidebarToggle = this.dialog.getByTestId(searchBoxV2.sidebarToggle) + this.sidebarBackdrop = this.dialog.getByTestId(searchBoxV2.sidebarBackdrop) + this.filterChipsScroll = this.dialog.getByTestId( + searchBoxV2.filterChipsScroll + ) } + /** Sidebar category tree button (e.g. `sampling`, `sampling/custom_sampling`). */ categoryButton(categoryId: string): Locator { - return this.dialog.getByTestId(`category-${categoryId}`) + return this.dialog.getByTestId(searchBoxV2.category(categoryId)) } - filterBarButton(name: string): Locator { - return this.dialog.getByRole('button', { name }) + /** Top filter-bar root category chip (e.g. `comfy`, `essentials`). */ + rootCategoryButton(id: RootCategoryId): Locator { + return this.dialog.getByTestId(searchBoxV2.rootCategory(id)) } - async reload(comfyPage: ComfyPage) { - await comfyPage.settings.setSetting('Comfy.NodeSearchBoxImpl', 'default') + /** Top filter-bar input/output type popover trigger. */ + typeFilterButton(key: 'input' | 'output'): Locator { + return this.dialog.getByTestId(searchBoxV2.typeFilter(key)) + } + + async applyTypeFilter( + key: 'input' | 'output', + typeName: string + ): Promise<void> { + const trigger = this.typeFilterButton(key) + await trigger.click() + await this.filterOptions.first().waitFor({ state: 'visible' }) + await this.filterSearch.fill(typeName) + await this.filterOptions.filter({ hasText: typeName }).first().click() + // The popover does not auto-close on selection — toggle the trigger. + await trigger.click() + await this.filterOptions.first().waitFor({ state: 'hidden' }) + } + + async removeFilterChip(index = 0): Promise<void> { + await this.filterChips + .nth(index) + .getByTestId(searchBoxV2.chipDelete) + .click() + } + + async toggle(): Promise<void> { + await this.comfyPage.command.executeCommand('Workspace.SearchBox.Toggle') + } + + async open(): Promise<void> { + if (await this.input.isVisible()) return + await this.toggle() + await this.input.waitFor({ state: 'visible' }) + } + + async openByDoubleClickCanvas(): Promise<void> { + // Use page.mouse.dblclick (not canvas.dblclick) so the z-999 Vue overlay + // does not intercept; coords target a viewport spot that is on the canvas + // and clear of both the side toolbar and any default-graph nodes. + await this.comfyPage.page.mouse.dblclick(200, 200, { delay: 5 }) + } + + async ensureV2Search(): Promise<void> { + await this.comfyPage.settings.setSetting( + 'Comfy.NodeSearchBoxImpl', + 'default' + ) + } + + async setup(): Promise<void> { + await this.ensureV2Search() + await this.comfyPage.settings.setSetting( + 'Comfy.LinkRelease.Action', + 'search box' + ) + await this.comfyPage.settings.setSetting( + 'Comfy.LinkRelease.ActionShift', + 'search box' + ) } } diff --git a/browser_tests/fixtures/components/ContextMenu.ts b/browser_tests/fixtures/components/ContextMenu.ts index fef61ac70d..fab6059baa 100644 --- a/browser_tests/fixtures/components/ContextMenu.ts +++ b/browser_tests/fixtures/components/ContextMenu.ts @@ -4,11 +4,13 @@ import type { Locator, Page } from '@playwright/test' export class ContextMenu { public readonly primeVueMenu: Locator public readonly litegraphMenu: Locator + public readonly litegraphContextMenu: Locator public readonly menuItems: Locator constructor(public readonly page: Page) { this.primeVueMenu = page.locator('.p-contextmenu, .p-menu') this.litegraphMenu = page.locator('.litemenu') + this.litegraphContextMenu = page.locator('.litecontextmenu') this.menuItems = page.locator('.p-menuitem, .litemenu-entry') } @@ -39,7 +41,10 @@ export class ContextMenu { const litegraphVisible = await this.litegraphMenu .isVisible() .catch(() => false) - return primeVueVisible || litegraphVisible + const litegraphContextVisible = await this.litegraphContextMenu + .isVisible() + .catch(() => false) + return primeVueVisible || litegraphVisible || litegraphContextVisible } async assertHasItems(items: string[]): Promise<void> { @@ -71,7 +76,8 @@ export class ContextMenu { async waitForHidden(): Promise<void> { await Promise.all([ this.primeVueMenu.waitFor({ state: 'hidden' }), - this.litegraphMenu.waitFor({ state: 'hidden' }) + this.litegraphMenu.waitFor({ state: 'hidden' }), + this.litegraphContextMenu.waitFor({ state: 'hidden' }) ]) } } diff --git a/browser_tests/helpers/manageGroupNode.ts b/browser_tests/fixtures/components/ManageGroupNode.ts similarity index 100% rename from browser_tests/helpers/manageGroupNode.ts rename to browser_tests/fixtures/components/ManageGroupNode.ts diff --git a/browser_tests/fixtures/components/PublishDialog.ts b/browser_tests/fixtures/components/PublishDialog.ts new file mode 100644 index 0000000000..3e2665db78 --- /dev/null +++ b/browser_tests/fixtures/components/PublishDialog.ts @@ -0,0 +1,72 @@ +import type { Locator, Page } from '@playwright/test' + +import { BaseDialog } from '@e2e/fixtures/components/BaseDialog' +import { TestIds } from '@e2e/fixtures/selectors' + +export class PublishDialog extends BaseDialog { + readonly nav: Locator + readonly footer: Locator + readonly savePrompt: Locator + readonly describeStep: Locator + readonly finishStep: Locator + readonly profilePrompt: Locator + readonly gateFlow: Locator + readonly nameInput: Locator + readonly descriptionTextarea: Locator + readonly tagsInput: Locator + readonly backButton: Locator + readonly nextButton: Locator + readonly publishButton: Locator + + constructor(page: Page) { + super(page, TestIds.publish.dialog) + this.nav = this.root.getByTestId(TestIds.publish.nav) + this.footer = this.root.getByTestId(TestIds.publish.footer) + this.savePrompt = this.root.getByTestId(TestIds.publish.savePrompt) + this.describeStep = this.root.getByTestId(TestIds.publish.describeStep) + this.finishStep = this.root.getByTestId(TestIds.publish.finishStep) + this.profilePrompt = this.root.getByTestId(TestIds.publish.profilePrompt) + this.gateFlow = this.root.getByTestId(TestIds.publish.gateFlow) + this.nameInput = this.root.getByTestId(TestIds.publish.nameInput) + this.descriptionTextarea = this.describeStep.locator('textarea') + this.tagsInput = this.root.getByTestId(TestIds.publish.tagsInput) + this.backButton = this.footer.getByRole('button', { name: 'Back' }) + this.nextButton = this.footer.getByRole('button', { name: 'Next' }) + this.publishButton = this.footer.getByRole('button', { + name: 'Publish to ComfyHub' + }) + } + + // Uses showPublishDialog() via Vite-bundled lazy imports that work in both + // dev and production, rather than clicking through the UI. + async open(): Promise<void> { + await this.page.evaluate(async () => { + await window.app!.extensionManager.dialog.showPublishDialog() + }) + await this.waitForVisible() + } + + tagSuggestion(name: string): Locator { + return this.describeStep.getByText(name, { exact: true }) + } + + navStep(label: string): Locator { + return this.nav.getByRole('button', { name: label }) + } + + currentNavStep(): Locator { + return this.nav.locator('[aria-current="step"]') + } + + async goNext(): Promise<void> { + await this.nextButton.click() + } + + async goBack(): Promise<void> { + await this.backButton.click() + } + + async goToStep(label: string): Promise<void> { + await this.navStep(label).click() + } +} diff --git a/browser_tests/helpers/templates.ts b/browser_tests/fixtures/components/Templates.ts similarity index 100% rename from browser_tests/helpers/templates.ts rename to browser_tests/fixtures/components/Templates.ts diff --git a/browser_tests/fixtures/components/TitleEditor.ts b/browser_tests/fixtures/components/TitleEditor.ts new file mode 100644 index 0000000000..e70848bd50 --- /dev/null +++ b/browser_tests/fixtures/components/TitleEditor.ts @@ -0,0 +1,33 @@ +import type { Locator, Page } from '@playwright/test' +import { expect } from '@playwright/test' + +import { TestIds } from '@e2e/fixtures/selectors' + +/** + * The node/group title-editing input. Rendered in three scopes: the canvas + * overlay (page-wide), the properties panel, and the Vue node itself. + */ +export class TitleEditor { + public readonly input: Locator + + constructor(scope: Page | Locator) { + this.input = scope.getByTestId(TestIds.node.titleInput) + } + + async setTitle(title: string): Promise<void> { + await this.input.fill(title) + await this.input.press('Enter') + } + + async cancel(): Promise<void> { + await this.input.press('Escape') + } + + async expectVisible(): Promise<void> { + await expect(this.input).toBeVisible() + } + + async expectHidden(): Promise<void> { + await expect(this.input).toBeHidden() + } +} diff --git a/browser_tests/fixtures/components/WidgetBoundingBox.ts b/browser_tests/fixtures/components/WidgetBoundingBox.ts new file mode 100644 index 0000000000..d691452cd2 --- /dev/null +++ b/browser_tests/fixtures/components/WidgetBoundingBox.ts @@ -0,0 +1,54 @@ +import type { Locator } from '@playwright/test' + +import { TestIds } from '@e2e/fixtures/selectors' + +class BoundingBoxCoordinate { + public readonly root: Locator + public readonly input: Locator + public readonly incrementButton: Locator + public readonly decrementButton: Locator + + constructor(root: Locator) { + this.root = root + this.input = root.locator('input') + this.incrementButton = root.getByTestId(TestIds.widgets.increment) + this.decrementButton = root.getByTestId(TestIds.widgets.decrement) + } + + async type(value: string | number): Promise<void> { + await this.input.fill(String(value)) + await this.input.press('Enter') + } + + async focus(): Promise<void> { + await this.input.focus() + } + + async increment(): Promise<void> { + await this.incrementButton.click() + } + + async decrement(): Promise<void> { + await this.decrementButton.click() + } +} + +export class WidgetBoundingBoxFixture { + public readonly root: Locator + public readonly x: BoundingBoxCoordinate + public readonly y: BoundingBoxCoordinate + public readonly width: BoundingBoxCoordinate + public readonly height: BoundingBoxCoordinate + + constructor(parent: Locator) { + this.root = parent.getByTestId('bounding-box') + this.x = new BoundingBoxCoordinate(this.root.getByTestId('bounding-box-x')) + this.y = new BoundingBoxCoordinate(this.root.getByTestId('bounding-box-y')) + this.width = new BoundingBoxCoordinate( + this.root.getByTestId('bounding-box-width') + ) + this.height = new BoundingBoxCoordinate( + this.root.getByTestId('bounding-box-height') + ) + } +} diff --git a/browser_tests/fixtures/helpers/AssetsHelper.ts b/browser_tests/fixtures/helpers/AssetsHelper.ts index 14ca4ab577..39c5f94336 100644 --- a/browser_tests/fixtures/helpers/AssetsHelper.ts +++ b/browser_tests/fixtures/helpers/AssetsHelper.ts @@ -1,9 +1,19 @@ import type { Page, Route } from '@playwright/test' -import type { JobsListResponse } from '@comfyorg/ingest-types' +import type { + CreateAssetExportData, + CreateAssetExportResponse, + JobsListResponse, + ListAssetsResponse +} from '@comfyorg/ingest-types' -import type { RawJobListItem } from '@/platform/remote/comfyui/jobs/jobTypes' +import type { + JobDetail, + RawJobListItem +} from '@/platform/remote/comfyui/jobs/jobTypes' -const jobsListRoutePattern = /\/api\/jobs(?:\?.*)?$/ +const jobsListRoutePattern = '**/api/jobs?*' +const assetsListRoutePattern = /\/api\/assets(?:\?.*)?$/ +const assetExportRoutePattern = '**/api/assets/export' const inputFilesRoutePattern = /\/internal\/files\/input(?:\?.*)?$/ const historyRoutePattern = /\/api\/history$/ @@ -158,12 +168,23 @@ function getExecutionDuration(job: RawJobListItem): number { export class AssetsHelper { private jobsRouteHandler: ((route: Route) => Promise<void>) | null = null + private cloudAssetsRouteHandler: ((route: Route) => Promise<void>) | null = + null + private assetExportRouteHandler: ((route: Route) => Promise<void>) | null = + null private inputFilesRouteHandler: ((route: Route) => Promise<void>) | null = null private deleteHistoryRouteHandler: ((route: Route) => Promise<void>) | null = null private generatedJobs: RawJobListItem[] = [] + private cloudAssetsResponse: ListAssetsResponse | null = null + private assetExportRequests: CreateAssetExportData['body'][] = [] + private assetExportResponse: CreateAssetExportResponse | null = null private importedFiles: string[] = [] + private readonly jobDetailRouteHandlers = new Map< + string, + (route: Route) => Promise<void> + >() constructor(private readonly page: Page) {} @@ -240,6 +261,82 @@ export class AssetsHelper { await this.page.route(jobsListRoutePattern, this.jobsRouteHandler) } + async mockCloudAssets(response: ListAssetsResponse): Promise<void> { + this.cloudAssetsResponse = response + + if (this.cloudAssetsRouteHandler) { + return + } + + this.cloudAssetsRouteHandler = async (route: Route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(this.cloudAssetsResponse) + }) + } + + await this.page.route(assetsListRoutePattern, this.cloudAssetsRouteHandler) + } + + async mockEmptyCloudAssets(): Promise<void> { + await this.mockCloudAssets({ + assets: [], + total: 0, + has_more: false + }) + } + + async captureAssetExportRequests( + response: CreateAssetExportResponse = { + task_id: 'asset-export-task', + status: 'created' + } + ): Promise<CreateAssetExportData['body'][]> { + this.assetExportRequests = [] + this.assetExportResponse = response + + if (this.assetExportRouteHandler) { + return this.assetExportRequests + } + + this.assetExportRouteHandler = async (route: Route) => { + this.assetExportRequests.push( + route.request().postDataJSON() as CreateAssetExportData['body'] + ) + + await route.fulfill({ + status: 202, + contentType: 'application/json', + body: JSON.stringify(this.assetExportResponse) + }) + } + + await this.page.route(assetExportRoutePattern, this.assetExportRouteHandler) + + return this.assetExportRequests + } + + async mockJobDetail(jobId: string, detail: JobDetail): Promise<void> { + const pattern = `**/api/jobs/${encodeURIComponent(jobId)}` + const existingHandler = this.jobDetailRouteHandlers.get(pattern) + + if (existingHandler) { + await this.page.unroute(pattern, existingHandler) + } + + const handler = async (route: Route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(detail) + }) + } + + this.jobDetailRouteHandlers.set(pattern, handler) + await this.page.route(pattern, handler) + } + async mockInputFiles(files: string[]): Promise<void> { this.importedFiles = [...files] @@ -295,6 +392,9 @@ export class AssetsHelper { async clearMocks(): Promise<void> { this.generatedJobs = [] + this.cloudAssetsResponse = null + this.assetExportRequests = [] + this.assetExportResponse = null this.importedFiles = [] if (this.jobsRouteHandler) { @@ -302,6 +402,22 @@ export class AssetsHelper { this.jobsRouteHandler = null } + if (this.cloudAssetsRouteHandler) { + await this.page.unroute( + assetsListRoutePattern, + this.cloudAssetsRouteHandler + ) + this.cloudAssetsRouteHandler = null + } + + if (this.assetExportRouteHandler) { + await this.page.unroute( + assetExportRoutePattern, + this.assetExportRouteHandler + ) + this.assetExportRouteHandler = null + } + if (this.inputFilesRouteHandler) { await this.page.unroute( inputFilesRoutePattern, @@ -317,5 +433,10 @@ export class AssetsHelper { ) this.deleteHistoryRouteHandler = null } + + for (const [pattern, handler] of this.jobDetailRouteHandlers) { + await this.page.unroute(pattern, handler) + } + this.jobDetailRouteHandlers.clear() } } diff --git a/browser_tests/fixtures/helpers/CanvasHelper.ts b/browser_tests/fixtures/helpers/CanvasHelper.ts index 6cae310d76..70853df3f2 100644 --- a/browser_tests/fixtures/helpers/CanvasHelper.ts +++ b/browser_tests/fixtures/helpers/CanvasHelper.ts @@ -74,7 +74,7 @@ export class CanvasHelper { * Use with `page.mouse` APIs when Vue DOM overlays above the canvas would * cause Playwright's actionability check to fail on the canvas locator. */ - private async toAbsolute(position: Position): Promise<Position> { + async toAbsolute(position: Position): Promise<Position> { const box = await this.canvas.boundingBox() if (!box) throw new Error('Canvas bounding box not available') return { x: box.x + position.x, y: box.y + position.y } @@ -150,6 +150,28 @@ export class CanvasHelper { await nextFrame(this.page) } + async getOffset(): Promise<[number, number]> { + return this.page.evaluate( + () => [...window.app!.canvas.ds.offset] as [number, number] + ) + } + + async getNodeTitleHeight(): Promise<number> { + return this.page.evaluate(() => window.LiteGraph!.NODE_TITLE_HEIGHT) + } + + /** + * Hold `Control+Shift` and drag from `from` to `to` using page-absolute + * coordinates. + */ + async ctrlShiftDrag(from: Position, to: Position): Promise<void> { + await this.page.keyboard.down('Control') + await this.page.keyboard.down('Shift') + await this.dragAndDrop(from, to) + await this.page.keyboard.up('Shift') + await this.page.keyboard.up('Control') + } + async convertOffsetToCanvas( pos: [number, number] ): Promise<[number, number]> { @@ -242,11 +264,39 @@ export class CanvasHelper { await this.page.mouse.up({ button: 'middle' }) } - async disconnectEdge(): Promise<void> { - await this.dragAndDrop( - DefaultGraphPositions.clipTextEncodeNode1InputSlot, - DefaultGraphPositions.emptySpace - ) + async disconnectEdge( + options: { modifiers?: ('Shift' | 'Control' | 'Alt' | 'Meta')[] } = {} + ): Promise<void> { + const { modifiers = [] } = options + for (const mod of modifiers) await this.page.keyboard.down(mod) + try { + await this.dragAndDrop( + DefaultGraphPositions.clipTextEncodeNode1InputSlot, + DefaultGraphPositions.emptySpace + ) + } finally { + for (const mod of modifiers) await this.page.keyboard.up(mod) + } + } + + async middleClick(position: Position): Promise<void> { + await this.mouseClickAt(position, { button: 'middle' }) + } + + async dblclickGroupTitle(title: string): Promise<void> { + const clientPos = await this.page.evaluate((targetTitle) => { + const groups = window.app!.canvas.graph?.groups ?? [] + const group = groups.find( + (g: { title: string }) => g.title === targetTitle + ) + if (!group) return null + const cx = group.pos[0] + group.size[0] / 2 + const cy = group.pos[1] + group.titleHeight / 2 + return window.app!.canvasPosToClientPos([cx, cy]) + }, title) + if (!clientPos) throw new Error(`Group "${title}" not found`) + await this.page.mouse.dblclick(clientPos[0], clientPos[1], { delay: 5 }) + await nextFrame(this.page) } async connectEdge(options: { reverse?: boolean } = {}): Promise<void> { diff --git a/browser_tests/fixtures/helpers/ClipboardHelper.ts b/browser_tests/fixtures/helpers/ClipboardHelper.ts index b1a329e7b7..36acb5b0b9 100644 --- a/browser_tests/fixtures/helpers/ClipboardHelper.ts +++ b/browser_tests/fixtures/helpers/ClipboardHelper.ts @@ -4,7 +4,7 @@ import { basename } from 'path' import type { Locator, Page } from '@playwright/test' import type { KeyboardHelper } from '@e2e/fixtures/helpers/KeyboardHelper' -import { getMimeType } from '@e2e/fixtures/helpers/mimeTypeUtil' +import { getMimeType } from '@e2e/fixtures/utils/mimeTypeUtil' export class ClipboardHelper { constructor( diff --git a/browser_tests/fixtures/helpers/CloudAuthHelper.ts b/browser_tests/fixtures/helpers/CloudAuthHelper.ts index a5c2c55f71..091492f1e3 100644 --- a/browser_tests/fixtures/helpers/CloudAuthHelper.ts +++ b/browser_tests/fixtures/helpers/CloudAuthHelper.ts @@ -13,7 +13,11 @@ import type { Page } from '@playwright/test' * so the SDK believes a user is signed in. Must be called before navigation. */ export class CloudAuthHelper { - constructor(private readonly page: Page) {} + private readonly appUrl: string + + constructor(private readonly page: Page) { + this.appUrl = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188' + } /** * Set up all auth mocks. Must be called before `comfyPage.setup()`. @@ -34,7 +38,7 @@ export class CloudAuthHelper { */ private async seedFirebaseIndexedDB(): Promise<void> { // Navigate to a lightweight endpoint to get a same-origin context - await this.page.goto('http://localhost:8188/api/users') + await this.page.goto(`${this.appUrl}/api/users`) await this.page.evaluate(() => { const MOCK_USER_DATA = { diff --git a/browser_tests/fixtures/helpers/DragDropHelper.ts b/browser_tests/fixtures/helpers/DragDropHelper.ts index 207fc3af41..ee89551e45 100644 --- a/browser_tests/fixtures/helpers/DragDropHelper.ts +++ b/browser_tests/fixtures/helpers/DragDropHelper.ts @@ -3,7 +3,7 @@ import { readFileSync } from 'fs' import type { Page } from '@playwright/test' import type { Position } from '@e2e/fixtures/types' -import { getMimeType } from '@e2e/fixtures/helpers/mimeTypeUtil' +import { getMimeType } from '@e2e/fixtures/utils/mimeTypeUtil' import { assetPath } from '@e2e/fixtures/utils/paths' import { nextFrame } from '@e2e/fixtures/utils/timing' diff --git a/browser_tests/tests/propertiesPanel/ErrorsTabHelper.ts b/browser_tests/fixtures/helpers/ErrorsTabHelper.ts similarity index 73% rename from browser_tests/tests/propertiesPanel/ErrorsTabHelper.ts rename to browser_tests/fixtures/helpers/ErrorsTabHelper.ts index 0e76d2d7e2..2d4be16b6f 100644 --- a/browser_tests/tests/propertiesPanel/ErrorsTabHelper.ts +++ b/browser_tests/fixtures/helpers/ErrorsTabHelper.ts @@ -4,6 +4,21 @@ import type { ComfyPage } from '@e2e/fixtures/ComfyPage' import { TestIds } from '@e2e/fixtures/selectors' import { PropertiesPanelHelper } from '@e2e/tests/propertiesPanel/PropertiesPanelHelper' +export async function enableErrorsOverlay(comfyPage: ComfyPage) { + await comfyPage.settings.setSetting( + 'Comfy.RightSidePanel.ShowErrorsTab', + true + ) +} + +/** Dismiss the error overlay (the floating dialog with the dismiss button). */ +export async function dismissErrorOverlay(comfyPage: ComfyPage): Promise<void> { + const overlay = comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay) + await expect(overlay).toBeVisible() + await overlay.getByTestId(TestIds.dialogs.errorOverlayDismiss).click() + await expect(overlay).toBeHidden() +} + export async function loadWorkflowAndOpenErrorsTab( comfyPage: ComfyPage, workflow: string diff --git a/browser_tests/fixtures/helpers/ExecutionHelper.ts b/browser_tests/fixtures/helpers/ExecutionHelper.ts index 1f53c45268..3483a13667 100644 --- a/browser_tests/fixtures/helpers/ExecutionHelper.ts +++ b/browser_tests/fixtures/helpers/ExecutionHelper.ts @@ -1,9 +1,35 @@ import type { WebSocketRoute } from '@playwright/test' +import type { NodeError, PromptResponse } from '@/schemas/apiSchema' import type { RawJobListItem } from '@/platform/remote/comfyui/jobs/jobTypes' import type { ComfyPage } from '@e2e/fixtures/ComfyPage' import { createMockJob } from '@e2e/fixtures/helpers/AssetsHelper' +const PROMPT_ROUTE_PATTERN = /\/api\/prompt$/ + +/** + * Build a `NodeError` describing a single failed input on a KSampler node. + * Shared between specs that surface validation rings via 400 responses. + */ +export function buildKSamplerError( + type: NodeError['errors'][number]['type'], + inputName: string, + message: string +): NodeError { + return { + class_type: 'KSampler', + dependent_outputs: [], + errors: [ + { + type, + message, + details: '', + extra_info: { input_name: inputName } + } + ] + } +} + /** * Helper for simulating prompt execution in e2e tests. */ @@ -16,13 +42,23 @@ export class ExecutionHelper { constructor( comfyPage: ComfyPage, - private readonly ws: WebSocketRoute + private readonly ws?: WebSocketRoute ) { this.page = comfyPage.page this.command = comfyPage.command this.assets = comfyPage.assets } + private requireWs(): WebSocketRoute { + if (!this.ws) { + throw new Error( + 'ExecutionHelper was constructed without a WebSocketRoute; ' + + 'pass `ws` to use methods that send WS frames.' + ) + } + return this.ws + } + /** * Intercept POST /api/prompt, execute Comfy.QueuePrompt, and return * the synthetic job ID. @@ -39,7 +75,7 @@ export class ExecutionHelper { }) await this.page.route( - '**/api/prompt', + PROMPT_ROUTE_PATTERN, async (route) => { await route.fulfill({ status: 200, @@ -60,6 +96,31 @@ export class ExecutionHelper { return jobId } + async mockValidationFailure( + nodeErrors: Record<string, NodeError> + ): Promise<void> { + const response: PromptResponse = { + node_errors: nodeErrors, + error: { + type: 'prompt_outputs_failed_validation', + message: 'Prompt outputs failed validation', + details: '' + } + } + + await this.page.route( + PROMPT_ROUTE_PATTERN, + async (route) => { + await route.fulfill({ + status: 400, + contentType: 'application/json', + body: JSON.stringify(response) + }) + }, + { times: 1 } + ) + } + /** * Send a binary `b_preview_with_metadata` WS message (type 4). * Encodes the metadata and a tiny 1x1 PNG so the app creates a blob URL. @@ -89,12 +150,12 @@ export class ExecutionHelper { new Uint8Array(buf, 8, metadataBytes.length).set(metadataBytes) new Uint8Array(buf, 8 + metadataBytes.length).set(png) - this.ws.send(Buffer.from(buf)) + this.requireWs().send(Buffer.from(buf)) } /** Send `execution_start` WS event. */ executionStart(jobId: string): void { - this.ws.send( + this.requireWs().send( JSON.stringify({ type: 'execution_start', data: { prompt_id: jobId, timestamp: Date.now() } @@ -104,7 +165,7 @@ export class ExecutionHelper { /** Send `executing` WS event to signal which node is currently running. */ executing(jobId: string, nodeId: string | null): void { - this.ws.send( + this.requireWs().send( JSON.stringify({ type: 'executing', data: { prompt_id: jobId, node: nodeId } @@ -118,7 +179,7 @@ export class ExecutionHelper { nodeId: string, output: Record<string, unknown> ): void { - this.ws.send( + this.requireWs().send( JSON.stringify({ type: 'executed', data: { @@ -133,7 +194,7 @@ export class ExecutionHelper { /** Send `execution_success` WS event. */ executionSuccess(jobId: string): void { - this.ws.send( + this.requireWs().send( JSON.stringify({ type: 'execution_success', data: { prompt_id: jobId, timestamp: Date.now() } @@ -143,7 +204,7 @@ export class ExecutionHelper { /** Send `execution_error` WS event. */ executionError(jobId: string, nodeId: string, message: string): void { - this.ws.send( + this.requireWs().send( JSON.stringify({ type: 'execution_error', data: { @@ -161,7 +222,7 @@ export class ExecutionHelper { /** Send `progress` WS event. */ progress(jobId: string, nodeId: string, value: number, max: number): void { - this.ws.send( + this.requireWs().send( JSON.stringify({ type: 'progress', data: { prompt_id: jobId, node: nodeId, value, max } @@ -201,7 +262,7 @@ export class ExecutionHelper { /** Send `status` WS event to update queue count. */ status(queueRemaining: number): void { - this.ws.send( + this.requireWs().send( JSON.stringify({ type: 'status', data: { status: { exec_info: { queue_remaining: queueRemaining } } } diff --git a/browser_tests/fixtures/helpers/NodeOperationsHelper.ts b/browser_tests/fixtures/helpers/NodeOperationsHelper.ts index 6e0befa1b9..9ad8cc8009 100644 --- a/browser_tests/fixtures/helpers/NodeOperationsHelper.ts +++ b/browser_tests/fixtures/helpers/NodeOperationsHelper.ts @@ -55,29 +55,32 @@ export class NodeOperationsHelper { * Add a node to the graph by type. * @param type - The node type (e.g. 'KSampler', 'VAEDecode') * @param options - GraphAddOptions (ghost, skipComputeOrder). When ghost is - * true and cursorPosition is provided, a synthetic MouseEvent is created - * as the dragEvent. - * @param cursorPosition - Client coordinates for ghost placement dragEvent + * true and position is provided, a synthetic MouseEvent is created as the + * dragEvent. + * @param position - When ghost is true, client coordinates for the ghost + * placement dragEvent. Otherwise, world coordinates assigned to node.pos. */ async addNode( type: string, options?: Omit<GraphAddOptions, 'dragEvent'>, - cursorPosition?: Position + position?: Position ): Promise<NodeReference> { const id = await this.page.evaluate( - ([nodeType, opts, cursor]) => { + ([nodeType, opts, pos]) => { const node = window.LiteGraph!.createNode(nodeType)! const addOpts: Record<string, unknown> = { ...opts } - if (opts?.ghost && cursor) { + if (opts?.ghost && pos) { addOpts.dragEvent = new MouseEvent('click', { - clientX: cursor.x, - clientY: cursor.y + clientX: pos.x, + clientY: pos.y }) + } else if (pos) { + node.pos = [pos.x, pos.y] } window.app!.graph.add(node, addOpts as GraphAddOptions) return node.id }, - [type, options ?? {}, cursorPosition ?? null] as const + [type, options ?? {}, position ?? null] as const ) return new NodeReference(id, this.comfyPage) } diff --git a/browser_tests/fixtures/helpers/PublishApiHelper.ts b/browser_tests/fixtures/helpers/PublishApiHelper.ts new file mode 100644 index 0000000000..fa1cc3104e --- /dev/null +++ b/browser_tests/fixtures/helpers/PublishApiHelper.ts @@ -0,0 +1,231 @@ +import type { Page, Route } from '@playwright/test' + +import type { + AssetInfo, + HubAssetUploadUrlResponse, + HubLabelInfo, + HubLabelListResponse, + HubProfile, + WorkflowPublishInfo +} from '@comfyorg/ingest-types' + +import { comfyPageFixture } from '@e2e/fixtures/ComfyPage' +import { PublishDialog } from '@e2e/fixtures/components/PublishDialog' + +import type { ShareableAssetsResponse } from '@/schemas/apiSchema' + +const DEFAULT_PROFILE: HubProfile = { + username: 'testuser', + display_name: 'Test User', + description: 'A test creator', + avatar_url: undefined +} + +const DEFAULT_TAG_LABELS: HubLabelInfo[] = [ + { name: 'anime', display_name: 'anime', type: 'tag' }, + { name: 'upscale', display_name: 'upscale', type: 'tag' }, + { name: 'faceswap', display_name: 'faceswap', type: 'tag' }, + { name: 'img2img', display_name: 'img2img', type: 'tag' }, + { name: 'controlnet', display_name: 'controlnet', type: 'tag' } +] + +const DEFAULT_PUBLISH_RESPONSE: WorkflowPublishInfo = { + workflow_id: 'test-workflow-id-456', + share_id: 'test-share-id-123', + publish_time: new Date().toISOString(), + listed: true, + assets: [] +} + +const DEFAULT_UPLOAD_URL_RESPONSE: HubAssetUploadUrlResponse = { + upload_url: 'https://mock-s3.example.com/upload', + public_url: 'https://mock-s3.example.com/asset.png', + token: 'mock-upload-token' +} + +export class PublishApiHelper { + private routeHandlers: Array<{ + pattern: string + handler: (route: Route) => Promise<void> + }> = [] + + constructor(private readonly page: Page) {} + + async mockProfile(profile: HubProfile | null): Promise<void> { + await this.addRoute('**/hub/profiles/me', async (route) => { + if (route.request().method() !== 'GET') { + await route.continue() + return + } + if (profile === null) { + await route.fulfill({ status: 404, body: 'Not found' }) + } else { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(profile) + }) + } + }) + } + + async mockTagLabels( + labels: HubLabelInfo[] = DEFAULT_TAG_LABELS + ): Promise<void> { + const response: HubLabelListResponse = { labels } + await this.addRoute('**/hub/labels**', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(response) + }) + }) + } + + async mockPublishStatus( + status: 'unpublished' | WorkflowPublishInfo + ): Promise<void> { + await this.addRoute('**/userdata/*/publish', async (route) => { + if (route.request().method() !== 'GET') { + await route.continue() + return + } + if (status === 'unpublished') { + await route.fulfill({ status: 404, body: 'Not found' }) + } else { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(status) + }) + } + }) + } + + async mockShareableAssets(assets: AssetInfo[] = []): Promise<void> { + const response: ShareableAssetsResponse = { assets } + await this.addRoute('**/assets/from-workflow', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(response) + }) + }) + } + + async mockPublishWorkflow( + response: WorkflowPublishInfo = DEFAULT_PUBLISH_RESPONSE + ): Promise<void> { + await this.removeRoutes('**/hub/workflows') + await this.addRoute('**/hub/workflows', async (route) => { + if (route.request().method() !== 'POST') { + await route.continue() + return + } + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(response) + }) + }) + } + + async mockPublishWorkflowError( + statusCode = 500, + message = 'Failed to publish workflow' + ): Promise<void> { + await this.removeRoutes('**/hub/workflows') + await this.addRoute('**/hub/workflows', async (route) => { + if (route.request().method() !== 'POST') { + await route.continue() + return + } + await route.fulfill({ + status: statusCode, + contentType: 'application/json', + body: JSON.stringify({ message }) + }) + }) + } + + async mockUploadUrl( + response: HubAssetUploadUrlResponse = DEFAULT_UPLOAD_URL_RESPONSE + ): Promise<void> { + await this.addRoute('**/hub/assets/upload-url', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(response) + }) + }) + } + + async setupDefaultMocks(options?: { + hasProfile?: boolean + hasPrivateAssets?: boolean + }): Promise<void> { + const { hasProfile = true, hasPrivateAssets = false } = options ?? {} + + await this.mockProfile(hasProfile ? DEFAULT_PROFILE : null) + await this.mockTagLabels() + await this.mockPublishStatus('unpublished') + await this.mockShareableAssets( + hasPrivateAssets + ? [ + { + id: 'asset-1', + name: 'my_model.safetensors', + preview_url: '', + storage_url: '', + model: true, + public: false, + in_library: true + } + ] + : [] + ) + await this.mockPublishWorkflow() + await this.mockUploadUrl() + } + + async cleanup(): Promise<void> { + for (const { pattern, handler } of this.routeHandlers) { + await this.page.unroute(pattern, handler) + } + this.routeHandlers = [] + } + + private async addRoute( + pattern: string, + handler: (route: Route) => Promise<void> + ): Promise<void> { + this.routeHandlers.push({ pattern, handler }) + await this.page.route(pattern, handler) + } + + private async removeRoutes(pattern: string): Promise<void> { + const handlers = this.routeHandlers.filter( + (route) => route.pattern === pattern + ) + for (const { handler } of handlers) { + await this.page.unroute(pattern, handler) + } + this.routeHandlers = this.routeHandlers.filter( + (route) => route.pattern !== pattern + ) + } +} + +export const publishFixture = comfyPageFixture.extend<{ + publishApi: PublishApiHelper + publishDialog: PublishDialog +}>({ + publishApi: async ({ comfyPage }, use) => { + const helper = new PublishApiHelper(comfyPage.page) + await use(helper) + await helper.cleanup() + }, + publishDialog: async ({ comfyPage }, use) => { + await use(new PublishDialog(comfyPage.page)) + } +}) diff --git a/browser_tests/fixtures/selectors.ts b/browser_tests/fixtures/selectors.ts index 8d1baf38e1..57124fdcaa 100644 --- a/browser_tests/fixtures/selectors.ts +++ b/browser_tests/fixtures/selectors.ts @@ -86,7 +86,11 @@ export const TestIds = { queueButton: 'queue-button', queueModeMenuTrigger: 'queue-mode-menu-trigger', saveButton: 'save-workflow-button', - subscribeButton: 'topbar-subscribe-button' + subscribeButton: 'topbar-subscribe-button', + loginButton: 'login-button', + loginButtonPopover: 'login-button-popover', + loginButtonPopoverLearnMore: 'login-button-popover-learn-more', + actionBarButtons: 'action-bar-buttons' }, nodeLibrary: { bookmarksSection: 'node-library-bookmarks-section' @@ -206,12 +210,26 @@ export const TestIds = { }, queue: { overlayToggle: 'queue-overlay-toggle', - clearHistoryAction: 'clear-history-action' + clearHistoryAction: 'clear-history-action', + jobAssetsList: 'job-assets-list', + notificationBanner: 'queue-notification-banner' }, errors: { imageLoadError: 'error-loading-image', videoLoadError: 'error-loading-video' }, + publish: { + dialog: 'publish-dialog', + savePrompt: 'publish-save-prompt', + describeStep: 'publish-describe-step', + finishStep: 'publish-finish-step', + footer: 'publish-footer', + profilePrompt: 'publish-profile-prompt', + nav: 'publish-nav', + gateFlow: 'publish-gate-flow', + nameInput: 'publish-name-input', + tagsInput: 'publish-tags-input' + }, loading: { overlay: 'loading-overlay' }, @@ -237,6 +255,20 @@ export const TestIds = { batchCounter: 'batch-counter', batchNext: 'batch-next', batchPrev: 'batch-prev' + }, + searchBoxV2: { + resultItem: 'result-item', + filterOption: 'filter-option', + filterChip: 'filter-chip', + chipDelete: 'chip-delete', + noResults: 'no-results', + nodeIdBadge: 'node-id-badge', + sidebarToggle: 'toggle-category-sidebar', + sidebarBackdrop: 'sidebar-backdrop', + filterChipsScroll: 'filter-chips-scroll', + category: (id: string) => `category-${id}`, + rootCategory: (id: string) => `search-category-${id}`, + typeFilter: (key: 'input' | 'output') => `search-filter-${key}` } } as const diff --git a/browser_tests/fixtures/helpers/boundsUtils.ts b/browser_tests/fixtures/utils/boundsUtils.ts similarity index 100% rename from browser_tests/fixtures/helpers/boundsUtils.ts rename to browser_tests/fixtures/utils/boundsUtils.ts diff --git a/browser_tests/helpers/builderTestUtils.ts b/browser_tests/fixtures/utils/builderTestUtils.ts similarity index 98% rename from browser_tests/helpers/builderTestUtils.ts rename to browser_tests/fixtures/utils/builderTestUtils.ts index 0d2de03aaf..720b0685e7 100644 --- a/browser_tests/helpers/builderTestUtils.ts +++ b/browser_tests/fixtures/utils/builderTestUtils.ts @@ -5,7 +5,7 @@ import type { AppModeHelper } from '@e2e/fixtures/helpers/AppModeHelper' import type { NodeReference } from '@e2e/fixtures/utils/litegraphUtils' import { comfyExpect } from '@e2e/fixtures/ComfyPage' -import { fitToViewInstant } from '@e2e/helpers/fitToView' +import { fitToViewInstant } from '@e2e/fixtures/utils/fitToView' interface BuilderSetupResult { inputNodeTitle: string diff --git a/browser_tests/helpers/clipboardSpy.ts b/browser_tests/fixtures/utils/clipboardSpy.ts similarity index 100% rename from browser_tests/helpers/clipboardSpy.ts rename to browser_tests/fixtures/utils/clipboardSpy.ts diff --git a/browser_tests/helpers/fitToView.ts b/browser_tests/fixtures/utils/fitToView.ts similarity index 100% rename from browser_tests/helpers/fitToView.ts rename to browser_tests/fixtures/utils/fitToView.ts diff --git a/browser_tests/fixtures/utils/groupHelpers.ts b/browser_tests/fixtures/utils/groupHelpers.ts new file mode 100644 index 0000000000..37d1de232b --- /dev/null +++ b/browser_tests/fixtures/utils/groupHelpers.ts @@ -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 +} diff --git a/browser_tests/fixtures/utils/litegraphUtils.ts b/browser_tests/fixtures/utils/litegraphUtils.ts index 4e7051eb69..0036526a65 100644 --- a/browser_tests/fixtures/utils/litegraphUtils.ts +++ b/browser_tests/fixtures/utils/litegraphUtils.ts @@ -1,7 +1,8 @@ import { expect } from '@playwright/test' +import type { SerialisableLLink } from '@/lib/litegraph/src/types/serialisation' import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema' -import { ManageGroupNode } from '@e2e/helpers/manageGroupNode' +import { ManageGroupNode } from '@e2e/fixtures/components/ManageGroupNode' import type { ComfyPage } from '@e2e/fixtures/ComfyPage' import type { Position, Size } from '@e2e/fixtures/types' import { VueNodeFixture } from '@e2e/fixtures/utils/vueNodeFixtures' @@ -169,6 +170,36 @@ class NodeSlotReference { [this.type, this.node.id, this.index] as const ) } + + async getLink(): Promise<SerialisableLLink | null> { + return await this.node.comfyPage.page.evaluate( + ([type, id, index]) => { + const graph = window.app!.canvas.graph! + const node = graph.getNodeById(id) + if (!node) throw new Error(`Node ${id} not found.`) + const linkId = + type === 'input' + ? node.inputs[index].link + : (node.outputs[index].links ?? [])[0] + if (linkId == null) return null + const link = + graph.links instanceof Map + ? graph.links.get(linkId) + : graph.links[linkId] + if (!link) return null + return { + id: link.id, + origin_id: link.origin_id, + origin_slot: link.origin_slot, + target_id: link.target_id, + target_slot: link.target_slot, + type: link.type, + parentId: link.parentId + } + }, + [this.type, this.node.id, this.index] as const + ) + } } export class NodeWidgetReference { @@ -326,6 +357,23 @@ export class NodeReference { const nodeSize = await this.getSize() return { x: nodePos.x + nodeSize.width / 2, y: nodePos.y - 15 } } + async dragBy( + delta: Position, + options?: { + modifiers?: ('Shift' | 'Control' | 'Alt' | 'Meta')[] + } + ): Promise<void> { + const titlePos = await this.getTitlePosition() + const target = { x: titlePos.x + delta.x, y: titlePos.y + delta.y } + const modifiers = options?.modifiers ?? [] + const keyboard = this.comfyPage.page.keyboard + for (const mod of modifiers) await keyboard.down(mod) + try { + await this.comfyPage.canvasOps.dragAndDrop(titlePos, target) + } finally { + for (const mod of modifiers) await keyboard.up(mod) + } + } async isPinned() { return !!(await this.getFlags()).pinned } diff --git a/browser_tests/fixtures/helpers/mimeTypeUtil.ts b/browser_tests/fixtures/utils/mimeTypeUtil.ts similarity index 100% rename from browser_tests/fixtures/helpers/mimeTypeUtil.ts rename to browser_tests/fixtures/utils/mimeTypeUtil.ts diff --git a/browser_tests/helpers/painter.ts b/browser_tests/fixtures/utils/painter.ts similarity index 100% rename from browser_tests/helpers/painter.ts rename to browser_tests/fixtures/utils/painter.ts diff --git a/browser_tests/helpers/perfReporter.ts b/browser_tests/fixtures/utils/perfReporter.ts similarity index 94% rename from browser_tests/helpers/perfReporter.ts rename to browser_tests/fixtures/utils/perfReporter.ts index 788b6b1dd2..61e7b369ce 100644 --- a/browser_tests/helpers/perfReporter.ts +++ b/browser_tests/fixtures/utils/perfReporter.ts @@ -56,7 +56,13 @@ export function writePerfReport( gitSha = process.env.GITHUB_SHA ?? 'local', branch = process.env.GITHUB_HEAD_REF ?? 'local' ) { - if (!readdirSync('test-results', { withFileTypes: true }).length) return + let entries + try { + entries = readdirSync('test-results', { withFileTypes: true }) + } catch { + return + } + if (!entries.length) return let tempFiles: string[] try { diff --git a/browser_tests/helpers/promotedWidgets.ts b/browser_tests/fixtures/utils/promotedWidgets.ts similarity index 98% rename from browser_tests/helpers/promotedWidgets.ts rename to browser_tests/fixtures/utils/promotedWidgets.ts index 5ba65c21d0..4228565c81 100644 --- a/browser_tests/helpers/promotedWidgets.ts +++ b/browser_tests/fixtures/utils/promotedWidgets.ts @@ -1,6 +1,6 @@ import type { ComfyPage } from '@e2e/fixtures/ComfyPage' -type PromotedWidgetEntry = [string, string] +export type PromotedWidgetEntry = [string, string] function isPromotedWidgetEntry(entry: unknown): entry is PromotedWidgetEntry { return ( diff --git a/browser_tests/fixtures/utils/selectionToolbox.ts b/browser_tests/fixtures/utils/selectionToolbox.ts new file mode 100644 index 0000000000..d135df4a4d --- /dev/null +++ b/browser_tests/fixtures/utils/selectionToolbox.ts @@ -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 +} diff --git a/browser_tests/fixtures/utils/vueNodeFixtures.ts b/browser_tests/fixtures/utils/vueNodeFixtures.ts index 51abc60639..5616fcdf0e 100644 --- a/browser_tests/fixtures/utils/vueNodeFixtures.ts +++ b/browser_tests/fixtures/utils/vueNodeFixtures.ts @@ -1,12 +1,13 @@ import type { Locator } from '@playwright/test' +import { TitleEditor } from '@e2e/fixtures/components/TitleEditor' import { TestIds } from '@e2e/fixtures/selectors' /** DOM-centric helper for a single Vue-rendered node on the canvas. */ export class VueNodeFixture { public readonly header: Locator public readonly title: Locator - public readonly titleInput: Locator + public readonly titleEditor: TitleEditor public readonly body: Locator public readonly pinIndicator: Locator public readonly collapseButton: Locator @@ -16,7 +17,7 @@ export class VueNodeFixture { constructor(private readonly locator: Locator) { this.header = locator.locator('[data-testid^="node-header-"]') this.title = locator.getByTestId('node-title') - this.titleInput = locator.getByTestId('node-title-input') + this.titleEditor = new TitleEditor(locator) this.body = locator.locator('[data-testid^="node-body-"]') this.pinIndicator = locator.getByTestId(TestIds.node.pinIndicator) this.collapseButton = locator.getByTestId('node-collapse-button') @@ -30,17 +31,8 @@ export class VueNodeFixture { async setTitle(value: string): Promise<void> { await this.header.dblclick() - const input = this.titleInput - await input.waitFor({ state: 'visible' }) - await input.fill(value) - await input.press('Enter') - } - - async cancelTitleEdit(): Promise<void> { - await this.header.dblclick() - const input = this.titleInput - await input.waitFor({ state: 'visible' }) - await input.press('Escape') + await this.titleEditor.expectVisible() + await this.titleEditor.setTitle(value) } async toggleCollapse(): Promise<void> { diff --git a/browser_tests/globalTeardown.ts b/browser_tests/globalTeardown.ts index f565da9405..1600af84c5 100644 --- a/browser_tests/globalTeardown.ts +++ b/browser_tests/globalTeardown.ts @@ -2,7 +2,7 @@ import { config as dotenvConfig } from 'dotenv' import MCR from 'monocart-coverage-reports' import { COVERAGE_OUTPUT_DIR, coverageSourceFilter } from '@e2e/coverageConfig' -import { writePerfReport } from '@e2e/helpers/perfReporter' +import { writePerfReport } from '@e2e/fixtures/utils/perfReporter' import { restorePath } from '@e2e/utils/backupUtils' dotenvConfig() diff --git a/browser_tests/tests/actionBarButtons.spec.ts b/browser_tests/tests/actionBarButtons.spec.ts new file mode 100644 index 0000000000..fc015c5417 --- /dev/null +++ b/browser_tests/tests/actionBarButtons.spec.ts @@ -0,0 +1,140 @@ +import type { Page } from '@playwright/test' +import { expect } from '@playwright/test' + +import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage' +import { TestIds } from '@e2e/fixtures/selectors' + +const ICON_CLASS = 'icon-[lucide--star]' +const BUTTON_LABEL = 'Test Action' +const BUTTON_TOOLTIP = 'Test action tooltip' + +async function registerTestButton( + page: Page, + opts: { + name?: string + icon?: string + label?: string + tooltip?: string + } = {} +): Promise<void> { + await page.evaluate( + ({ name, icon, label, tooltip }) => { + window.app!.registerExtension({ + name, + actionBarButtons: [{ icon, label, tooltip, onClick: () => {} }] + }) + }, + { + name: opts.name ?? 'TestActionBarButton', + icon: opts.icon ?? ICON_CLASS, + label: opts.label ?? BUTTON_LABEL, + tooltip: opts.tooltip ?? BUTTON_TOOLTIP + } + ) +} + +test.describe('ActionBar Buttons', { tag: ['@ui'] }, () => { + test.describe('Empty state', () => { + test('container is hidden when no extension registers buttons', async ({ + comfyPage + }) => { + await expect( + comfyPage.page.getByTestId(TestIds.topbar.actionBarButtons) + ).toBeHidden() + }) + }) + + test.describe('Button rendering', () => { + test('registered button is visible with correct label', async ({ + comfyPage + }) => { + await registerTestButton(comfyPage.page) + const container = comfyPage.page.getByTestId( + TestIds.topbar.actionBarButtons + ) + await expect(container).toBeVisible() + await expect( + container.getByRole('button', { name: BUTTON_TOOLTIP }) + ).toBeVisible() + await expect(container.getByText(BUTTON_LABEL)).toBeVisible() + }) + + test('button icon is rendered', async ({ comfyPage }) => { + await registerTestButton(comfyPage.page) + const icon = comfyPage.page + .getByTestId(TestIds.topbar.actionBarButtons) + .getByRole('button', { name: BUTTON_TOOLTIP }) + .locator('i') + await expect(icon).toHaveClass(ICON_CLASS) + }) + + test('multiple registered buttons all appear', async ({ comfyPage }) => { + await comfyPage.page.evaluate(() => { + window.app!.registerExtension({ + name: 'TestActionBarButtons', + actionBarButtons: [ + { + icon: 'icon-[lucide--star]', + label: 'First', + tooltip: 'First action', + onClick: () => {} + }, + { + icon: 'icon-[lucide--heart]', + label: 'Second', + tooltip: 'Second action', + onClick: () => {} + } + ] + }) + }) + + const container = comfyPage.page.getByTestId( + TestIds.topbar.actionBarButtons + ) + await expect( + container.getByRole('button', { name: 'First action' }) + ).toBeVisible() + await expect( + container.getByRole('button', { name: 'Second action' }) + ).toBeVisible() + }) + }) + + test.describe('Click handler', () => { + test('clicking a button fires its onClick handler', async ({ + comfyPage + }) => { + const onClickFired = comfyPage.page.evaluate( + ({ icon, label, tooltip }) => + new Promise<boolean>((resolve) => { + window.app!.registerExtension({ + name: 'TestActionBarButton', + actionBarButtons: [ + { icon, label, tooltip, onClick: () => resolve(true) } + ] + }) + }), + { icon: ICON_CLASS, label: BUTTON_LABEL, tooltip: BUTTON_TOOLTIP } + ) + + const button = comfyPage.page + .getByTestId(TestIds.topbar.actionBarButtons) + .getByRole('button', { name: BUTTON_TOOLTIP }) + await button.click() + + await expect(onClickFired).resolves.toBe(true) + }) + }) + + test.describe('Mobile layout', { tag: ['@mobile'] }, () => { + test('button label is hidden on mobile viewport', async ({ comfyPage }) => { + await registerTestButton(comfyPage.page) + const container = comfyPage.page.getByTestId( + TestIds.topbar.actionBarButtons + ) + await expect(container).toBeVisible() + await expect(container.getByText(BUTTON_LABEL)).toBeHidden() + }) + }) +}) diff --git a/browser_tests/tests/appModeArrange.spec.ts b/browser_tests/tests/appModeArrange.spec.ts index 1dd343d103..a9c3fe9773 100644 --- a/browser_tests/tests/appModeArrange.spec.ts +++ b/browser_tests/tests/appModeArrange.spec.ts @@ -2,7 +2,7 @@ import { comfyPageFixture as test, comfyExpect as expect } from '@e2e/fixtures/ComfyPage' -import { setupBuilder } from '@e2e/helpers/builderTestUtils' +import { setupBuilder } from '@e2e/fixtures/utils/builderTestUtils' test.describe('App mode arrange step', { tag: '@ui' }, () => { test.beforeEach(async ({ comfyPage }) => { diff --git a/browser_tests/tests/appModePruning.spec.ts b/browser_tests/tests/appModePruning.spec.ts index d9a082c499..110ac456fa 100644 --- a/browser_tests/tests/appModePruning.spec.ts +++ b/browser_tests/tests/appModePruning.spec.ts @@ -3,8 +3,8 @@ import { comfyPageFixture as test, comfyExpect as expect } from '@e2e/fixtures/ComfyPage' -import { setupBuilder } from '@e2e/helpers/builderTestUtils' -import { fitToViewInstant } from '@e2e/helpers/fitToView' +import { setupBuilder } from '@e2e/fixtures/utils/builderTestUtils' +import { fitToViewInstant } from '@e2e/fixtures/utils/fitToView' const RESIZE_NODE_TITLE = 'Resize Image/Mask' const RESIZE_NODE_ID = '1' diff --git a/browser_tests/tests/appModeWidgetRename.spec.ts b/browser_tests/tests/appModeWidgetRename.spec.ts index 7dd738ba78..87bad9d636 100644 --- a/browser_tests/tests/appModeWidgetRename.spec.ts +++ b/browser_tests/tests/appModeWidgetRename.spec.ts @@ -5,7 +5,7 @@ import { import { saveAndReopenInAppMode, setupSubgraphBuilder -} from '@e2e/helpers/builderTestUtils' +} from '@e2e/fixtures/utils/builderTestUtils' test.describe('App mode widget rename', { tag: ['@ui', '@subgraph'] }, () => { test.beforeEach(async ({ comfyPage }) => { diff --git a/browser_tests/tests/bottomPanelLogs.spec.ts b/browser_tests/tests/bottomPanelLogs.spec.ts index 8465c794a3..4befe18c67 100644 --- a/browser_tests/tests/bottomPanelLogs.spec.ts +++ b/browser_tests/tests/bottomPanelLogs.spec.ts @@ -12,7 +12,7 @@ import { webSocketFixture } from '@e2e/fixtures/ws' import { getClipboardText, interceptClipboardWrite -} from '@e2e/helpers/clipboardSpy' +} from '@e2e/fixtures/utils/clipboardSpy' const test = mergeTests(comfyPageFixture, logsTerminalFixture, webSocketFixture) diff --git a/browser_tests/tests/builderReorder.spec.ts b/browser_tests/tests/builderReorder.spec.ts index ede4cbd860..feb73e7e74 100644 --- a/browser_tests/tests/builderReorder.spec.ts +++ b/browser_tests/tests/builderReorder.spec.ts @@ -8,7 +8,7 @@ import { builderSaveAs, openWorkflowFromSidebar, setupBuilder -} from '@e2e/helpers/builderTestUtils' +} from '@e2e/fixtures/utils/builderTestUtils' const WIDGETS = ['seed', 'steps', 'cfg'] diff --git a/browser_tests/tests/builderSaveFlow.spec.ts b/browser_tests/tests/builderSaveFlow.spec.ts index 7d98e305f7..65763efadf 100644 --- a/browser_tests/tests/builderSaveFlow.spec.ts +++ b/browser_tests/tests/builderSaveFlow.spec.ts @@ -8,8 +8,8 @@ import { builderSaveAs, openWorkflowFromSidebar, setupBuilder -} from '@e2e/helpers/builderTestUtils' -import { fitToViewInstant } from '@e2e/helpers/fitToView' +} from '@e2e/fixtures/utils/builderTestUtils' +import { fitToViewInstant } from '@e2e/fixtures/utils/fitToView' /** * After a first save, open save-as again from the chevron, diff --git a/browser_tests/tests/canvasLayoutSettings.spec.ts b/browser_tests/tests/canvasLayoutSettings.spec.ts new file mode 100644 index 0000000000..1996c8ea71 --- /dev/null +++ b/browser_tests/tests/canvasLayoutSettings.spec.ts @@ -0,0 +1,175 @@ +import { + comfyExpect as expect, + comfyPageFixture as test +} from '@e2e/fixtures/ComfyPage' +import type { ComfyPage } from '@e2e/fixtures/ComfyPage' +import type { Size } from '@e2e/fixtures/types' + +const expectedGroupSize = ( + nodeBounds: Size, + padding: number, + titleHeight: number +): Size => ({ + width: nodeBounds.width + padding * 2, + // Group height adds one title row above the contained node bounds (which + // themselves already include the node's own title), independent of padding. + height: nodeBounds.height + padding * 2 + titleHeight +}) + +test.describe('Canvas layout settings', { tag: '@canvas' }, () => { + test.describe('Comfy.SnapToGrid.GridSize', () => { + const DRAG_DELTA = { x: 550, y: 330 } as const + + test.beforeEach(async ({ comfyPage }) => { + await comfyPage.nodeOps.clearGraph() + }) + + const createNode = async (comfyPage: ComfyPage) => { + const note = await comfyPage.nodeOps.addNode('Note', undefined, { + x: 0, + y: 0 + }) + await note.centerOnNode() + return note + } + + test('shift+drag rounds final node position to multiples of grid size', async ({ + comfyPage + }) => { + await comfyPage.settings.setSetting('Comfy.SnapToGrid.GridSize', 100) + const note = await createNode(comfyPage) + + await note.dragBy(DRAG_DELTA, { modifiers: ['Shift'] }) + + // raw final world pos = (550, 330); rounded to nearest 100 = (600, 300) + const after = await note.getProperty<[number, number]>('pos') + expect(after[0]).toBe(600) + expect(after[1]).toBe(300) + }) + + test('grid size determines the snap multiple', async ({ comfyPage }) => { + await comfyPage.settings.setSetting('Comfy.SnapToGrid.GridSize', 50) + const note = await createNode(comfyPage) + + await note.dragBy(DRAG_DELTA, { modifiers: ['Shift'] }) + + // raw final world pos = (550, 330); rounded to nearest 50 = (550, 350) + const after = await note.getProperty<[number, number]>('pos') + expect(after[0]).toBe(550) + expect(after[1]).toBe(350) + }) + + test('drag without shift bypasses snap regardless of grid size', async ({ + comfyPage + }) => { + await comfyPage.settings.setSetting('Comfy.SnapToGrid.GridSize', 100) + const note = await createNode(comfyPage) + const before = await note.getProperty<[number, number]>('pos') + + await note.dragBy(DRAG_DELTA) + + const after = await note.getProperty<[number, number]>('pos') + expect(after[0]).toBe(before[0] + DRAG_DELTA.x) + expect(after[1]).toBe(before[1] + DRAG_DELTA.y) + }) + }) + + test.describe('Comfy.GroupSelectedNodes.Padding', () => { + test.beforeEach(async ({ comfyPage }) => { + await comfyPage.workflow.loadWorkflow('nodes/single_ksampler') + }) + + const groupAroundAllNodesWithPadding = async ( + comfyPage: ComfyPage, + padding: number + ): Promise<Size> => { + await comfyPage.settings.setSetting( + 'Comfy.GroupSelectedNodes.Padding', + padding + ) + await comfyPage.command.executeCommand('Comfy.Canvas.SelectAll') + await comfyPage.command.executeCommand('Comfy.Graph.GroupSelectedNodes') + return comfyPage.page.evaluate(() => { + const group = window.app!.graph.groups[0] + return { width: group.size[0], height: group.size[1] } + }) + } + + test('padding=0 makes the group exactly enclose the selection', async ({ + comfyPage + }) => { + const ksampler = ( + await comfyPage.nodeOps.getNodeRefsByType('KSampler') + )[0] + const nodeBounds = await ksampler.getBounding() + const titleHeight = await comfyPage.canvasOps.getNodeTitleHeight() + + const group = await groupAroundAllNodesWithPadding(comfyPage, 0) + + expect(group).toEqual(expectedGroupSize(nodeBounds, 0, titleHeight)) + }) + + test('padding=50 grows the group by 100 around the selection', async ({ + comfyPage + }) => { + const ksampler = ( + await comfyPage.nodeOps.getNodeRefsByType('KSampler') + )[0] + const nodeBounds = await ksampler.getBounding() + const titleHeight = await comfyPage.canvasOps.getNodeTitleHeight() + + const group = await groupAroundAllNodesWithPadding(comfyPage, 50) + + expect(group).toEqual(expectedGroupSize(nodeBounds, 50, titleHeight)) + }) + }) + + test.describe('LiteGraph.ContextMenu.Scaling', () => { + const ZOOM_SCALE = 2 + const litegraphContextMenu = (comfyPage: ComfyPage) => + comfyPage.page.locator('.litecontextmenu') + + test.beforeEach(async ({ comfyPage }) => { + await comfyPage.workflow.loadWorkflow('widgets/load_image_widget') + await comfyPage.canvasOps.setScale(ZOOM_SCALE) + }) + + const openComboMenu = async (comfyPage: ComfyPage) => { + const loadImage = ( + await comfyPage.nodeOps.getNodeRefsByType('LoadImage') + )[0] + const fileCombo = await loadImage.getWidget(0) + await fileCombo.click() + } + + test('combo widget popup is scaled when setting is enabled', async ({ + comfyPage + }) => { + await comfyPage.settings.setSetting('LiteGraph.ContextMenu.Scaling', true) + + await openComboMenu(comfyPage) + + const menu = litegraphContextMenu(comfyPage) + await expect(menu).toBeVisible() + await expect(menu).toHaveCSS( + 'transform', + `matrix(${ZOOM_SCALE}, 0, 0, ${ZOOM_SCALE}, 0, 0)` + ) + }) + + test('combo widget popup is not scaled when setting is disabled', async ({ + comfyPage + }) => { + await comfyPage.settings.setSetting( + 'LiteGraph.ContextMenu.Scaling', + false + ) + + await openComboMenu(comfyPage) + + const menu = litegraphContextMenu(comfyPage) + await expect(menu).toBeVisible() + await expect(menu).toHaveCSS('transform', 'none') + }) + }) +}) diff --git a/browser_tests/tests/canvasSettings.spec.ts b/browser_tests/tests/canvasSettings.spec.ts new file mode 100644 index 0000000000..e026edc284 --- /dev/null +++ b/browser_tests/tests/canvasSettings.spec.ts @@ -0,0 +1,400 @@ +import type { ComfyPage } from '@e2e/fixtures/ComfyPage' +import { + comfyExpect as expect, + comfyPageFixture as test +} from '@e2e/fixtures/ComfyPage' +import { sleep } from '@e2e/fixtures/utils/timing' + +const CLIP_NODE_COUNT = 2 + +const getClipNodesDragBox = async (comfyPage: ComfyPage) => { + const clipNodes = await comfyPage.nodeOps.getNodeRefsByType('CLIPTextEncode') + expect( + clipNodes, + 'Default workflow is expected to contain exactly two CLIPTextEncode nodes' + ).toHaveLength(CLIP_NODE_COUNT) + const p1 = await clipNodes[0].getPosition() + const p2 = await clipNodes[1].getPosition() + const margin = 64 + const from = await comfyPage.canvasOps.toAbsolute({ + x: Math.min(p1.x, p2.x) - margin, + y: Math.min(p1.y, p2.y) - margin + }) + const to = await comfyPage.canvasOps.toAbsolute({ + x: Math.max(p1.x, p2.x) + margin, + y: Math.max(p1.y, p2.y) + margin + }) + return { from, to } +} + +test.describe('Canvas settings', { tag: '@canvas' }, () => { + test.describe('Comfy.Graph.CanvasInfo', () => { + test( + 'toggles the bottom-left HUD', + { tag: '@screenshot' }, + async ({ comfyPage }) => { + const box = await comfyPage.canvas.boundingBox() + expect(box, 'Canvas bounding box must be available').not.toBeNull() + // HUD is drawn ~80px tall along the bottom edge of the canvas; grab a + // comfortable 180px × 160px strip to catch it across viewports. + const HUD_WIDTH = 180 + const HUD_HEIGHT = 160 + const hudClip = { + x: box!.x, + y: box!.y + box!.height - HUD_HEIGHT, + width: HUD_WIDTH, + height: HUD_HEIGHT + } + + await test.step('Capture HUD region with setting off', async () => { + await comfyPage.settings.setSetting('Comfy.Graph.CanvasInfo', false) + await comfyPage.canvasOps.resetView() + await comfyPage.canvasOps.moveMouseToEmptyArea() + await expect(comfyPage.page).toHaveScreenshot( + 'canvas-info-hud-off.png', + { clip: hudClip, maxDiffPixels: 50 } + ) + }) + + await test.step('Capture HUD region with setting on', async () => { + await comfyPage.settings.setSetting('Comfy.Graph.CanvasInfo', true) + await comfyPage.canvasOps.moveMouseToEmptyArea() + await expect(comfyPage.page).toHaveScreenshot( + 'canvas-info-hud-on.png', + { clip: hudClip, maxDiffPixels: 50 } + ) + }) + } + ) + }) + + test.describe('Comfy.Graph.CtrlShiftZoom', () => { + const CTRL_SHIFT_DRAG_FROM = { x: 100, y: 100 } + const CTRL_SHIFT_DRAG_TO = { x: 400, y: 400 } + + test('Ctrl+Shift+drag zooms canvas when enabled', async ({ comfyPage }) => { + await comfyPage.settings.setSetting('Comfy.Graph.CtrlShiftZoom', true) + await comfyPage.canvasOps.resetView() + const initialScale = await comfyPage.canvasOps.getScale() + + await comfyPage.canvasOps.ctrlShiftDrag( + CTRL_SHIFT_DRAG_FROM, + CTRL_SHIFT_DRAG_TO + ) + + await expect + .poll(() => comfyPage.canvasOps.getScale()) + .not.toBeCloseTo(initialScale, 2) + }) + + test('Ctrl+Shift+drag does not zoom when disabled', async ({ + comfyPage + }) => { + await comfyPage.settings.setSetting('Comfy.Graph.CtrlShiftZoom', false) + await comfyPage.canvasOps.resetView() + const initialScale = await comfyPage.canvasOps.getScale() + + await comfyPage.canvasOps.ctrlShiftDrag( + CTRL_SHIFT_DRAG_FROM, + CTRL_SHIFT_DRAG_TO + ) + + expect(await comfyPage.canvasOps.getScale()).toBeCloseTo(initialScale, 2) + }) + }) + + test.describe('Comfy.Graph.LiveSelection', () => { + test.beforeEach(async ({ comfyPage }) => { + await comfyPage.settings.setSetting( + 'Comfy.Canvas.NavigationMode', + 'standard' + ) + }) + + test('selects nodes mid-drag when enabled', async ({ comfyPage }) => { + await comfyPage.settings.setSetting('Comfy.Graph.LiveSelection', true) + const { from, to } = await getClipNodesDragBox(comfyPage) + + await comfyPage.page.mouse.move(from.x, from.y) + await comfyPage.page.mouse.down() + await comfyPage.page.mouse.move(to.x, to.y, { steps: 10 }) + + await expect + .poll(() => comfyPage.nodeOps.getSelectedGraphNodesCount()) + .toBe(CLIP_NODE_COUNT) + + await comfyPage.page.mouse.up() + await comfyPage.nextFrame() + }) + + test('defers selection to drag end when disabled', async ({ + comfyPage + }) => { + await comfyPage.settings.setSetting('Comfy.Graph.LiveSelection', false) + const { from, to } = await getClipNodesDragBox(comfyPage) + + await comfyPage.page.mouse.move(from.x, from.y) + await comfyPage.page.mouse.down() + await comfyPage.page.mouse.move(to.x, to.y, { steps: 10 }) + expect(await comfyPage.nodeOps.getSelectedGraphNodesCount()).toBe(0) + + await comfyPage.page.mouse.up() + await expect + .poll(() => comfyPage.nodeOps.getSelectedGraphNodesCount()) + .toBe(CLIP_NODE_COUNT) + }) + }) + + test.describe('Comfy.Canvas.MouseWheelScroll', () => { + const WHEEL_POS = { x: 400, y: 400 } + + test('wheel zooms when set to zoom', async ({ comfyPage }) => { + await comfyPage.settings.setSetting( + 'Comfy.Canvas.MouseWheelScroll', + 'zoom' + ) + const initialScale = await comfyPage.canvasOps.getScale() + + await comfyPage.page.mouse.move(WHEEL_POS.x, WHEEL_POS.y) + await comfyPage.page.mouse.wheel(0, -120) + await comfyPage.page.mouse.wheel(0, -120) + await comfyPage.nextFrame() + + expect(await comfyPage.canvasOps.getScale()).not.toBeCloseTo( + initialScale, + 3 + ) + }) + + test('wheel pans when set to panning', async ({ comfyPage }) => { + await comfyPage.settings.setSetting( + 'Comfy.Canvas.MouseWheelScroll', + 'panning' + ) + const initialScale = await comfyPage.canvasOps.getScale() + const initialOffset = await comfyPage.canvasOps.getOffset() + + await comfyPage.page.mouse.move(WHEEL_POS.x, WHEEL_POS.y) + await comfyPage.page.mouse.wheel(0, 120) + await comfyPage.page.mouse.wheel(0, 120) + await comfyPage.nextFrame() + + expect(await comfyPage.canvasOps.getScale()).toBeCloseTo(initialScale, 3) + const offset = await comfyPage.canvasOps.getOffset() + expect( + Math.abs(offset[0] - initialOffset[0]) + + Math.abs(offset[1] - initialOffset[1]) + ).toBeGreaterThan(1) + }) + }) + + test.describe('Comfy.Canvas.LeftMouseClickBehavior', () => { + test('override to panning makes empty left-drag pan the canvas', async ({ + comfyPage + }) => { + await test.step("Flip to 'select' then back to 'panning' (NavigationMode→custom)", async () => { + await comfyPage.settings.setSetting( + 'Comfy.Canvas.LeftMouseClickBehavior', + 'select' + ) + await comfyPage.settings.setSetting( + 'Comfy.Canvas.LeftMouseClickBehavior', + 'panning' + ) + }) + + await comfyPage.canvasOps.resetView() + + const initialOffset = await comfyPage.canvasOps.getOffset() + await comfyPage.canvasOps.dragAndDrop( + { x: 200, y: 300 }, + { x: 400, y: 500 } + ) + const offset = await comfyPage.canvasOps.getOffset() + + expect( + Math.abs(offset[0] - initialOffset[0]) + + Math.abs(offset[1] - initialOffset[1]) + ).toBeGreaterThan(50) + expect(await comfyPage.nodeOps.getSelectedGraphNodesCount()).toBe(0) + }) + + test('override to select turns empty left-drag into a selection rectangle', async ({ + comfyPage + }) => { + await comfyPage.settings.setSetting( + 'Comfy.Canvas.LeftMouseClickBehavior', + 'select' + ) + const { from, to } = await getClipNodesDragBox(comfyPage) + + await comfyPage.canvasOps.dragAndDrop(from, to) + + await expect + .poll(() => comfyPage.nodeOps.getSelectedGraphNodesCount()) + .toBe(CLIP_NODE_COUNT) + }) + }) + + test.describe('Pointer settings', () => { + /** + * Press left-mouse at canvas-relative `pos`, hold for `holdMs` (0 = no + * hold), nudge by `(dx, dy)` absolute pixels, then release. Spec-local + * because it exists only to probe the CanvasPointer timing thresholds. + */ + const holdDragAt = async ( + comfyPage: ComfyPage, + pos: { x: number; y: number }, + opts: { dx: number; dy: number; holdMs: number } + ) => { + const abs = await comfyPage.canvasOps.toAbsolute(pos) + await comfyPage.page.mouse.move(abs.x, abs.y) + await comfyPage.page.mouse.down() + await sleep(opts.holdMs) + await comfyPage.page.mouse.move(abs.x + opts.dx, abs.y + opts.dy) + await comfyPage.page.mouse.up() + await comfyPage.nextFrame() + } + + test('DoubleClickTime controls whether two clicks open the title editor', async ({ + comfyPage + }) => { + await comfyPage.settings.setSetting( + 'Comfy.Node.DoubleClickTitleToEdit', + true + ) + const clipNodes = + await comfyPage.nodeOps.getNodeRefsByType('CLIPTextEncode') + expect( + clipNodes, + 'Default workflow must have CLIPTextEncode nodes' + ).toHaveLength(CLIP_NODE_COUNT) + const titlePos = await clipNodes[0].getTitlePosition() + const CLICK_GAP_MS = 200 + + await test.step(`Gap (${CLICK_GAP_MS}ms) exceeds DoubleClickTime → editor stays hidden`, async () => { + await comfyPage.settings.setSetting( + 'Comfy.Pointer.DoubleClickTime', + 100 + ) + await comfyPage.canvasOps.mouseClickAt(titlePos) + await sleep(CLICK_GAP_MS) + await comfyPage.canvasOps.mouseClickAt(titlePos) + await comfyPage.titleEditor.expectHidden() + }) + + await test.step(`Gap (${CLICK_GAP_MS}ms) within DoubleClickTime → editor opens`, async () => { + await comfyPage.settings.setSetting( + 'Comfy.Pointer.DoubleClickTime', + 1000 + ) + await comfyPage.canvasOps.mouseClickAt(titlePos) + await sleep(CLICK_GAP_MS) + await comfyPage.canvasOps.mouseClickAt(titlePos) + await comfyPage.titleEditor.expectVisible() + }) + }) + + test('ClickBufferTime governs the click-vs-drag time threshold', async ({ + comfyPage + }) => { + // Keep drift generous so only elapsed time distinguishes click vs drag. + await comfyPage.settings.setSetting('Comfy.Pointer.ClickDrift', 20) + const node = ( + await comfyPage.nodeOps.getNodeRefsByType('CLIPTextEncode') + )[0] + const titlePos = await node.getTitlePosition() + const NUDGE = 2 + const HOLD_MS = 250 + + await test.step(`Buffer=2000ms (hold=${HOLD_MS}ms within buffer) → click, node stays put`, async () => { + await comfyPage.settings.setSetting( + 'Comfy.Pointer.ClickBufferTime', + 2000 + ) + const before = await node.getPosition() + await holdDragAt(comfyPage, titlePos, { + dx: NUDGE, + dy: NUDGE, + holdMs: HOLD_MS + }) + const after = await node.getPosition() + expect(after.x).toBeCloseTo(before.x, 0) + expect(after.y).toBeCloseTo(before.y, 0) + }) + + await test.step(`Buffer=50ms (hold=${HOLD_MS}ms exceeds buffer) → drag, node moves`, async () => { + await comfyPage.settings.setSetting('Comfy.Pointer.ClickBufferTime', 50) + const before = await node.getPosition() + await holdDragAt(comfyPage, titlePos, { + dx: NUDGE, + dy: NUDGE, + holdMs: HOLD_MS + }) + const after = await node.getPosition() + expect( + Math.abs(after.x - before.x) + Math.abs(after.y - before.y) + ).toBeGreaterThan(0) + }) + }) + + test('ClickDrift governs the click-vs-drag distance threshold', async ({ + comfyPage + }) => { + // Keep buffer generous so only drift distance matters. + await comfyPage.settings.setSetting('Comfy.Pointer.ClickBufferTime', 2000) + const node = ( + await comfyPage.nodeOps.getNodeRefsByType('CLIPTextEncode') + )[0] + const titlePos = await node.getTitlePosition() + const NUDGE = 8 + + await test.step(`Drift=20px (nudge=${NUDGE}px within tolerance) → click, node stays put`, async () => { + await comfyPage.settings.setSetting('Comfy.Pointer.ClickDrift', 20) + const before = await node.getPosition() + await holdDragAt(comfyPage, titlePos, { + dx: NUDGE, + dy: NUDGE, + holdMs: 0 + }) + const after = await node.getPosition() + expect(after.x).toBeCloseTo(before.x, 0) + expect(after.y).toBeCloseTo(before.y, 0) + }) + + await test.step(`Drift=1px (nudge=${NUDGE}px exceeds tolerance) → drag, node moves`, async () => { + await comfyPage.settings.setSetting('Comfy.Pointer.ClickDrift', 1) + const before = await node.getPosition() + await holdDragAt(comfyPage, titlePos, { + dx: NUDGE, + dy: NUDGE, + holdMs: 0 + }) + const after = await node.getPosition() + expect( + Math.abs(after.x - before.x) + Math.abs(after.y - before.y) + ).toBeGreaterThan(0) + }) + }) + }) + + test.describe('LiteGraph.Canvas.MaximumFps', () => { + // Behavioural FPS counting via rAF is not reliable under Playwright + // (CI jitter, background throttling, canvas-idle behaviour). Assert the + // render-loop throttle value instead — that is what actually governs + // frame cadence. + const getFrameGap = (comfyPage: ComfyPage) => + comfyPage.page.evaluate(() => window.app!.canvas.maximumFps * 1000) + + test('caps the render loop frame gap', async ({ comfyPage }) => { + await comfyPage.settings.setSetting('LiteGraph.Canvas.MaximumFps', 30) + await expect.poll(() => getFrameGap(comfyPage)).toBeCloseTo(1000 / 30, 1) + + await comfyPage.settings.setSetting('LiteGraph.Canvas.MaximumFps', 60) + await expect.poll(() => getFrameGap(comfyPage)).toBeCloseTo(1000 / 60, 1) + + await comfyPage.settings.setSetting('LiteGraph.Canvas.MaximumFps', 0) + await expect.poll(() => getFrameGap(comfyPage)).toBe(0) + }) + }) +}) diff --git a/browser_tests/tests/canvasSettings.spec.ts-snapshots/canvas-info-hud-off-chromium-linux.png b/browser_tests/tests/canvasSettings.spec.ts-snapshots/canvas-info-hud-off-chromium-linux.png new file mode 100644 index 0000000000..8743bd5bbf Binary files /dev/null and b/browser_tests/tests/canvasSettings.spec.ts-snapshots/canvas-info-hud-off-chromium-linux.png differ diff --git a/browser_tests/tests/canvasSettings.spec.ts-snapshots/canvas-info-hud-on-chromium-linux.png b/browser_tests/tests/canvasSettings.spec.ts-snapshots/canvas-info-hud-on-chromium-linux.png new file mode 100644 index 0000000000..4950a4dd87 Binary files /dev/null and b/browser_tests/tests/canvasSettings.spec.ts-snapshots/canvas-info-hud-on-chromium-linux.png differ diff --git a/browser_tests/tests/cloud-asset-default.spec.ts b/browser_tests/tests/cloud-asset-default.spec.ts index 3b557b2fba..b345bf80e1 100644 --- a/browser_tests/tests/cloud-asset-default.spec.ts +++ b/browser_tests/tests/cloud-asset-default.spec.ts @@ -1,4 +1,5 @@ import { expect } from '@playwright/test' +import type { Route } from '@playwright/test' import type { Asset, ListAssetsResponse } from '@comfyorg/ingest-types' import { comfyPageFixture } from '@e2e/fixtures/ComfyPage' @@ -12,25 +13,35 @@ function makeAssetsResponse(assets: Asset[]): ListAssetsResponse { } const CLOUD_ASSETS: Asset[] = [STABLE_CHECKPOINT, STABLE_CHECKPOINT_2] +const WAITING_FOR_WIDGET_TYPE = 'waiting:type' +const WAITING_FOR_WIDGET_VALUE = 'waiting:value' // Stub /api/assets before the app loads. The local ComfyUI backend has no // /api/assets endpoint (returns 503), which poisons the assets store on // first load. Narrow pattern avoids intercepting static /assets/*.js bundles. // // TODO: Consider moving this stub into ComfyPage fixture for all @cloud tests. -const test = comfyPageFixture.extend<{ stubCloudAssets: void }>({ +const test = comfyPageFixture.extend<{ + cloudAssetRequests: string[] + stubCloudAssets: void +}>({ + cloudAssetRequests: async ({ page: _page }, use) => { + await use([]) + }, stubCloudAssets: [ - async ({ page }, use) => { - const pattern = '**/api/assets?*' - await page.route(pattern, (route) => - route.fulfill({ + async ({ cloudAssetRequests, page }, use) => { + const pattern = /\/api\/assets(?:\?.*)?$/ + const assetsRouteHandler = (route: Route) => { + cloudAssetRequests.push(route.request().url()) + return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(makeAssetsResponse(CLOUD_ASSETS)) }) - ) + } + await page.route(pattern, assetsRouteHandler) await use() - await page.unroute(pattern) + await page.unroute(pattern, assetsRouteHandler) }, { auto: true } ] @@ -42,23 +53,36 @@ test.describe('Asset-supported node default value', { tag: '@cloud' }, () => { }) test('should use first cloud asset when server default is not in assets', async ({ + cloudAssetRequests, comfyPage }) => { - // The default workflow contains a CheckpointLoaderSimple node whose - // server default (from object_info) is a local file not in cloud assets. - // Wait for the existing node's asset widget to mount, confirming the - // assets store has been populated from the stub before adding a new node. + // Wait for the checkpoint asset query to complete and the existing widget + // to upgrade into asset mode before creating a fresh node. The current + // default node may keep a previously resolved value; what matters is that + // new nodes resolve against the cloud asset list after the fetch. + await expect + .poll(() => + cloudAssetRequests.some((url) => { + const includeTags = + new URL(url).searchParams.get('include_tags') ?? '' + return includeTags.split(',').includes('checkpoints') + }) + ) + .toBe(true) + await expect .poll( () => - comfyPage.page.evaluate(() => { + comfyPage.page.evaluate((waitingForWidgetType) => { const node = window.app!.graph.nodes.find( (n: { type: string }) => n.type === 'CheckpointLoaderSimple' ) - return node?.widgets?.find( - (w: { name: string }) => w.name === 'ckpt_name' - )?.type - }), + return ( + node?.widgets?.find( + (w: { name: string }) => w.name === 'ckpt_name' + )?.type ?? waitingForWidgetType + ) + }, WAITING_FOR_WIDGET_TYPE), { timeout: 10_000 } ) .toBe('asset') @@ -81,15 +105,22 @@ test.describe('Asset-supported node default value', { tag: '@cloud' }, () => { await expect .poll( () => - comfyPage.page.evaluate((id) => { - const node = window.app!.graph.getNodeById(id) - const widget = node?.widgets?.find( - (w: { name: string }) => w.name === 'ckpt_name' - ) - if (widget?.type !== 'asset') return 'waiting:type' - const val = String(widget?.value ?? '') - return val === 'Select model' ? 'waiting:value' : val - }, nodeId), + comfyPage.page.evaluate( + ({ id, waitingForWidgetType, waitingForWidgetValue }) => { + const node = window.app!.graph.getNodeById(id) + const widget = node?.widgets?.find( + (w: { name: string }) => w.name === 'ckpt_name' + ) + if (widget?.type !== 'asset') return waitingForWidgetType + const val = String(widget?.value ?? '') + return val === 'Select model' ? waitingForWidgetValue : val + }, + { + id: nodeId, + waitingForWidgetType: WAITING_FOR_WIDGET_TYPE, + waitingForWidgetValue: WAITING_FOR_WIDGET_VALUE + } + ), { timeout: 15_000 } ) .toBe(CLOUD_ASSETS[0].name) diff --git a/browser_tests/tests/dialogs/publishDialog.spec.ts b/browser_tests/tests/dialogs/publishDialog.spec.ts new file mode 100644 index 0000000000..00d7db0bbe --- /dev/null +++ b/browser_tests/tests/dialogs/publishDialog.spec.ts @@ -0,0 +1,306 @@ +import { expect } from '@playwright/test' + +import type { ComfyPage } from '@e2e/fixtures/ComfyPage' +import type { PublishDialog } from '@e2e/fixtures/components/PublishDialog' + +import { publishFixture as test } from '@e2e/fixtures/helpers/PublishApiHelper' + +const PUBLISH_FEATURE_FLAGS = { + comfyhub_upload_enabled: true, + comfyhub_profile_gate_enabled: true +} as const + +async function saveAndOpenPublishDialog( + comfyPage: ComfyPage, + dialog: PublishDialog, + workflowName: string +): Promise<void> { + await comfyPage.menu.topbar.saveWorkflow(workflowName) + const overwriteDialog = comfyPage.page.locator( + '.p-dialog:has-text("Overwrite")' + ) + // Bounded wait: point-in-time isVisible() can miss dialogs that open + // slightly after saveWorkflow() resolves. + try { + await overwriteDialog.waitFor({ state: 'visible', timeout: 500 }) + await comfyPage.confirmDialog.click('overwrite') + } catch { + // No overwrite dialog — workflow name was unique. + } + + await dialog.open() +} + +test.describe('Publish dialog - wizard navigation', () => { + test.beforeEach(async ({ comfyPage, publishApi, publishDialog }) => { + await comfyPage.featureFlags.setFlags(PUBLISH_FEATURE_FLAGS) + await publishApi.setupDefaultMocks() + await saveAndOpenPublishDialog(comfyPage, publishDialog, 'test-publish-wf') + }) + + test('opens on the Describe step by default', async ({ publishDialog }) => { + await expect(publishDialog.describeStep).toBeVisible() + await expect(publishDialog.nameInput).toBeVisible() + await expect(publishDialog.descriptionTextarea).toBeVisible() + }) + + test('pre-fills workflow name from active workflow', async ({ + publishDialog + }) => { + await expect(publishDialog.nameInput).toHaveValue(/test-publish-wf/) + }) + + test('Next button navigates to Examples step', async ({ publishDialog }) => { + await publishDialog.goNext() + await expect(publishDialog.describeStep).toBeHidden() + // Examples step should show thumbnail toggle and upload area + await expect( + publishDialog.root.getByText('Select a thumbnail') + ).toBeVisible() + }) + + test('Back button returns to Describe step from Examples', async ({ + publishDialog + }) => { + await publishDialog.goNext() + await expect(publishDialog.describeStep).toBeHidden() + + await publishDialog.goBack() + await expect(publishDialog.describeStep).toBeVisible() + }) + + test('navigates through all steps to Finish', async ({ publishDialog }) => { + await publishDialog.goNext() // → Examples + await publishDialog.goNext() // → Finish + await expect(publishDialog.finishStep).toBeVisible() + await expect(publishDialog.publishButton).toBeVisible() + }) + + test('clicking nav step navigates directly', async ({ publishDialog }) => { + await publishDialog.goToStep('Finish publishing') + await expect(publishDialog.finishStep).toBeVisible() + + await publishDialog.goToStep('Describe your workflow') + await expect(publishDialog.describeStep).toBeVisible() + }) + + test('closes dialog via Escape key', async ({ comfyPage, publishDialog }) => { + await comfyPage.page.keyboard.press('Escape') + await expect(publishDialog.root).toBeHidden() + }) +}) + +test.describe('Publish dialog - Describe step', () => { + test.beforeEach(async ({ comfyPage, publishApi, publishDialog }) => { + await comfyPage.featureFlags.setFlags(PUBLISH_FEATURE_FLAGS) + await publishApi.setupDefaultMocks() + await saveAndOpenPublishDialog(comfyPage, publishDialog, 'test-describe-wf') + }) + + test('allows editing the workflow name', async ({ publishDialog }) => { + await publishDialog.nameInput.clear() + await publishDialog.nameInput.fill('My Custom Workflow') + await expect(publishDialog.nameInput).toHaveValue('My Custom Workflow') + }) + + test('allows editing the description', async ({ publishDialog }) => { + await publishDialog.descriptionTextarea.fill( + 'A great workflow for anime art' + ) + await expect(publishDialog.descriptionTextarea).toHaveValue( + 'A great workflow for anime art' + ) + }) + + test('displays tag suggestions from mocked API', async ({ + publishDialog + }) => { + await expect(publishDialog.root.getByText('anime')).toBeVisible() + await expect(publishDialog.root.getByText('upscale')).toBeVisible() + }) + + // TODO(#11548): Tag click emits update:tags but the tag does not appear in + // the active list during E2E. Needs investigation of the parent state + // binding. + test.fixme('clicking a tag suggestion adds it', async ({ publishDialog }) => { + await publishDialog.root.getByText('anime').click() + + await expect(publishDialog.tagsInput.getByText('anime')).toBeVisible() + }) +}) + +test.describe('Publish dialog - Examples step', () => { + test.beforeEach(async ({ comfyPage, publishApi, publishDialog }) => { + await comfyPage.featureFlags.setFlags(PUBLISH_FEATURE_FLAGS) + await publishApi.setupDefaultMocks() + await saveAndOpenPublishDialog(comfyPage, publishDialog, 'test-examples-wf') + await publishDialog.goNext() // Navigate to Examples step + }) + + test('shows thumbnail type toggle options', async ({ publishDialog }) => { + await expect( + publishDialog.root.getByText('Image', { exact: true }) + ).toBeVisible() + await expect( + publishDialog.root.getByText('Video', { exact: true }) + ).toBeVisible() + await expect( + publishDialog.root.getByText('Image comparison', { exact: true }) + ).toBeVisible() + }) + + test('shows example image upload tile', async ({ publishDialog }) => { + await expect( + publishDialog.root.getByRole('button', { name: 'Upload example image' }) + ).toBeVisible() + }) +}) + +test.describe('Publish dialog - Finish step with profile', () => { + test.beforeEach(async ({ comfyPage, publishApi, publishDialog }) => { + await comfyPage.featureFlags.setFlags(PUBLISH_FEATURE_FLAGS) + await publishApi.setupDefaultMocks({ hasProfile: true }) + await saveAndOpenPublishDialog(comfyPage, publishDialog, 'test-finish-wf') + await publishDialog.goToStep('Finish publishing') + }) + + test('shows profile card with username', async ({ publishDialog }) => { + await expect(publishDialog.finishStep).toBeVisible() + await expect(publishDialog.root.getByText('@testuser')).toBeVisible() + await expect(publishDialog.root.getByText('Test User')).toBeVisible() + }) + + test('publish button is enabled when no private assets', async ({ + publishDialog + }) => { + await expect(publishDialog.publishButton).toBeEnabled() + }) +}) + +test.describe('Publish dialog - Finish step with private assets', () => { + test.beforeEach(async ({ comfyPage, publishApi, publishDialog }) => { + await comfyPage.featureFlags.setFlags(PUBLISH_FEATURE_FLAGS) + await publishApi.setupDefaultMocks({ + hasProfile: true, + hasPrivateAssets: true + }) + await saveAndOpenPublishDialog(comfyPage, publishDialog, 'test-assets-wf') + await publishDialog.goToStep('Finish publishing') + }) + + test('publish button is disabled until assets acknowledged', async ({ + publishDialog + }) => { + await expect(publishDialog.finishStep).toBeVisible() + await expect(publishDialog.publishButton).toBeDisabled() + + const checkbox = publishDialog.finishStep.getByRole('checkbox') + await checkbox.check() + + await expect(publishDialog.publishButton).toBeEnabled() + }) +}) + +test.describe('Publish dialog - no profile', () => { + test.beforeEach(async ({ comfyPage, publishApi, publishDialog }) => { + await comfyPage.featureFlags.setFlags(PUBLISH_FEATURE_FLAGS) + await publishApi.setupDefaultMocks({ hasProfile: false }) + await saveAndOpenPublishDialog( + comfyPage, + publishDialog, + 'test-noprofile-wf' + ) + await publishDialog.goToStep('Finish publishing') + }) + + test('shows profile creation prompt when user has no profile', async ({ + publishDialog + }) => { + await expect(publishDialog.profilePrompt).toBeVisible() + await expect( + publishDialog.root.getByText('Create a profile to publish to ComfyHub') + ).toBeVisible() + }) + + test('clicking create profile CTA shows profile creation form', async ({ + publishDialog + }) => { + await publishDialog.root + .getByRole('button', { name: 'Create a profile' }) + .click() + await expect(publishDialog.gateFlow).toBeVisible() + }) +}) + +test.describe('Publish dialog - unsaved workflow', () => { + test.beforeEach(async ({ comfyPage, publishApi }) => { + await comfyPage.featureFlags.setFlags(PUBLISH_FEATURE_FLAGS) + await publishApi.setupDefaultMocks() + // Don't save workflow — open dialog on the default temporary workflow + }) + + test('shows save prompt for temporary workflow', async ({ + comfyPage, + publishDialog + }) => { + // Create a new workflow to ensure it's temporary + await comfyPage.menu.topbar.triggerTopbarCommand(['New']) + await publishDialog.open() + + await expect(publishDialog.savePrompt).toBeVisible() + await expect( + publishDialog.root.getByText( + 'You must save your workflow before publishing' + ) + ).toBeVisible() + // Nav should be hidden when save is required + await expect(publishDialog.nav).toBeHidden() + }) +}) + +test.describe('Publish dialog - submission', () => { + test.beforeEach(async ({ comfyPage }) => { + await comfyPage.featureFlags.setFlags(PUBLISH_FEATURE_FLAGS) + }) + + test('successful publish closes dialog', async ({ + comfyPage, + publishApi, + publishDialog + }) => { + await publishApi.setupDefaultMocks({ hasProfile: true }) + await saveAndOpenPublishDialog(comfyPage, publishDialog, 'test-submit-wf') + await publishDialog.goToStep('Finish publishing') + await expect(publishDialog.finishStep).toBeVisible() + + await publishDialog.publishButton.click() + await expect(publishDialog.root).toBeHidden({ timeout: 10_000 }) + }) + + test('failed publish shows error toast', async ({ + comfyPage, + publishApi, + publishDialog + }) => { + await publishApi.setupDefaultMocks({ hasProfile: true }) + // Override publish mock with error response + await publishApi.mockPublishWorkflowError(500, 'Internal error') + + await saveAndOpenPublishDialog( + comfyPage, + publishDialog, + 'test-submit-fail-wf' + ) + await publishDialog.goToStep('Finish publishing') + await expect(publishDialog.finishStep).toBeVisible() + + await publishDialog.publishButton.click() + + // Error toast should appear + await expect(comfyPage.toast.visibleToasts.first()).toBeVisible({ + timeout: 10_000 + }) + // Dialog should remain open + await expect(publishDialog.root).toBeVisible() + }) +}) diff --git a/browser_tests/tests/errorDialog.spec.ts b/browser_tests/tests/errorDialog.spec.ts index 218cdd7a74..41defbd063 100644 --- a/browser_tests/tests/errorDialog.spec.ts +++ b/browser_tests/tests/errorDialog.spec.ts @@ -8,7 +8,7 @@ import { TestIds } from '@e2e/fixtures/selectors' import { interceptClipboardWrite, getClipboardText -} from '@e2e/helpers/clipboardSpy' +} from '@e2e/fixtures/utils/clipboardSpy' async function triggerConfigureError( comfyPage: ComfyPage, diff --git a/browser_tests/tests/errorOverlay.spec.ts b/browser_tests/tests/errorOverlay.spec.ts index 6ebfee36ab..1962021aa9 100644 --- a/browser_tests/tests/errorOverlay.spec.ts +++ b/browser_tests/tests/errorOverlay.spec.ts @@ -5,7 +5,7 @@ import { comfyExpect as expect } from '@e2e/fixtures/ComfyPage' import { TestIds } from '@e2e/fixtures/selectors' -import { cleanupFakeModel } from '@e2e/tests/propertiesPanel/ErrorsTabHelper' +import { cleanupFakeModel } from '@e2e/fixtures/helpers/ErrorsTabHelper' test.describe('Error overlay', { tag: '@ui' }, () => { test.beforeEach(async ({ comfyPage }) => { diff --git a/browser_tests/tests/groupCopyPaste.spec.ts b/browser_tests/tests/groupCopyPaste.spec.ts index 8263b4aa4b..de6e514663 100644 --- a/browser_tests/tests/groupCopyPaste.spec.ts +++ b/browser_tests/tests/groupCopyPaste.spec.ts @@ -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() diff --git a/browser_tests/tests/groupSelectChildren.spec.ts b/browser_tests/tests/groupSelectChildren.spec.ts index 931bbf8d94..263e488f47 100644 --- a/browser_tests/tests/groupSelectChildren.spec.ts +++ b/browser_tests/tests/groupSelectChildren.spec.ts @@ -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} diff --git a/browser_tests/tests/keyboardShortcutActions.spec.ts b/browser_tests/tests/keyboardShortcutActions.spec.ts index 1351e5a1a7..e271bd9bb3 100644 --- a/browser_tests/tests/keyboardShortcutActions.spec.ts +++ b/browser_tests/tests/keyboardShortcutActions.spec.ts @@ -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 }) => { diff --git a/browser_tests/tests/linkNodeInteractionSettings.spec.ts b/browser_tests/tests/linkNodeInteractionSettings.spec.ts new file mode 100644 index 0000000000..c3d0af7828 --- /dev/null +++ b/browser_tests/tests/linkNodeInteractionSettings.spec.ts @@ -0,0 +1,208 @@ +import type { ComfyPage } from '@e2e/fixtures/ComfyPage' +import { + comfyExpect as expect, + comfyPageFixture as test +} from '@e2e/fixtures/ComfyPage' +import { DefaultGraphPositions } from '@e2e/fixtures/constants/defaultGraphPositions' + +const VAE_DECODE_SAMPLES_INPUT_SLOT = 0 +const DEFAULT_GROUP_TITLE = 'Group' + +test.describe('Link & node interaction settings', { tag: '@canvas' }, () => { + test.describe('Comfy.LinkRelease.Action', () => { + test('"search box" opens node search on link release', async ({ + comfyPage + }) => { + await comfyPage.settings.setSetting( + 'Comfy.LinkRelease.Action', + 'search box' + ) + await comfyPage.canvasOps.disconnectEdge() + await expect(comfyPage.searchBoxV2.input).toBeVisible() + }) + + test('"context menu" opens litegraph connection menu on link release', async ({ + comfyPage + }) => { + await comfyPage.settings.setSetting( + 'Comfy.LinkRelease.Action', + 'context menu' + ) + await comfyPage.canvasOps.disconnectEdge() + await expect(comfyPage.contextMenu.litegraphContextMenu).toBeVisible() + }) + + test('"no action" suppresses both search box and context menu', async ({ + comfyPage + }) => { + await comfyPage.settings.setSetting( + 'Comfy.LinkRelease.Action', + 'no action' + ) + await comfyPage.canvasOps.disconnectEdge() + await expect(comfyPage.searchBoxV2.input).toBeHidden() + await expect(comfyPage.contextMenu.litegraphContextMenu).toBeHidden() + }) + }) + + test.describe('Comfy.LinkRelease.ActionShift', () => { + test('shift+drag dispatches to ActionShift (not Action)', async ({ + comfyPage + }) => { + await comfyPage.settings.setSetting( + 'Comfy.LinkRelease.Action', + 'no action' + ) + await comfyPage.settings.setSetting( + 'Comfy.LinkRelease.ActionShift', + 'search box' + ) + + await comfyPage.canvasOps.disconnectEdge({ modifiers: ['Shift'] }) + + await expect(comfyPage.searchBoxV2.input).toBeVisible() + }) + }) + + test.describe('Comfy.Node.DoubleClickTitleToEdit', () => { + test('enabled → double-click on node title opens editor', async ({ + comfyPage + }) => { + await comfyPage.settings.setSetting( + 'Comfy.Node.DoubleClickTitleToEdit', + true + ) + const [node] = await comfyPage.nodeOps.getNodeRefsByType('CLIPTextEncode') + await comfyPage.canvasOps.mouseDblclickAt(await node.getTitlePosition()) + await comfyPage.titleEditor.expectVisible() + }) + + test('disabled → double-click on node title stays hidden', async ({ + comfyPage + }) => { + await comfyPage.settings.setSetting( + 'Comfy.Node.DoubleClickTitleToEdit', + false + ) + const [node] = await comfyPage.nodeOps.getNodeRefsByType('CLIPTextEncode') + await comfyPage.canvasOps.mouseDblclickAt(await node.getTitlePosition()) + await comfyPage.titleEditor.expectHidden() + }) + }) + + test.describe('Comfy.Group.DoubleClickTitleToEdit', () => { + test.beforeEach(async ({ comfyPage }) => { + await comfyPage.workflow.loadWorkflow('groups/single_group_only') + }) + + test('enabled → double-click on group title opens editor', async ({ + comfyPage + }) => { + await comfyPage.settings.setSetting( + 'Comfy.Group.DoubleClickTitleToEdit', + true + ) + await comfyPage.canvasOps.dblclickGroupTitle(DEFAULT_GROUP_TITLE) + await comfyPage.titleEditor.expectVisible() + }) + + test('disabled → double-click on group title stays hidden', async ({ + comfyPage + }) => { + await comfyPage.settings.setSetting( + 'Comfy.Group.DoubleClickTitleToEdit', + false + ) + await comfyPage.canvasOps.dblclickGroupTitle(DEFAULT_GROUP_TITLE) + await comfyPage.titleEditor.expectHidden() + }) + }) + + test.describe('Comfy.Node.BypassAllLinksOnDelete', () => { + test('enabled → deleting KSampler bridges EmptyLatentImage → VAEDecode.samples', async ({ + comfyPage + }) => { + await comfyPage.settings.setSetting( + 'Comfy.Node.BypassAllLinksOnDelete', + true + ) + const [kSampler] = await comfyPage.nodeOps.getNodeRefsByType('KSampler') + const [emptyLatent] = + await comfyPage.nodeOps.getNodeRefsByType('EmptyLatentImage') + const [vaeDecode] = await comfyPage.nodeOps.getNodeRefsByType('VAEDecode') + const vaeSamplesInput = await vaeDecode.getInput( + VAE_DECODE_SAMPLES_INPUT_SLOT + ) + + await test.step('precondition: KSampler feeds VAEDecode.samples', async () => { + expect( + (await vaeSamplesInput.getLink())?.origin_id, + 'VAEDecode.samples should originate from KSampler before delete' + ).toBe(kSampler.id) + }) + + await kSampler.delete() + + await expect + .poll(async () => (await vaeSamplesInput.getLink())?.origin_id ?? null) + .toBe(emptyLatent.id) + }) + + test('disabled → deleting KSampler drops VAEDecode.samples', async ({ + comfyPage + }) => { + await comfyPage.settings.setSetting( + 'Comfy.Node.BypassAllLinksOnDelete', + false + ) + const [kSampler] = await comfyPage.nodeOps.getNodeRefsByType('KSampler') + const [vaeDecode] = await comfyPage.nodeOps.getNodeRefsByType('VAEDecode') + const vaeSamplesInput = await vaeDecode.getInput( + VAE_DECODE_SAMPLES_INPUT_SLOT + ) + + await kSampler.delete() + + await expect.poll(() => vaeSamplesInput.getLink()).toBeNull() + }) + }) + + test.describe('Comfy.Node.MiddleClickRerouteNode', () => { + async function countReroutes(comfyPage: ComfyPage): Promise<number> { + return (await comfyPage.nodeOps.getNodeRefsByType('Reroute')).length + } + + test('enabled → middle-click on an output slot creates a Reroute', async ({ + comfyPage + }) => { + await comfyPage.settings.setSetting( + 'Comfy.Node.MiddleClickRerouteNode', + true + ) + const before = await countReroutes(comfyPage) + + await comfyPage.canvasOps.middleClick( + DefaultGraphPositions.loadCheckpointNodeClipOutputSlot + ) + + await expect.poll(() => countReroutes(comfyPage)).toBe(before + 1) + }) + + test('disabled → middle-click on an output slot does nothing', async ({ + comfyPage + }) => { + await comfyPage.settings.setSetting( + 'Comfy.Node.MiddleClickRerouteNode', + false + ) + const before = await countReroutes(comfyPage) + + await comfyPage.canvasOps.middleClick( + DefaultGraphPositions.loadCheckpointNodeClipOutputSlot + ) + await comfyPage.nextFrame() + + expect(await countReroutes(comfyPage)).toBe(before) + }) + }) +}) diff --git a/browser_tests/tests/load3d/load3d.spec.ts b/browser_tests/tests/load3d/load3d.spec.ts index 7197845049..f3ec05cbc1 100644 --- a/browser_tests/tests/load3d/load3d.spec.ts +++ b/browser_tests/tests/load3d/load3d.spec.ts @@ -282,6 +282,57 @@ test.describe('Load3D', () => { }) }) +test.describe('Load3D silent 404 on missing output model', () => { + test('Does not show an error toast when the output model file is missing (404)', async ({ + comfyPage + }) => { + // Intercept model fetch and return 404 to simulate a missing output file + // (e.g. shared workflow opened on a machine that never ran it) + await comfyPage.page.route('**/view?**', (route) => + route.fulfill({ status: 404, body: 'Not Found' }) + ) + + // This workflow has a Preview3D node with Last Time Model File set, + // triggering the loadFolder: 'output' + silentOnNotFound: true path. + await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true) + + // Wait for the 404 response before asserting — gives the load attempt time + // to complete without using waitForTimeout + const responsePromise = comfyPage.page.waitForResponse('**/view?**') + await comfyPage.workflow.loadWorkflow('3d/load3d_missing_model') + await responsePromise + + await expect( + comfyPage.toast.visibleToasts.filter({ hasText: 'Error loading model' }) + ).toHaveCount(0) + }) + + test('Shows an error toast when a non-404 error occurs loading the output model', async ({ + comfyPage + }) => { + // Intercept with a 500 to simulate a real server error (not 404) — toast must appear + await comfyPage.page.route('**/view?**', (route) => + route.fulfill({ status: 500, body: 'Internal Server Error' }) + ) + + await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true) + + const responsePromise = comfyPage.page.waitForResponse('**/view?**') + await comfyPage.workflow.loadWorkflow('3d/load3d_missing_model') + await responsePromise + + await expect + .poll( + () => + comfyPage.toast.visibleToasts + .filter({ hasText: 'Error loading model' }) + .count(), + { timeout: 10000 } + ) + .toBeGreaterThan(0) + }) +}) + test.describe('Load3D initialization failure', () => { test('Surfaces a toast when the THREE.WebGLRenderer cannot be created', async ({ comfyPage diff --git a/browser_tests/tests/loginButton.spec.ts b/browser_tests/tests/loginButton.spec.ts new file mode 100644 index 0000000000..3ee00950c4 --- /dev/null +++ b/browser_tests/tests/loginButton.spec.ts @@ -0,0 +1,106 @@ +import type { Page } from '@playwright/test' +import { expect } from '@playwright/test' + +import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage' +import { SignInDialog } from '@e2e/fixtures/components/SignInDialog' +import { TestIds } from '@e2e/fixtures/selectors' + +/** + * Enable the show_signin_button server feature flag so LoginButton renders + * in WorkflowTabs (which uses `flags.showSignInButton ?? isDesktop`). + * The flag is reset automatically on each fresh page load in beforeEach. + */ +async function enableLoginButtonFlag(page: Page): Promise<void> { + await page.evaluate(() => { + window.app!.api.serverFeatureFlags.value = { + ...window.app!.api.serverFeatureFlags.value, + show_signin_button: true + } + }) +} + +test.describe('Login Button', { tag: ['@ui'] }, () => { + test.beforeEach(async ({ comfyPage }) => { + await comfyPage.setup() + }) + + test.describe('Visibility', () => { + test('button is hidden when show_signin_button flag is off', async ({ + comfyPage + }) => { + await comfyPage.page.evaluate(() => { + window.app!.api.serverFeatureFlags.value = { + ...window.app!.api.serverFeatureFlags.value, + show_signin_button: false + } + }) + await expect( + comfyPage.page.getByTestId(TestIds.topbar.loginButton) + ).toBeHidden() + }) + + test('button is visible when show_signin_button flag is enabled', async ({ + comfyPage + }) => { + await enableLoginButtonFlag(comfyPage.page) + await expect( + comfyPage.page.getByTestId(TestIds.topbar.loginButton) + ).toBeVisible() + }) + }) + + test.describe('ARIA', () => { + test('button has correct aria-label', async ({ comfyPage }) => { + await enableLoginButtonFlag(comfyPage.page) + const button = comfyPage.page.getByTestId(TestIds.topbar.loginButton) + await expect(button).toHaveAttribute('aria-label', /.+/) + }) + }) + + test.describe('Click behaviour', () => { + test('clicking the button opens the sign-in dialog', async ({ + comfyPage + }) => { + await enableLoginButtonFlag(comfyPage.page) + const dialog = new SignInDialog(comfyPage.page) + await comfyPage.page.getByTestId(TestIds.topbar.loginButton).click() + await expect(dialog.root).toBeVisible() + }) + }) + + test.describe('Hover popover', () => { + test('hovering shows an informational popover', async ({ comfyPage }) => { + await enableLoginButtonFlag(comfyPage.page) + await comfyPage.page.getByTestId(TestIds.topbar.loginButton).hover() + await expect( + comfyPage.page.getByTestId(TestIds.topbar.loginButtonPopover) + ).toBeVisible() + }) + + test('popover contains a Learn more link', async ({ comfyPage }) => { + await enableLoginButtonFlag(comfyPage.page) + await comfyPage.page.getByTestId(TestIds.topbar.loginButton).hover() + const learnMoreLink = comfyPage.page.getByTestId( + TestIds.topbar.loginButtonPopoverLearnMore + ) + await expect(learnMoreLink).toBeVisible() + await expect(learnMoreLink).toHaveAttribute('href', /api-nodes/) + }) + + test('popover hides after mouse leaves the button area', async ({ + comfyPage + }) => { + await enableLoginButtonFlag(comfyPage.page) + const button = comfyPage.page.getByTestId(TestIds.topbar.loginButton) + await button.hover() + await expect( + comfyPage.page.getByTestId(TestIds.topbar.loginButtonPopover) + ).toBeVisible() + + await comfyPage.canvas.hover() + await expect( + comfyPage.page.getByTestId(TestIds.topbar.loginButtonPopover) + ).toBeHidden() + }) + }) +}) diff --git a/browser_tests/tests/nodeGhostPlacement.spec.ts b/browser_tests/tests/nodeGhostPlacement.spec.ts index a528dbd9a1..63336edb93 100644 --- a/browser_tests/tests/nodeGhostPlacement.spec.ts +++ b/browser_tests/tests/nodeGhostPlacement.spec.ts @@ -201,12 +201,10 @@ for (const mode of ['litegraph', 'vue'] as const) { 'subgraph blueprint added from search box enters ghost mode', { tag: ['@subgraph'] }, async ({ comfyPage }) => { - await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled') await comfyPage.settings.setSetting( 'Comfy.NodeSearchBoxImpl', 'default' ) - await comfyPage.searchBoxV2.reload(comfyPage) // Convert a node to a subgraph and publish it as a blueprint const nodeRef = await comfyPage.nodeOps.getNodeRefById('3') @@ -231,9 +229,8 @@ for (const mode of ['litegraph', 'vue'] as const) { const nodeCountBefore = await comfyPage.nodeOps.getGraphNodesCount() // Open v2 search box and search for the published blueprint - await comfyPage.canvasOps.doubleClick() const { searchBoxV2 } = comfyPage - await expect(searchBoxV2.input).toBeVisible() + await searchBoxV2.open() await searchBoxV2.input.fill(blueprintName) await expect(searchBoxV2.results.first()).toBeVisible() @@ -270,5 +267,65 @@ for (const mode of ['litegraph', 'vue'] as const) { expect(afterPlace!.ghost).toBe(false) } ) + + test( + 'Escape during ghost placement inside a subgraph cancels the ghost without exiting the subgraph', + { tag: ['@subgraph'] }, + async ({ comfyPage }) => { + await comfyPage.searchBoxV2.setup() + await comfyPage.settings.setSetting( + 'Comfy.NodeSearchBoxImpl.FollowCursor', + true + ) + await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph') + + if (mode === 'vue') { + await comfyPage.vueNodes.waitForNodes() + await comfyPage.vueNodes.enterSubgraph('2') + } else { + const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2') + await subgraphNode.navigateIntoSubgraph() + } + await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true) + + const subgraphId = await comfyPage.subgraph.getActiveGraphId() + const initialNodeCount = await comfyPage.subgraph.getNodeCount() + + const { searchBoxV2 } = comfyPage + await searchBoxV2.open() + await searchBoxV2.input.fill('KSampler') + await expect(searchBoxV2.results.first()).toBeVisible() + await comfyPage.page.keyboard.press('Enter') + await expect(searchBoxV2.input).toBeHidden() + + await expect + .poll(() => + comfyPage.page.evaluate( + () => window.app!.canvas.state.ghostNodeId != null + ) + ) + .toBe(true) + + await comfyPage.keyboard.press('Escape') + + await expect + .poll(() => comfyPage.subgraph.isInSubgraph(), { + message: + 'Escape during ghost placement should cancel the ghost, not exit the subgraph' + }) + .toBe(true) + await expect + .poll(() => comfyPage.subgraph.getActiveGraphId()) + .toBe(subgraphId) + await expect + .poll(() => + comfyPage.page.evaluate(() => window.app!.canvas.state.ghostNodeId) + ) + .toBeNull() + await expect + .poll(() => comfyPage.subgraph.getNodeCount()) + .toBe(initialNodeCount) + } + ) }) } diff --git a/browser_tests/tests/nodeHelp.spec.ts b/browser_tests/tests/nodeHelp.spec.ts index a70b989cca..11cd72b1fd 100644 --- a/browser_tests/tests/nodeHelp.spec.ts +++ b/browser_tests/tests/nodeHelp.spec.ts @@ -3,7 +3,7 @@ import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage' import type { ComfyPage } from '@e2e/fixtures/ComfyPage' -import { fitToViewInstant } from '@e2e/helpers/fitToView' +import { fitToViewInstant } from '@e2e/fixtures/utils/fitToView' import type { WorkspaceStore } from '@e2e/types/globals' import type { NodeReference } from '@e2e/fixtures/utils/litegraphUtils' diff --git a/browser_tests/tests/nodeSearchBoxV2.spec.ts b/browser_tests/tests/nodeSearchBoxV2.spec.ts index 627e74cdbf..7e014a859e 100644 --- a/browser_tests/tests/nodeSearchBoxV2.spec.ts +++ b/browser_tests/tests/nodeSearchBoxV2.spec.ts @@ -5,32 +5,19 @@ import { test.describe('Node search box V2', { tag: '@node' }, () => { test.beforeEach(async ({ comfyPage }) => { - await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled') - await comfyPage.settings.setSetting('Comfy.NodeSearchBoxImpl', 'default') - await comfyPage.settings.setSetting( - 'Comfy.LinkRelease.Action', - 'search box' - ) - await comfyPage.settings.setSetting( - 'Comfy.LinkRelease.ActionShift', - 'search box' - ) - await comfyPage.searchBoxV2.reload(comfyPage) + await comfyPage.searchBoxV2.setup() }) test('Can open search and add node', async ({ comfyPage }) => { const { searchBoxV2 } = comfyPage const initialCount = await comfyPage.nodeOps.getGraphNodesCount() - await comfyPage.canvasOps.doubleClick() - await expect(searchBoxV2.input).toBeVisible() - + await searchBoxV2.open() await searchBoxV2.input.fill('KSampler') await expect(searchBoxV2.results.first()).toBeVisible() await comfyPage.page.keyboard.press('Enter') await expect(searchBoxV2.input).toBeHidden() - await expect .poll(() => comfyPage.nodeOps.getGraphNodesCount()) .toBe(initialCount + 1) @@ -40,16 +27,12 @@ test.describe('Node search box V2', { tag: '@node' }, () => { const { searchBoxV2 } = comfyPage const initialCount = await comfyPage.nodeOps.getGraphNodesCount() - await comfyPage.canvasOps.doubleClick() - await expect(searchBoxV2.input).toBeVisible() - - // Default results should be visible without typing + await searchBoxV2.open() + // Default results should be visible without typing. await expect(searchBoxV2.results.first()).toBeVisible() - // Enter should add the first (selected) result await comfyPage.page.keyboard.press('Enter') await expect(searchBoxV2.input).toBeHidden() - await expect .poll(() => comfyPage.nodeOps.getGraphNodesCount()) .toBe(initialCount + 1) @@ -63,12 +46,9 @@ test.describe('Node search box V2', { tag: '@node' }, () => { await comfyPage.settings.setSetting('Comfy.NodeLibrary.Bookmarks.V2', [ 'KSampler' ]) - await searchBoxV2.reload(comfyPage) - await comfyPage.canvasOps.doubleClick() - await expect(searchBoxV2.input).toBeVisible() - - await searchBoxV2.filterBarButton('Bookmarked').click() + await searchBoxV2.open() + await searchBoxV2.rootCategoryButton('favorites').click() await expect(searchBoxV2.results).toHaveCount(1) await expect(searchBoxV2.results.first()).toContainText('KSampler') @@ -79,13 +59,10 @@ test.describe('Node search box V2', { tag: '@node' }, () => { }) => { const { searchBoxV2 } = comfyPage - await comfyPage.canvasOps.doubleClick() - await expect(searchBoxV2.input).toBeVisible() - + await searchBoxV2.open() await searchBoxV2.categoryButton('sampling').click() await expect(searchBoxV2.results.first()).toBeVisible() - await expect.poll(() => searchBoxV2.results.count()).toBeGreaterThan(0) }) }) @@ -93,26 +70,23 @@ test.describe('Node search box V2', { tag: '@node' }, () => { test('Can filter by input type via filter bar', async ({ comfyPage }) => { const { searchBoxV2 } = comfyPage - await comfyPage.canvasOps.doubleClick() - await expect(searchBoxV2.input).toBeVisible() + await searchBoxV2.open() - // Click "Input" filter chip in the filter bar - await searchBoxV2.filterBarButton('Input').click() + await test.step('Open Input filter popover', async () => { + await searchBoxV2.typeFilterButton('input').click() + await expect(searchBoxV2.filterOptions.first()).toBeVisible() + }) - // Filter options should appear - await expect(searchBoxV2.filterOptions.first()).toBeVisible() + await test.step('Select MODEL type', async () => { + await searchBoxV2.filterSearch.fill('MODEL') + await searchBoxV2.filterOptions + .filter({ hasText: 'MODEL' }) + .first() + .click() + }) - // Type to narrow and select MODEL - await searchBoxV2.filterSearch.fill('MODEL') - await searchBoxV2.filterOptions - .filter({ hasText: 'MODEL' }) - .first() - .click() - - // Filter chip should appear and results should be filtered - await expect( - searchBoxV2.dialog.getByText('Input:', { exact: false }).locator('..') - ).toContainText('MODEL') + await expect(searchBoxV2.filterChips).toHaveCount(1) + await expect(searchBoxV2.filterChips.first()).toContainText('MODEL') await expect(searchBoxV2.results.first()).toBeVisible() }) }) @@ -122,32 +96,180 @@ test.describe('Node search box V2', { tag: '@node' }, () => { const { searchBoxV2 } = comfyPage const initialCount = await comfyPage.nodeOps.getGraphNodesCount() - await comfyPage.canvasOps.doubleClick() - await expect(searchBoxV2.input).toBeVisible() - + await searchBoxV2.open() await searchBoxV2.input.fill('KSampler') const results = searchBoxV2.results await expect(results.first()).toBeVisible() - // First result selected by default - await expect(results.first()).toHaveAttribute('aria-selected', 'true') + await test.step('First result is selected by default', async () => { + await expect(results.first()).toHaveAttribute('aria-selected', 'true') + }) - // ArrowDown moves selection - await comfyPage.page.keyboard.press('ArrowDown') - await expect(results.nth(1)).toHaveAttribute('aria-selected', 'true') - await expect(results.first()).toHaveAttribute('aria-selected', 'false') + await test.step('ArrowDown moves selection to next result', async () => { + await comfyPage.page.keyboard.press('ArrowDown') + await expect(results.nth(1)).toHaveAttribute('aria-selected', 'true') + await expect(results.first()).toHaveAttribute('aria-selected', 'false') + }) - // ArrowUp moves back - await comfyPage.page.keyboard.press('ArrowUp') - await expect(results.first()).toHaveAttribute('aria-selected', 'true') + await test.step('ArrowUp moves selection back', async () => { + await comfyPage.page.keyboard.press('ArrowUp') + await expect(results.first()).toHaveAttribute('aria-selected', 'true') + }) - // Enter selects and adds node - await comfyPage.page.keyboard.press('Enter') - await expect(searchBoxV2.input).toBeHidden() + await test.step('Enter selects and adds the node', async () => { + await comfyPage.page.keyboard.press('Enter') + await expect(searchBoxV2.input).toBeHidden() + await expect + .poll(() => comfyPage.nodeOps.getGraphNodesCount()) + .toBe(initialCount + 1) + }) + }) + }) - await expect - .poll(() => comfyPage.nodeOps.getGraphNodesCount()) - .toBe(initialCount + 1) + test.describe('Category sidebar', () => { + test('Sidebar toggle hides and shows the category sidebar', async ({ + comfyPage + }) => { + const { searchBoxV2 } = comfyPage + await searchBoxV2.open() + + const samplingCategory = searchBoxV2.categoryButton('sampling') + await expect(samplingCategory).toBeVisible() + await expect(searchBoxV2.sidebarToggle).toHaveAttribute( + 'aria-expanded', + 'true' + ) + + await searchBoxV2.sidebarToggle.click() + await expect(searchBoxV2.sidebarToggle).toHaveAttribute( + 'aria-expanded', + 'false' + ) + await expect(samplingCategory).toBeHidden() + + await searchBoxV2.sidebarToggle.click() + await expect(searchBoxV2.sidebarToggle).toHaveAttribute( + 'aria-expanded', + 'true' + ) + await expect(samplingCategory).toBeVisible() + }) + + test('Filter bar scrolls horizontally while the sidebar toggle stays pinned', async ({ + comfyPage + }) => { + const { searchBoxV2 } = comfyPage + // Narrow viewport so the chips overflow the filter bar + await comfyPage.page.setViewportSize({ width: 360, height: 800 }) + await searchBoxV2.open() + + const scrollEl = searchBoxV2.filterChipsScroll + const dims = await scrollEl.evaluate((el) => ({ + scrollWidth: el.scrollWidth, + clientWidth: el.clientWidth + })) + expect(dims.scrollWidth).toBeGreaterThan(dims.clientWidth) + + await scrollEl.evaluate((el) => { + el.scrollLeft = el.scrollWidth + }) + + // The toggle lives outside the scroll container, so even when the + // chips scroll hundreds of px it must remain visible in the viewport. + await expect(searchBoxV2.sidebarToggle).toBeInViewport() + }) + + test('@mobile Sidebar is collapsed by default on mobile', async ({ + comfyPage + }) => { + const { searchBoxV2 } = comfyPage + await searchBoxV2.open() + + await expect(searchBoxV2.sidebarToggle).toHaveAttribute( + 'aria-expanded', + 'false' + ) + await expect(searchBoxV2.categoryButton('sampling')).toBeHidden() + }) + + test('@mobile Clicking outside the sidebar closes it', async ({ + comfyPage + }) => { + const { searchBoxV2 } = comfyPage + await searchBoxV2.open() + + await searchBoxV2.sidebarToggle.click() + await expect(searchBoxV2.sidebarToggle).toHaveAttribute( + 'aria-expanded', + 'true' + ) + await expect(searchBoxV2.categoryButton('sampling')).toBeVisible() + await expect(searchBoxV2.sidebarBackdrop).toBeVisible() + + // The backdrop spans the full content area, but the sidebar (z-20) + // covers its left ~208px (w-52). Click past that to land on the + // backdrop rather than the sidebar. + await searchBoxV2.sidebarBackdrop.click({ position: { x: 240, y: 40 } }) + + await expect(searchBoxV2.sidebarToggle).toHaveAttribute( + 'aria-expanded', + 'false' + ) + await expect(searchBoxV2.categoryButton('sampling')).toBeHidden() + await expect(searchBoxV2.sidebarBackdrop).toBeHidden() + }) + + test('@mobile Focusing the search input closes the sidebar', async ({ + comfyPage + }) => { + const { searchBoxV2 } = comfyPage + await searchBoxV2.open() + + await searchBoxV2.sidebarToggle.click() + await expect(searchBoxV2.sidebarToggle).toHaveAttribute( + 'aria-expanded', + 'true' + ) + + await searchBoxV2.input.focus() + + await expect(searchBoxV2.sidebarToggle).toHaveAttribute( + 'aria-expanded', + 'false' + ) + }) + + test('Sidebar state across mobile/desktop resizes', async ({ + comfyPage + }) => { + const { searchBoxV2 } = comfyPage + const switchToDesktop = () => + comfyPage.page.setViewportSize({ width: 1280, height: 800 }) + const switchToMobile = () => + comfyPage.page.setViewportSize({ width: 360, height: 800 }) + const expectExpanded = (value: 'true' | 'false') => + expect(searchBoxV2.sidebarToggle).toHaveAttribute( + 'aria-expanded', + value + ) + + await switchToDesktop() + await searchBoxV2.open() + await expectExpanded('true') + + await switchToMobile() + await expectExpanded('false') + + await searchBoxV2.sidebarToggle.click() + await switchToDesktop() + await expectExpanded('true') + + await searchBoxV2.sidebarToggle.click() + await switchToMobile() + await expectExpanded('false') + + await switchToDesktop() + await expectExpanded('false') }) }) }) diff --git a/browser_tests/tests/nodeSearchBoxV2Extended.spec.ts b/browser_tests/tests/nodeSearchBoxV2Extended.spec.ts index d4bcd3452b..3a50e4a887 100644 --- a/browser_tests/tests/nodeSearchBoxV2Extended.spec.ts +++ b/browser_tests/tests/nodeSearchBoxV2Extended.spec.ts @@ -2,27 +2,17 @@ import { comfyExpect as expect, comfyPageFixture as test } from '@e2e/fixtures/ComfyPage' +import { RootCategory } from '@/components/searchbox/v2/rootCategories' test.describe('Node search box V2 extended', { tag: '@node' }, () => { test.beforeEach(async ({ comfyPage }) => { - await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled') - await comfyPage.settings.setSetting('Comfy.NodeSearchBoxImpl', 'default') - await comfyPage.settings.setSetting( - 'Comfy.LinkRelease.Action', - 'search box' - ) - await comfyPage.settings.setSetting( - 'Comfy.LinkRelease.ActionShift', - 'search box' - ) - await comfyPage.searchBoxV2.reload(comfyPage) + await comfyPage.searchBoxV2.setup() }) test('Double-click on empty canvas opens search', async ({ comfyPage }) => { const { searchBoxV2 } = comfyPage - await comfyPage.canvasOps.doubleClick() - await expect(searchBoxV2.input).toBeVisible() + await searchBoxV2.openByDoubleClickCanvas() await expect(searchBoxV2.dialog).toBeVisible() }) @@ -32,43 +22,40 @@ test.describe('Node search box V2 extended', { tag: '@node' }, () => { const { searchBoxV2 } = comfyPage const initialCount = await comfyPage.nodeOps.getGraphNodesCount() - await comfyPage.canvasOps.doubleClick() - await expect(searchBoxV2.input).toBeVisible() - + await searchBoxV2.open() await searchBoxV2.input.fill('KSampler') await expect(searchBoxV2.results.first()).toBeVisible() await comfyPage.page.keyboard.press('Escape') await expect(searchBoxV2.input).toBeHidden() - await expect .poll(() => comfyPage.nodeOps.getGraphNodesCount()) .toBe(initialCount) }) - test('Search clears when reopening', async ({ comfyPage }) => { - const { searchBoxV2 } = comfyPage + for (const closeKey of ['Enter', 'Escape'] as const) { + test(`Reopening search after ${closeKey} has no persisted state`, async ({ + comfyPage + }) => { + const { searchBoxV2 } = comfyPage - await comfyPage.canvasOps.doubleClick() - await expect(searchBoxV2.input).toBeVisible() + await searchBoxV2.open() + await searchBoxV2.input.fill('KSampler') + await expect(searchBoxV2.results.first()).toBeVisible() + await comfyPage.page.keyboard.press(closeKey) + await expect(searchBoxV2.input).toBeHidden() - await searchBoxV2.input.fill('KSampler') - await expect(searchBoxV2.results.first()).toBeVisible() - - await comfyPage.page.keyboard.press('Escape') - await expect(searchBoxV2.input).toBeHidden() - - await comfyPage.canvasOps.doubleClick() - await expect(searchBoxV2.input).toBeVisible() - await expect(searchBoxV2.input).toHaveValue('') - }) + await searchBoxV2.open() + await expect(searchBoxV2.input).toHaveValue('') + await expect(searchBoxV2.filterChips).toHaveCount(0) + }) + } test.describe('Category navigation', () => { test('Category navigation updates results', async ({ comfyPage }) => { const { searchBoxV2 } = comfyPage - await comfyPage.canvasOps.doubleClick() - await expect(searchBoxV2.input).toBeVisible() + await searchBoxV2.open() await searchBoxV2.categoryButton('sampling').click() await expect(searchBoxV2.results.first()).toBeVisible() @@ -76,7 +63,6 @@ test.describe('Node search box V2 extended', { tag: '@node' }, () => { await searchBoxV2.categoryButton('loaders').click() await expect(searchBoxV2.results.first()).toBeVisible() - await expect .poll(() => searchBoxV2.results.allTextContents()) .not.toEqual(samplingResults) @@ -87,58 +73,328 @@ test.describe('Node search box V2 extended', { tag: '@node' }, () => { test('Filter chip removal restores results', async ({ comfyPage }) => { const { searchBoxV2 } = comfyPage - await comfyPage.canvasOps.doubleClick() - await expect(searchBoxV2.input).toBeVisible() + await searchBoxV2.open() - // Record initial result text for comparison + // Search first to keep the result set under the 64-item cap. + await searchBoxV2.input.fill('Load') await expect(searchBoxV2.results.first()).toBeVisible() - const unfilteredResults = await searchBoxV2.results.allTextContents() + const unfilteredCount = await searchBoxV2.results.count() - // Apply Input filter with MODEL type - await searchBoxV2.filterBarButton('Input').click() - await expect(searchBoxV2.filterOptions.first()).toBeVisible() - await searchBoxV2.filterSearch.fill('MODEL') - await searchBoxV2.filterOptions - .filter({ hasText: 'MODEL' }) - .first() - .click() + await test.step('Apply Input/MODEL filter', async () => { + await searchBoxV2.applyTypeFilter('input', 'MODEL') + await expect(searchBoxV2.filterChips).toHaveCount(1) + await expect + .poll(() => searchBoxV2.results.count()) + .not.toBe(unfilteredCount) + }) - // Verify filter chip appeared and results changed - const filterChip = searchBoxV2.dialog.getByTestId('filter-chip') - await expect(filterChip).toBeVisible() - await expect(searchBoxV2.results.first()).toBeVisible() - await expect - .poll(() => searchBoxV2.results.allTextContents()) - .not.toEqual(unfilteredResults) - - // Remove filter by clicking the chip delete button - await filterChip.getByTestId('chip-delete').click() - - // Filter chip should be removed - await expect(filterChip).toBeHidden() - await expect(searchBoxV2.results.first()).toBeVisible() + await test.step('Remove the filter chip', async () => { + await searchBoxV2.removeFilterChip() + await expect(searchBoxV2.filterChips).toHaveCount(0) + await expect(searchBoxV2.results).toHaveCount(unfilteredCount) + }) }) }) - test.describe('Keyboard navigation', () => { - test('ArrowUp on first item keeps first selected', async ({ + test.describe('Link release', () => { + test('Link release opens search with pre-applied type filter', async ({ comfyPage }) => { const { searchBoxV2 } = comfyPage - await comfyPage.canvasOps.doubleClick() + await comfyPage.canvasOps.disconnectEdge() await expect(searchBoxV2.input).toBeVisible() + // disconnectEdge pulls a CLIP link → expect a single CLIP filter chip. + await expect(searchBoxV2.filterChips).toHaveCount(1) + await expect(searchBoxV2.filterChips.first()).toContainText('CLIP') + }) + + test('Link release auto-connects added node', async ({ comfyPage }) => { + const { searchBoxV2 } = comfyPage + const NODE_TYPE = 'CLIPTextEncode' + const refsBefore = await comfyPage.nodeOps.getNodeRefsByType(NODE_TYPE) + const idsBefore = new Set(refsBefore.map((n) => n.id)) + + await comfyPage.canvasOps.disconnectEdge() + await expect(searchBoxV2.input).toBeVisible() + + await searchBoxV2.input.fill('CLIP Text Encode') + await expect(searchBoxV2.results.first()).toBeVisible() + await comfyPage.page.keyboard.press('Enter') + await expect(searchBoxV2.input).toBeHidden() + + // A new CLIPTextEncode node should have been added. + await expect + .poll(() => + comfyPage.nodeOps + .getNodeRefsByType(NODE_TYPE) + .then((refs) => refs.length) + ) + .toBe(refsBefore.length + 1) + + // Verify the auto-connect: the newly-added node's CLIP input must be + // connected (proves the release wasn't just dropped). + const refsAfter = await comfyPage.nodeOps.getNodeRefsByType(NODE_TYPE) + const newNode = refsAfter.find((n) => !idsBefore.has(n.id)) + expect(newNode, 'expected a new CLIPTextEncode node').toBeDefined() + const clipInput = await newNode!.getInput(0) + await expect.poll(() => clipInput.getLinkCount()).toBe(1) + }) + }) + + test.describe('Filter combinations', () => { + test('Output type filter filters results', async ({ comfyPage }) => { + const { searchBoxV2 } = comfyPage + + await searchBoxV2.open() + + await searchBoxV2.input.fill('Load') + await expect(searchBoxV2.results.first()).toBeVisible() + const unfilteredCount = await searchBoxV2.results.count() + + await searchBoxV2.applyTypeFilter('output', 'IMAGE') + await expect(searchBoxV2.filterChips).toHaveCount(1) + await expect + .poll(() => searchBoxV2.results.count()) + .not.toBe(unfilteredCount) + }) + + test('Multiple type filters (Input + Output) narrows results', async ({ + comfyPage + }) => { + const { searchBoxV2 } = comfyPage + + await searchBoxV2.open() + + await searchBoxV2.applyTypeFilter('input', 'MODEL') + await expect(searchBoxV2.filterChips).toHaveCount(1) + await expect(searchBoxV2.results.first()).toBeVisible() + const singleFilterCount = await searchBoxV2.results.count() + + await searchBoxV2.applyTypeFilter('output', 'LATENT') + await expect(searchBoxV2.filterChips).toHaveCount(2) + await expect + .poll(() => searchBoxV2.results.count()) + .toBeLessThan(singleFilterCount) + }) + + test('Root filter + search query narrows results', async ({ + comfyPage + }) => { + const { searchBoxV2 } = comfyPage + + await searchBoxV2.open() + await searchBoxV2.input.fill('Sampler') + await expect(searchBoxV2.results.first()).toBeVisible() + const unfilteredCount = await searchBoxV2.results.count() + + await searchBoxV2.rootCategoryButton('comfy').click() + await expect + .poll(() => searchBoxV2.results.count()) + .toBeLessThan(unfilteredCount) + await expect.poll(() => searchBoxV2.results.count()).toBeGreaterThan(0) + }) + + test('Root filter + category selection', async ({ comfyPage }) => { + const { searchBoxV2 } = comfyPage + + await searchBoxV2.open() + + await searchBoxV2.rootCategoryButton('comfy').click() + await expect(searchBoxV2.results.first()).toBeVisible() + const comfyCount = await searchBoxV2.results.count() + + // Under root filter, categories are prefixed (e.g. comfy/sampling). + await searchBoxV2.categoryButton('comfy/sampling').click() + await expect + .poll(() => searchBoxV2.results.count()) + .toBeLessThan(comfyCount) + }) + }) + + test.describe('Category sidebar', () => { + test('Category tree expand and collapse', async ({ comfyPage }) => { + const { searchBoxV2 } = comfyPage + + await searchBoxV2.open() + + const samplingBtn = searchBoxV2.categoryButton('sampling') + const subcategory = searchBoxV2.categoryButton('sampling/custom_sampling') + + await test.step('Expanding sampling reveals its subcategories', async () => { + await samplingBtn.click() + await expect(subcategory).toBeVisible() + }) + + await test.step('Collapsing sampling hides its subcategories', async () => { + await samplingBtn.click() + await expect(subcategory).toBeHidden() + }) + }) + + test('Subcategory narrows results to subset', async ({ comfyPage }) => { + const { searchBoxV2 } = comfyPage + + await searchBoxV2.open() + + await searchBoxV2.categoryButton('sampling').click() + await expect(searchBoxV2.results.first()).toBeVisible() + const parentCount = await searchBoxV2.results.count() + + const subcategory = searchBoxV2.categoryButton('sampling/custom_sampling') + await expect(subcategory).toBeVisible() + await subcategory.click() + + await expect + .poll(() => searchBoxV2.results.count()) + .toBeLessThan(parentCount) + }) + + test('Most relevant resets category filter', async ({ comfyPage }) => { + const { searchBoxV2 } = comfyPage + + await searchBoxV2.open() + await expect(searchBoxV2.results.first()).toBeVisible() + const defaultCount = await searchBoxV2.results.count() + + await searchBoxV2.categoryButton('sampling').click() + await expect + .poll(() => searchBoxV2.results.count()) + .not.toBe(defaultCount) + + await searchBoxV2.categoryButton('most-relevant').click() + await expect(searchBoxV2.results).toHaveCount(defaultCount) + }) + + test( + 'Blueprint root chip filters to published blueprints', + { tag: ['@subgraph'] }, + async ({ comfyPage }) => { + const blueprintName = `chip-test-${crypto.randomUUID().slice(0, 8)}` + const nodeRef = await comfyPage.nodeOps.getNodeRefById('3') + await nodeRef.click('title') + await comfyPage.command.executeCommand('Comfy.Graph.ConvertToSubgraph') + await expect + .poll(() => + comfyPage.nodeOps + .getNodeRefsByTitle('New Subgraph') + .then((refs) => refs.length) + ) + .toBe(1) + const subgraphNodes = + await comfyPage.nodeOps.getNodeRefsByTitle('New Subgraph') + await subgraphNodes[0].click('title') + await comfyPage.command.executeCommand('Comfy.PublishSubgraph', { + name: blueprintName + }) + await expect(comfyPage.visibleToasts).toHaveCount(1, { timeout: 5000 }) + await comfyPage.toast.closeToasts(1) + + const { searchBoxV2 } = comfyPage + await searchBoxV2.open() + + const blueprintsChip = searchBoxV2.rootCategoryButton( + RootCategory.Blueprint + ) + await expect(blueprintsChip).toBeVisible() + await blueprintsChip.click() + + // Blueprints persist across tests on the same worker; filter by the + // unique name we just published rather than asserting the full list. + await expect( + searchBoxV2.results.filter({ hasText: blueprintName }) + ).toHaveCount(1) + } + ) + }) + + test.describe('Search behavior', () => { + test('Search narrows results progressively', async ({ comfyPage }) => { + const { searchBoxV2 } = comfyPage + const getCount = () => searchBoxV2.results.count() + + await searchBoxV2.open() + + await searchBoxV2.input.fill('S') + await expect(searchBoxV2.results.first()).toBeVisible() + const count1 = await getCount() + + await searchBoxV2.input.fill('Sa') + await expect.poll(getCount).toBeLessThan(count1) + const count2 = await getCount() + + await searchBoxV2.input.fill('Sampler') + await expect.poll(getCount).toBeLessThan(count2) + }) + + test('No results shown for nonsensical query', async ({ comfyPage }) => { + const { searchBoxV2 } = comfyPage + + await searchBoxV2.open() + await searchBoxV2.input.fill('zzzxxxyyy_nonexistent_node') + + await expect(searchBoxV2.noResults).toBeVisible() + await expect(searchBoxV2.results).toHaveCount(0) + }) + }) + + test.describe('Filter chip interaction', () => { + test('Multiple filter chips displayed', async ({ comfyPage }) => { + const { searchBoxV2 } = comfyPage + + await searchBoxV2.open() + await searchBoxV2.applyTypeFilter('input', 'MODEL') + await searchBoxV2.applyTypeFilter('output', 'LATENT') + + await expect(searchBoxV2.filterChips).toHaveCount(2) + const chipTexts = await searchBoxV2.filterChips.allTextContents() + expect(chipTexts.some((t) => t.includes('MODEL'))).toBe(true) + expect(chipTexts.some((t) => t.includes('LATENT'))).toBe(true) + }) + }) + + test.describe('Settings-driven behavior', () => { + test('Node ID name shown when setting enabled', async ({ comfyPage }) => { + await comfyPage.settings.setSetting( + 'Comfy.NodeSearchBoxImpl.ShowIdName', + true + ) + const { searchBoxV2 } = comfyPage + + await searchBoxV2.open() + await searchBoxV2.input.fill('VAE Decode') + await expect(searchBoxV2.results.first()).toBeVisible() + + await expect(searchBoxV2.nodeIdBadge.first()).toBeVisible() + await expect(searchBoxV2.nodeIdBadge.first()).toContainText('VAEDecode') + }) + + test('Follow-cursor disabled places node without ghost mode', async ({ + comfyPage + }) => { + await comfyPage.settings.setSetting( + 'Comfy.NodeSearchBoxImpl.FollowCursor', + false + ) + const { searchBoxV2 } = comfyPage + const initialCount = await comfyPage.nodeOps.getGraphNodesCount() + + await searchBoxV2.open() + await searchBoxV2.input.fill('KSampler') - const results = searchBoxV2.results - await expect(results.first()).toBeVisible() + await expect(searchBoxV2.results.first()).toBeVisible() - // First result should be selected by default - await expect(results.first()).toHaveAttribute('aria-selected', 'true') + await searchBoxV2.results.first().click() + await expect(searchBoxV2.input).toBeHidden() - // ArrowUp on first item should keep first selected - await comfyPage.page.keyboard.press('ArrowUp') - await expect(results.first()).toHaveAttribute('aria-selected', 'true') + await expect + .poll(() => comfyPage.nodeOps.getGraphNodesCount()) + .toBe(initialCount + 1) + + await expect( + comfyPage.page.locator('[data-node-id][data-ghost]') + ).toHaveCount(0) }) }) }) diff --git a/browser_tests/tests/painter.spec.ts b/browser_tests/tests/painter.spec.ts index e2d994929e..e8aa977055 100644 --- a/browser_tests/tests/painter.spec.ts +++ b/browser_tests/tests/painter.spec.ts @@ -7,7 +7,7 @@ import { drawStroke, hasCanvasContent, triggerSerialization -} from '@e2e/helpers/painter' +} from '@e2e/fixtures/utils/painter' import type { TestGraphAccess } from '@e2e/types/globals' test.describe('Painter', { tag: ['@widget', '@vue-nodes'] }, () => { @@ -163,7 +163,7 @@ test.describe('Painter', { tag: ['@widget', '@vue-nodes'] }, () => { .poll(() => cursor.evaluate((el: HTMLElement) => el.style.transform)) .not.toBe(transform1) - await comfyPage.page.mouse.move(0, 0) + await comfyPage.page.mouse.move(box.x + box.width + 50, box.y) await expect(cursor).toBeHidden() }) @@ -187,7 +187,10 @@ test.describe('Painter', { tag: ['@widget', '@vue-nodes'] }, () => { box.y + box.height * 0.5, { steps: 10 } ) - await comfyPage.page.mouse.move(box.x - 20, box.y + box.height * 0.5) + await comfyPage.page.mouse.move( + box.x + box.width + 20, + box.y + box.height * 0.5 + ) await comfyPage.page.mouse.up() await comfyPage.nextFrame() @@ -408,6 +411,7 @@ test.describe('Painter', { tag: ['@widget', '@vue-nodes'] }, () => { await expect .poll(() => canvas.evaluate((el: HTMLCanvasElement) => el.width)) + // default 512 + slider step 64 = 576 .toBe(576) }) @@ -493,6 +497,29 @@ test.describe('Painter', { tag: ['@widget', '@vue-nodes'] }, () => { .toBe(false) } ) + + test('Clear on empty canvas is harmless', async ({ comfyPage }) => { + const painterWidget = comfyPage.vueNodes + .getNodeLocator('1') + .locator('.widget-expands') + const canvas = painterWidget.locator('canvas') + + await expect + .poll(() => hasCanvasContent(canvas), { + message: 'canvas should start empty' + }) + .toBe(false) + + await painterWidget + .getByTestId('painter-clear-button') + .dispatchEvent('click') + + await expect + .poll(() => hasCanvasContent(canvas), { + message: 'canvas should still be empty after clearing empty canvas' + }) + .toBe(false) + }) }) test.describe('Serialization', () => { @@ -560,36 +587,6 @@ test.describe('Painter', { tag: ['@widget', '@vue-nodes'] }, () => { }) test.describe('Eraser', () => { - test('Eraser removes previously drawn content', async ({ comfyPage }) => { - const node = comfyPage.vueNodes.getNodeLocator('1') - const painterWidget = node.locator('.widget-expands') - const canvas = painterWidget.locator('canvas') - await expect(canvas).toBeVisible() - - await drawStroke(comfyPage.page, canvas) - await comfyPage.nextFrame() - await expect.poll(() => hasCanvasContent(canvas)).toBe(true) - - await painterWidget.getByRole('button', { name: 'Eraser' }).click() - await drawStroke(comfyPage.page, canvas) - await comfyPage.nextFrame() - - await expect - .poll( - () => - canvas.evaluate((el: HTMLCanvasElement) => { - const ctx = el.getContext('2d') - if (!ctx) return false - const cx = Math.floor(el.width / 2) - const cy = Math.floor(el.height / 2) - const { data } = ctx.getImageData(cx - 5, cy - 5, 10, 10) - return data.every((v, i) => i % 4 !== 3 || v === 0) - }), - { message: 'erased area should be transparent' } - ) - .toBe(true) - }) - test('Eraser on empty canvas adds no content', async ({ comfyPage }) => { const node = comfyPage.vueNodes.getNodeLocator('1') const painterWidget = node.locator('.widget-expands') @@ -604,18 +601,318 @@ test.describe('Painter', { tag: ['@widget', '@vue-nodes'] }, () => { }) }) - test('Multiple strokes accumulate on the canvas', async ({ comfyPage }) => { + test.describe('Serialization — unchanged canvas', () => { + test( + 'Unchanged canvas does not re-upload on second serialization', + { tag: '@slow' }, + async ({ comfyPage }) => { + let uploadCount = 0 + + await comfyPage.page.route('**/upload/image', async (route) => { + uploadCount++ + const mockResponse: UploadImageResponse = { name: 'painter-test.png' } + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(mockResponse) + }) + }) + + const canvas = comfyPage.vueNodes + .getNodeLocator('1') + .locator('.widget-expands canvas') + + await drawStroke(comfyPage.page, canvas) + await triggerSerialization(comfyPage.page) + expect(uploadCount, 'first serialization should upload once').toBe(1) + + await triggerSerialization(comfyPage.page) + expect( + uploadCount, + 'second serialization without new drawing should not re-upload' + ).toBe(1) + } + ) + }) + + test.describe('Settings persistence', () => { + test('Tool selection is saved to node properties', async ({ + comfyPage + }) => { + const painterWidget = comfyPage.vueNodes + .getNodeLocator('1') + .locator('.widget-expands') + + await painterWidget.getByRole('button', { name: 'Eraser' }).click() + + await expect + .poll( + () => + comfyPage.page.evaluate(() => { + const graph = window.graph as TestGraphAccess | undefined + return graph?._nodes_by_id?.['1']?.properties?.painterTool as + | string + | undefined + }), + { message: 'painterTool property should update to eraser' } + ) + .toBe('eraser') + }) + + test('Brush size change is saved to node properties', async ({ + comfyPage + }) => { + const sizeRow = comfyPage.vueNodes + .getNodeLocator('1') + .locator('.widget-expands') + .getByTestId('painter-size-row') + const sizeSlider = sizeRow.getByRole('slider') + + await expect( + sizeRow.getByTestId('painter-size-value'), + 'brush size should start at default 20' + ).toHaveText('20') + + await sizeSlider.focus() + for (let i = 0; i < 10; i++) { + await sizeSlider.press('ArrowRight') + } + + await expect + .poll( + () => + comfyPage.page.evaluate(() => { + const graph = window.graph as TestGraphAccess | undefined + return graph?._nodes_by_id?.['1']?.properties + ?.painterBrushSize as number | undefined + }), + { message: 'painterBrushSize property should update to 30' } + ) + .toBe(30) + }) + }) + + test('Controls collapse to single column in compact mode', async ({ + comfyPage + }) => { + const painterWidget = comfyPage.vueNodes + .getNodeLocator('1') + .locator('.widget-expands') + const toolLabel = painterWidget.getByText('Tool', { exact: true }) + + await expect( + toolLabel, + 'tool label should be visible in two-column layout' + ).toBeVisible() + + await comfyPage.page.evaluate(() => { + const graph = window.graph as TestGraphAccess | undefined + const node = graph?._nodes_by_id?.['1'] + if (node) { + node.size = [200, 400] + window.app!.canvas.setDirty(true, true) + } + }) + + await expect( + toolLabel, + 'tool label should hide in compact single-column layout' + ).toBeHidden() + }) + + test('Multiple sequential strokes at different positions all accumulate', async ({ + comfyPage + }) => { const canvas = comfyPage.vueNodes .getNodeLocator('1') .locator('.widget-expands canvas') await expect(canvas).toBeVisible() - await drawStroke(comfyPage.page, canvas, { yPct: 0.3 }) + await drawStroke(comfyPage.page, canvas, { yPct: 0.25 }) + await drawStroke(comfyPage.page, canvas, { yPct: 0.5 }) + await drawStroke(comfyPage.page, canvas, { yPct: 0.75 }) await comfyPage.nextFrame() - await expect.poll(() => hasCanvasContent(canvas)).toBe(true) - await drawStroke(comfyPage.page, canvas, { yPct: 0.7 }) - await comfyPage.nextFrame() - await expect.poll(() => hasCanvasContent(canvas)).toBe(true) + const hasContentAtRow = (yFraction: number) => + canvas.evaluate((el: HTMLCanvasElement, y: number) => { + const ctx = el.getContext('2d') + if (!ctx) return false + const cy = Math.floor(el.height * y) + const { data } = ctx.getImageData(0, cy - 5, el.width, 10) + for (let i = 3; i < data.length; i += 4) { + if (data[i] > 0) return true + } + return false + }, yFraction) + + await expect + .poll(() => hasContentAtRow(0.25), { + message: 'top stroke should be present' + }) + .toBe(true) + await expect + .poll(() => hasContentAtRow(0.5), { + message: 'middle stroke should be present' + }) + .toBe(true) + await expect + .poll(() => hasContentAtRow(0.75), { + message: 'bottom stroke should be present' + }) + .toBe(true) }) }) + +test.describe( + 'Painter — input image connection', + { tag: ['@widget', '@vue-nodes', '@slow'] }, + () => { + test.setTimeout(60_000) + + test.beforeEach(async ({ comfyPage }) => { + await comfyPage.page.evaluate(() => window.app?.graph?.clear()) + await comfyPage.workflow.loadWorkflow('widgets/painter_with_input') + }) + + test('Width, height, and bg_color controls hide when input is connected', async ({ + comfyPage + }) => { + const painterWidget = comfyPage.vueNodes + .getNodeLocator('1') + .locator('.widget-expands') + + await expect( + painterWidget.getByTestId('painter-width-row'), + 'width row should be hidden when input is connected' + ).toBeHidden() + await expect( + painterWidget.getByTestId('painter-height-row'), + 'height row should be hidden when input is connected' + ).toBeHidden() + await expect( + painterWidget.getByTestId('painter-bg-color-row'), + 'background color row should be hidden when input is connected' + ).toBeHidden() + await expect( + painterWidget.getByTestId('painter-dimension-text'), + 'dimension text should be visible when input is connected' + ).toBeVisible() + }) + + test('Canvas resizes to match input image dimensions after execution', async ({ + comfyPage + }) => { + await comfyPage.runButton.click() + + const node = comfyPage.vueNodes.getNodeLocator('1') + const img = node.locator('.widget-expands img') + await expect( + img, + 'input image should appear after execution' + ).toBeVisible({ + timeout: 30_000 + }) + + await expect + .poll( + () => + img.evaluate( + (el: HTMLImageElement) => el.complete && el.naturalWidth > 0 + ), + { + message: 'input image should be fully decoded', + timeout: 30_000 + } + ) + .toBe(true) + + const { nw, nh } = await img.evaluate((el: HTMLImageElement) => ({ + nw: el.naturalWidth, + nh: el.naturalHeight + })) + + const canvas = node.locator('.widget-expands canvas') + await expect + .poll(() => canvas.evaluate((el: HTMLCanvasElement) => el.width), { + message: 'canvas width should match input image natural width' + }) + .toBe(nw) + await expect + .poll(() => canvas.evaluate((el: HTMLCanvasElement) => el.height), { + message: 'canvas height should match input image natural height' + }) + .toBe(nh) + }) + + test('Drawing over input image produces content on canvas', async ({ + comfyPage + }) => { + await comfyPage.runButton.click() + + const node = comfyPage.vueNodes.getNodeLocator('1') + const img = node.locator('.widget-expands img') + await expect( + img, + 'input image should appear after execution' + ).toBeVisible({ + timeout: 30_000 + }) + await expect + .poll( + () => + img.evaluate( + (el: HTMLImageElement) => el.complete && el.naturalWidth > 0 + ), + { message: 'input image should be fully decoded', timeout: 30_000 } + ) + .toBe(true) + + const nw = await img.evaluate((el: HTMLImageElement) => el.naturalWidth) + const canvas = node.locator('.widget-expands canvas') + await expect + .poll(() => canvas.evaluate((el: HTMLCanvasElement) => el.width), { + message: 'canvas should resize to match input image width', + timeout: 15_000 + }) + .toBe(nw) + + // Use dispatchEvent to bypass the LiteGraph canvas z-index overlay that + // intercepts coordinate-based hit testing from page.mouse + const box = await canvas.boundingBox() + if (!box) throw new Error('Canvas bounding box not found') + const startX = box.x + box.width * 0.3 + const endX = box.x + box.width * 0.7 + const midY = box.y + box.height * 0.5 + const pointerOpts = { + bubbles: true, + cancelable: true, + pointerId: 1, + button: 0, + isPrimary: true + } + await canvas.dispatchEvent('pointerdown', { + ...pointerOpts, + clientX: startX, + clientY: midY + }) + for (let i = 1; i <= 10; i++) { + await canvas.dispatchEvent('pointermove', { + ...pointerOpts, + clientX: startX + (endX - startX) * (i / 10), + clientY: midY + }) + } + await canvas.dispatchEvent('pointerup', { + ...pointerOpts, + clientX: endX, + clientY: midY + }) + + await expect + .poll(() => hasCanvasContent(canvas), { + message: 'drawing over input image should produce canvas content' + }) + .toBe(true) + }) + } +) diff --git a/browser_tests/tests/performance.spec.ts b/browser_tests/tests/performance.spec.ts index 10a0ac8fb4..2988653fe3 100644 --- a/browser_tests/tests/performance.spec.ts +++ b/browser_tests/tests/performance.spec.ts @@ -1,7 +1,10 @@ import { expect } from '@playwright/test' import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage' -import { logMeasurement, recordMeasurement } from '@e2e/helpers/perfReporter' +import { + logMeasurement, + recordMeasurement +} from '@e2e/fixtures/utils/perfReporter' test.describe('Performance', { tag: ['@perf'] }, () => { test('canvas idle style recalculations', async ({ comfyPage }) => { diff --git a/browser_tests/tests/propertiesPanel/PropertiesPanelHelper.ts b/browser_tests/tests/propertiesPanel/PropertiesPanelHelper.ts index d7b425af8d..86fa9ebdd1 100644 --- a/browser_tests/tests/propertiesPanel/PropertiesPanelHelper.ts +++ b/browser_tests/tests/propertiesPanel/PropertiesPanelHelper.ts @@ -1,6 +1,7 @@ import type { Locator, Page } from '@playwright/test' import { expect } from '@playwright/test' +import { TitleEditor } from '@e2e/fixtures/components/TitleEditor' import { TestIds } from '@e2e/fixtures/selectors' export class PropertiesPanelHelper { @@ -8,12 +9,14 @@ export class PropertiesPanelHelper { readonly panelTitle: Locator readonly searchBox: Locator readonly closeButton: Locator + readonly titleEditor: TitleEditor constructor(readonly page: Page) { this.root = page.getByTestId(TestIds.propertiesPanel.root) this.panelTitle = this.root.locator('h3') this.searchBox = this.root.getByPlaceholder(/^Search/) this.closeButton = this.root.locator('button[aria-pressed]') + this.titleEditor = new TitleEditor(this.root) } get tabs(): Locator { @@ -28,10 +31,6 @@ export class PropertiesPanelHelper { return this.panelTitle.locator('i[class*="lucide--pencil"]') } - get titleInput(): Locator { - return this.root.getByTestId(TestIds.node.titleInput) - } - getNodeStateButton(state: 'Normal' | 'Bypass' | 'Mute'): Locator { return this.root.locator('button', { hasText: state }) } @@ -86,8 +85,8 @@ export class PropertiesPanelHelper { async editTitle(newTitle: string): Promise<void> { await this.titleEditIcon.click() - await this.titleInput.fill(newTitle) - await this.titleInput.press('Enter') + await this.titleEditor.expectVisible() + await this.titleEditor.setTitle(newTitle) } async searchWidgets(query: string): Promise<void> { diff --git a/browser_tests/tests/propertiesPanel/errorsTabMissingMedia.spec.ts b/browser_tests/tests/propertiesPanel/errorsTabMissingMedia.spec.ts index b6f6325298..81aa6a93ec 100644 --- a/browser_tests/tests/propertiesPanel/errorsTabMissingMedia.spec.ts +++ b/browser_tests/tests/propertiesPanel/errorsTabMissingMedia.spec.ts @@ -3,7 +3,7 @@ import { expect } from '@playwright/test' import type { ComfyPage } from '@e2e/fixtures/ComfyPage' import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage' import { TestIds } from '@e2e/fixtures/selectors' -import { loadWorkflowAndOpenErrorsTab } from '@e2e/tests/propertiesPanel/ErrorsTabHelper' +import { loadWorkflowAndOpenErrorsTab } from '@e2e/fixtures/helpers/ErrorsTabHelper' async function uploadFileViaDropzone(comfyPage: ComfyPage) { const dropzone = comfyPage.page.getByTestId( diff --git a/browser_tests/tests/propertiesPanel/errorsTabMissingModels.spec.ts b/browser_tests/tests/propertiesPanel/errorsTabMissingModels.spec.ts index 1a043eed72..41b40cf977 100644 --- a/browser_tests/tests/propertiesPanel/errorsTabMissingModels.spec.ts +++ b/browser_tests/tests/propertiesPanel/errorsTabMissingModels.spec.ts @@ -5,11 +5,11 @@ import { TestIds } from '@e2e/fixtures/selectors' import { interceptClipboardWrite, getClipboardText -} from '@e2e/helpers/clipboardSpy' +} from '@e2e/fixtures/utils/clipboardSpy' import { cleanupFakeModel, loadWorkflowAndOpenErrorsTab -} from '@e2e/tests/propertiesPanel/ErrorsTabHelper' +} from '@e2e/fixtures/helpers/ErrorsTabHelper' test.describe('Errors tab - Missing models', { tag: '@ui' }, () => { test.beforeEach(async ({ comfyPage }) => { diff --git a/browser_tests/tests/propertiesPanel/errorsTabMissingNodes.spec.ts b/browser_tests/tests/propertiesPanel/errorsTabMissingNodes.spec.ts index 8cd1a993f5..22c5e3afaf 100644 --- a/browser_tests/tests/propertiesPanel/errorsTabMissingNodes.spec.ts +++ b/browser_tests/tests/propertiesPanel/errorsTabMissingNodes.spec.ts @@ -2,7 +2,7 @@ import { expect } from '@playwright/test' import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage' import { TestIds } from '@e2e/fixtures/selectors' -import { loadWorkflowAndOpenErrorsTab } from '@e2e/tests/propertiesPanel/ErrorsTabHelper' +import { loadWorkflowAndOpenErrorsTab } from '@e2e/fixtures/helpers/ErrorsTabHelper' test.describe('Errors tab - Missing nodes', { tag: '@ui' }, () => { test.beforeEach(async ({ comfyPage }) => { diff --git a/browser_tests/tests/propertiesPanel/errorsTabModeAware.spec.ts b/browser_tests/tests/propertiesPanel/errorsTabModeAware.spec.ts index 7db9ff9c46..9f668fba6a 100644 --- a/browser_tests/tests/propertiesPanel/errorsTabModeAware.spec.ts +++ b/browser_tests/tests/propertiesPanel/errorsTabModeAware.spec.ts @@ -6,7 +6,7 @@ import { cleanupFakeModel, openErrorsTab, loadWorkflowAndOpenErrorsTab -} from '@e2e/tests/propertiesPanel/ErrorsTabHelper' +} from '@e2e/fixtures/helpers/ErrorsTabHelper' test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => { test.beforeEach(async ({ comfyPage }) => { diff --git a/browser_tests/tests/propertiesPanel/titleEditing.spec.ts b/browser_tests/tests/propertiesPanel/titleEditing.spec.ts index 6656ca4893..bb4b04d401 100644 --- a/browser_tests/tests/propertiesPanel/titleEditing.spec.ts +++ b/browser_tests/tests/propertiesPanel/titleEditing.spec.ts @@ -18,7 +18,7 @@ test.describe('Properties panel - Title editing', () => { test('should enter edit mode on pencil click', async () => { await panel.titleEditIcon.click() - await expect(panel.titleInput).toBeVisible() + await panel.titleEditor.expectVisible() }) test('should update node title on edit', async () => { diff --git a/browser_tests/tests/queue/queueSettings.spec.ts b/browser_tests/tests/queue/queueSettings.spec.ts new file mode 100644 index 0000000000..337e57cfed --- /dev/null +++ b/browser_tests/tests/queue/queueSettings.spec.ts @@ -0,0 +1,121 @@ +import { mergeTests } from '@playwright/test' +import type { Locator, Page, Request } from '@playwright/test' +import type { JobsListResponse } from '@comfyorg/ingest-types' + +import type { ComfyPage } from '@e2e/fixtures/ComfyPage' +import { + comfyExpect as expect, + comfyPageFixture +} from '@e2e/fixtures/ComfyPage' +import { createMockJobs } from '@e2e/fixtures/helpers/AssetsHelper' +import { ExecutionHelper } from '@e2e/fixtures/helpers/ExecutionHelper' +import { TestIds } from '@e2e/fixtures/selectors' +import { webSocketFixture } from '@e2e/fixtures/ws' + +const test = mergeTests(comfyPageFixture, webSocketFixture) + +const TOTAL_MOCK_JOBS = 20 +const overflowJobsListRoutePattern = '**/api/jobs?*' + +function isHistoryJobsRequest(url: string): boolean { + if (!url.includes('/api/jobs')) return false + const params = new URL(url).searchParams + const statuses = (params.get('status') ?? '').split(',') + return statuses.includes('completed') +} + +async function captureNextHistoryRequest( + comfyPage: ComfyPage, + exec: ExecutionHelper +): Promise<Request> { + const requestPromise = comfyPage.page.waitForRequest( + (req) => isHistoryJobsRequest(req.url()), + { timeout: 5000 } + ) + exec.status(0) + return requestPromise +} + +function getJobListResults(page: Page): Locator { + return page.getByTestId(TestIds.queue.jobAssetsList).locator('[data-job-id]') +} + +test.describe('Queue settings', { tag: '@canvas' }, () => { + test.describe('Comfy.Queue.MaxHistoryItems', () => { + test.describe('limit query parameter', () => { + test.beforeEach(async ({ comfyPage }) => { + await comfyPage.assets.mockOutputHistory( + createMockJobs(TOTAL_MOCK_JOBS) + ) + }) + + test.afterEach(async ({ comfyPage }) => { + await comfyPage.assets.clearMocks() + }) + + test('limit query parameter on /api/jobs reflects the setting', async ({ + comfyPage, + getWebSocket + }) => { + const TARGET_LIMIT = 6 + await comfyPage.settings.setSetting( + 'Comfy.Queue.MaxHistoryItems', + TARGET_LIMIT + ) + + const exec = new ExecutionHelper(comfyPage, await getWebSocket()) + const request = await captureNextHistoryRequest(comfyPage, exec) + const url = new URL(request.url()) + expect(url.searchParams.get('limit')).toBe(String(TARGET_LIMIT)) + }) + }) + + test('queue panel caps history items to the configured number', async ({ + comfyPage, + getWebSocket + }) => { + // Add a mock route that returns all jobs regardless of the request's `limit` param + const overflowJobs = createMockJobs(TOTAL_MOCK_JOBS) + await comfyPage.page.route( + overflowJobsListRoutePattern, + async (route) => { + const url = new URL(route.request().url()) + if (!url.searchParams.get('status')?.includes('completed')) { + await route.continue() + return + } + const response = { + jobs: overflowJobs, + pagination: { + offset: 0, + limit: overflowJobs.length, + total: overflowJobs.length, + has_more: false + } + } satisfies { + jobs: unknown[] + pagination: JobsListResponse['pagination'] + } + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(response) + }) + } + ) + + const VISIBLE_LIMIT = 6 + await comfyPage.settings.setSetting( + 'Comfy.Queue.MaxHistoryItems', + VISIBLE_LIMIT + ) + const exec = new ExecutionHelper(comfyPage, await getWebSocket()) + await captureNextHistoryRequest(comfyPage, exec) + + await comfyPage.page.getByTestId(TestIds.queue.overlayToggle).click() + const jobs = getJobListResults(comfyPage.page) + await expect(jobs.first()).toBeVisible() + await expect(jobs).toHaveCount(VISIBLE_LIMIT) + }) + }) +}) diff --git a/browser_tests/tests/queueNotificationBanners.spec.ts b/browser_tests/tests/queueNotificationBanners.spec.ts new file mode 100644 index 0000000000..ca64919045 --- /dev/null +++ b/browser_tests/tests/queueNotificationBanners.spec.ts @@ -0,0 +1,164 @@ +import type { Page } from '@playwright/test' +import { expect } from '@playwright/test' + +import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage' +import { TestIds } from '@e2e/fixtures/selectors' + +// Mirrors BANNER_DISMISS_DELAY_MS in src/composables/queue/useQueueNotificationBanners.ts. +// Duplicated here to avoid pulling production source (and its litegraph +// transitive deps) into the Playwright TS loader. +const BANNER_DISMISS_DELAY_MS = 4000 +const BANNER_ASSERT_TIMEOUT_MS = BANNER_DISMISS_DELAY_MS + 2000 + +const REQUEST_ID_PRIMARY = 1 +const REQUEST_ID_SECONDARY = 2 +const REQUEST_ID_MISMATCH = 999 + +let nextRequestId = 1000 +const newRequestId = () => nextRequestId++ + +function bannerLocator(page: Page) { + return page.getByTestId(TestIds.queue.notificationBanner) +} + +type DispatchOpts = { batchCount?: number; requestId?: number } + +function dispatchPromptQueueing(page: Page, opts: DispatchOpts = {}) { + return page.evaluate( + ([batchCount, requestId]) => { + window.app!.api.dispatchCustomEvent('promptQueueing', { + batchCount, + requestId + }) + }, + [opts.batchCount ?? 1, opts.requestId ?? newRequestId()] + ) +} + +function dispatchPromptQueued(page: Page, opts: DispatchOpts = {}) { + return page.evaluate( + ([batchCount, requestId]) => { + window.app!.api.dispatchCustomEvent('promptQueued', { + number: 0, + batchCount, + requestId + }) + }, + [opts.batchCount ?? 1, opts.requestId ?? newRequestId()] + ) +} + +test.describe('Queue notification banners', { tag: ['@ui'] }, () => { + test.describe('Queuing lifecycle', () => { + test('promptQueueing event shows a queueing banner', async ({ + comfyPage + }) => { + await dispatchPromptQueueing(comfyPage.page) + + const banner = bannerLocator(comfyPage.page) + await expect(banner).toBeVisible() + await expect(banner).toContainText('queuing') + }) + + test('promptQueued upgrades a pending banner to queued', async ({ + comfyPage + }) => { + await dispatchPromptQueueing(comfyPage.page, { + batchCount: 1, + requestId: REQUEST_ID_PRIMARY + }) + + const banner = bannerLocator(comfyPage.page) + await expect(banner).toContainText('queuing') + + await dispatchPromptQueued(comfyPage.page, { + batchCount: 1, + requestId: REQUEST_ID_PRIMARY + }) + + await expect(banner).toContainText('queued') + }) + + test('promptQueued with batch count > 1 shows plural text', async ({ + comfyPage + }) => { + await dispatchPromptQueued(comfyPage.page, { batchCount: 3 }) + + const banner = bannerLocator(comfyPage.page) + await expect(banner).toBeVisible() + await expect(banner).toContainText('3') + await expect(banner).toContainText('jobs added to queue') + }) + + test('promptQueued with mismatched requestId enqueues a separate queued banner', async ({ + comfyPage + }) => { + await dispatchPromptQueueing(comfyPage.page, { + batchCount: 1, + requestId: REQUEST_ID_PRIMARY + }) + + const banner = bannerLocator(comfyPage.page) + await expect(banner).toContainText('queuing') + + await dispatchPromptQueued(comfyPage.page, { + batchCount: 1, + requestId: REQUEST_ID_MISMATCH + }) + + // Pending banner is not upgraded — still shows "queuing". + await expect(banner).toContainText('queuing') + + // After the pending banner auto-dismisses, the queued banner appears. + await expect(banner).toContainText('queued', { + timeout: BANNER_ASSERT_TIMEOUT_MS + }) + }) + }) + + test.describe('Auto-dismiss', () => { + test('Banner auto-dismisses after timeout', async ({ comfyPage }) => { + await dispatchPromptQueued(comfyPage.page) + + const banner = bannerLocator(comfyPage.page) + await expect(banner).toBeVisible() + await expect(banner).toBeHidden({ timeout: BANNER_ASSERT_TIMEOUT_MS }) + }) + }) + + test.describe('Notification queue (FIFO)', () => { + test('Second notification shows after first auto-dismisses', async ({ + comfyPage + }) => { + await dispatchPromptQueued(comfyPage.page, { + batchCount: 1, + requestId: REQUEST_ID_PRIMARY + }) + await dispatchPromptQueued(comfyPage.page, { + batchCount: 2, + requestId: REQUEST_ID_SECONDARY + }) + + const banner = bannerLocator(comfyPage.page) + await expect(banner).toContainText('Job queued') + await expect(banner).toContainText('2 jobs added to queue', { + timeout: BANNER_ASSERT_TIMEOUT_MS + }) + }) + }) + + test.describe('Direct queued event (no pending predecessor)', () => { + test('promptQueued without prior queueing shows queued banner directly', async ({ + comfyPage + }) => { + await dispatchPromptQueued(comfyPage.page, { + batchCount: 1, + requestId: REQUEST_ID_PRIMARY + }) + + const banner = bannerLocator(comfyPage.page) + await expect(banner).toBeVisible() + await expect(banner).toContainText('queued') + }) + }) +}) diff --git a/browser_tests/tests/rightSidePanelTabs.spec.ts b/browser_tests/tests/rightSidePanelTabs.spec.ts index 6110971fba..13c687940a 100644 --- a/browser_tests/tests/rightSidePanelTabs.spec.ts +++ b/browser_tests/tests/rightSidePanelTabs.spec.ts @@ -2,7 +2,6 @@ import { comfyPageFixture as test, comfyExpect as expect } from '@e2e/fixtures/ComfyPage' -import { TestIds } from '@e2e/fixtures/selectors' test.describe('Right Side Panel Tabs', { tag: '@ui' }, () => { test('Properties panel opens with workflow overview', async ({ @@ -35,11 +34,8 @@ test.describe('Right Side Panel Tabs', { tag: '@ui' }, () => { // Click on the title to enter edit mode await propertiesPanel.panelTitle.click() - const titleInput = propertiesPanel.root.getByTestId(TestIds.node.titleInput) - await expect(titleInput).toBeVisible() - - await titleInput.fill('My Custom Sampler') - await titleInput.press('Enter') + await propertiesPanel.titleEditor.expectVisible() + await propertiesPanel.titleEditor.setTitle('My Custom Sampler') await expect(propertiesPanel.panelTitle).toContainText('My Custom Sampler') }) diff --git a/browser_tests/tests/selectionBoundingBox.spec.ts b/browser_tests/tests/selectionBoundingBox.spec.ts index dae9435017..4bb44bd60a 100644 --- a/browser_tests/tests/selectionBoundingBox.spec.ts +++ b/browser_tests/tests/selectionBoundingBox.spec.ts @@ -3,7 +3,7 @@ import type { Page } from '@playwright/test' import type { ComfyPage } from '@e2e/fixtures/ComfyPage' import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage' -import { measureSelectionBounds } from '@e2e/fixtures/helpers/boundsUtils' +import { measureSelectionBounds } from '@e2e/fixtures/utils/boundsUtils' import type { NodeReference } from '@e2e/fixtures/utils/litegraphUtils' const SUBGRAPH_ID = '2' diff --git a/browser_tests/tests/selectionToolbox.spec.ts b/browser_tests/tests/selectionToolbox.spec.ts index c673a2a879..03ac01ac1e 100644 --- a/browser_tests/tests/selectionToolbox.spec.ts +++ b/browser_tests/tests/selectionToolbox.spec.ts @@ -42,8 +42,11 @@ test.describe('Selection Toolbox', { tag: ['@screenshot', '@ui'] }, () => { // Selection toolbox should be visible with multiple nodes selected await expect(comfyPage.selectionToolbox).toBeVisible() // Border is now drawn on canvas, check via screenshot + // Allow small anti-aliasing variance on the canvas-drawn selection border + // (see flake history: commits 1cafa4be9, 53165033e, fbcd36d35) await expect(comfyPage.canvas).toHaveScreenshot( - 'selection-toolbox-multiple-nodes-border.png' + 'selection-toolbox-multiple-nodes-border.png', + { maxDiffPixels: 100 } ) }) diff --git a/browser_tests/tests/selectionToolboxMoreActions.spec.ts b/browser_tests/tests/selectionToolboxMoreActions.spec.ts index ee700e2081..6c2d7f0991 100644 --- a/browser_tests/tests/selectionToolboxMoreActions.spec.ts +++ b/browser_tests/tests/selectionToolboxMoreActions.spec.ts @@ -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 diff --git a/browser_tests/tests/selectionToolboxRename.spec.ts b/browser_tests/tests/selectionToolboxRename.spec.ts new file mode 100644 index 0000000000..0bbd77fe9b --- /dev/null +++ b/browser_tests/tests/selectionToolboxRename.spec.ts @@ -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']) + }) + }) +}) diff --git a/browser_tests/tests/sidebar/assets.spec.ts b/browser_tests/tests/sidebar/assets.spec.ts index 76801b6bb1..cadd23d6ae 100644 --- a/browser_tests/tests/sidebar/assets.spec.ts +++ b/browser_tests/tests/sidebar/assets.spec.ts @@ -5,7 +5,10 @@ import { createMockJob, createMockJobs } from '@e2e/fixtures/helpers/AssetsHelper' -import type { RawJobListItem } from '@/platform/remote/comfyui/jobs/jobTypes' +import type { + JobDetail, + RawJobListItem +} from '@/platform/remote/comfyui/jobs/jobTypes' // --------------------------------------------------------------------------- // Shared fixtures @@ -62,6 +65,37 @@ const SAMPLE_IMPORTED_FILES = [ 'audio_clip.wav' ] +const JOB_GAMMA_DETAIL: JobDetail = { + ...SAMPLE_JOBS[2], + outputs: { + '3': { + images: [ + { + filename: 'abstract_art.png', + subfolder: '', + type: 'output' + }, + { + filename: 'abstract_art_alt.png', + subfolder: '', + type: 'output' + } + ] + } + } +} + +const cloudTest = test.extend<{ mockCloudAssetSidebarData: void }>({ + mockCloudAssetSidebarData: async ({ comfyPage }, use) => { + await comfyPage.assets.mockOutputHistory(SAMPLE_JOBS) + await comfyPage.assets.mockEmptyCloudAssets() + + await use() + + await comfyPage.assets.clearMocks() + } +}) + // ========================================================================== // 1. Empty states // ========================================================================== @@ -633,6 +667,96 @@ test.describe('Assets sidebar - bulk actions', () => { }) }) +cloudTest.describe('Assets sidebar - cloud exports', { tag: '@cloud' }, () => { + cloudTest( + 'Single job selection uses preserve naming strategy', + async ({ comfyPage, mockCloudAssetSidebarData }) => { + void mockCloudAssetSidebarData + const exportRequests = await comfyPage.assets.captureAssetExportRequests() + + const tab = comfyPage.menu.assetsTab + await tab.open() + await tab.waitForAssets() + + await tab.assetCards.first().click() + await expect(tab.downloadSelectedButton).toBeVisible() + + await tab.downloadSelectedButton.click() + + await expect.poll(() => exportRequests).toHaveLength(1) + + const payload = exportRequests[0] + expect(payload.job_ids).toEqual(['job-gamma']) + expect(payload.job_asset_name_filters).toBeUndefined() + expect(payload.naming_strategy).toBe('preserve') + } + ) + + cloudTest( + 'Multiple selected assets from one job use preserve naming strategy', + async ({ comfyPage, mockCloudAssetSidebarData }) => { + void mockCloudAssetSidebarData + const exportRequests = await comfyPage.assets.captureAssetExportRequests() + await comfyPage.assets.mockJobDetail('job-gamma', JOB_GAMMA_DETAIL) + + const tab = comfyPage.menu.assetsTab + await tab.open() + await tab.waitForAssets() + + await tab.assetCards + .first() + .getByRole('button', { name: 'See more outputs' }) + .click() + await expect(tab.backToAssetsButton).toBeVisible() + await expect.poll(() => tab.assetCards.count()).toBe(2) + + await tab.assetCards.first().click() + await comfyPage.page.keyboard.down('Control') + await tab.assetCards.nth(1).click() + await comfyPage.page.keyboard.up('Control') + + await expect(tab.selectedCards).toHaveCount(2) + await tab.downloadSelectedButton.click() + + await expect.poll(() => exportRequests).toHaveLength(1) + + const payload = exportRequests[0] + expect(payload.job_ids).toEqual(['job-gamma']) + expect(payload.job_asset_name_filters?.['job-gamma']?.toSorted()).toEqual( + ['abstract_art.png', 'abstract_art_alt.png'] + ) + expect(payload.naming_strategy).toBe('preserve') + } + ) + + cloudTest( + 'Multiple selected jobs use job-time naming strategy', + async ({ comfyPage, mockCloudAssetSidebarData }) => { + void mockCloudAssetSidebarData + const exportRequests = await comfyPage.assets.captureAssetExportRequests() + + const tab = comfyPage.menu.assetsTab + await tab.open() + await tab.waitForAssets() + + await tab.assetCards.nth(1).click() + await comfyPage.page.keyboard.down('Control') + await tab.assetCards.nth(2).click() + await comfyPage.page.keyboard.up('Control') + + await expect(tab.selectedCards).toHaveCount(2) + await tab.downloadSelectedButton.click() + + await expect.poll(() => exportRequests).toHaveLength(1) + + const payload = exportRequests[0] + expect(payload.job_ids?.toSorted()).toEqual(['job-alpha', 'job-beta']) + expect(payload.job_asset_name_filters).toBeUndefined() + expect(payload.naming_strategy).toBe('group_by_job_time') + } + ) +}) + // ========================================================================== // 9. Pagination // ========================================================================== @@ -888,3 +1012,42 @@ test.describe('Assets sidebar - media type filter', () => { await expect(tab.assetCards).toHaveCount(initialCount, { timeout: 5000 }) }) }) + +test.describe('Assets sidebar - drag and drop', () => { + test('Dragging outputs from assets skips upload', async ({ comfyPage }) => { + await comfyPage.assets.mockOutputHistory([ + createMockJob({ + id: 'job', + preview_output: { + filename: `test.png`, + type: 'temp', + nodeId: '1', + mediaType: 'images' + } + }) + ]) + await comfyPage.page.route('**/upload/image', (route) => { + expect(true, 'file is not uploaded').toBe(false) + return route.fulfill({ status: 405 }) + }) + + await comfyPage.workflow.loadWorkflow('widgets/load_image_widget') + + await comfyPage.canvas.focus() + await comfyPage.page.keyboard.press('.') + const { assetsTab } = comfyPage.menu + await assetsTab.open() + await assetsTab.waitForAssets() + await expect(assetsTab.assetCards).toHaveCount(1) + + const targetPosition = + (await comfyPage.canvasOps.getNodeCenterByTitle('Load Image')) ?? + undefined + + await assetsTab.assetCards.dragTo(comfyPage.canvas, { targetPosition }) + + const nodes = await comfyPage.nodeOps.getNodeRefsByType('LoadImage') + const fileComboWidget = await nodes[0].getWidget(0) + await expect.poll(() => fileComboWidget.getValue()).toBe('test.png [temp]') + }) +}) diff --git a/browser_tests/tests/sidebar/workflows.spec.ts b/browser_tests/tests/sidebar/workflows.spec.ts index 120907bfb1..b1696872c1 100644 --- a/browser_tests/tests/sidebar/workflows.spec.ts +++ b/browser_tests/tests/sidebar/workflows.spec.ts @@ -2,7 +2,7 @@ import { expect } from '@playwright/test' import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage' import { TestIds } from '@e2e/fixtures/selectors' -import { openErrorsTab } from '@e2e/tests/propertiesPanel/ErrorsTabHelper' +import { openErrorsTab } from '@e2e/fixtures/helpers/ErrorsTabHelper' test.describe('Workflows sidebar', () => { test.beforeEach(async ({ comfyPage }) => { diff --git a/browser_tests/tests/subgraph/subgraphLifecycle.spec.ts b/browser_tests/tests/subgraph/subgraphLifecycle.spec.ts index fb2596abd6..14fe3da400 100644 --- a/browser_tests/tests/subgraph/subgraphLifecycle.spec.ts +++ b/browser_tests/tests/subgraph/subgraphLifecycle.spec.ts @@ -2,7 +2,7 @@ import { expect } from '@playwright/test' import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage' import { TestIds } from '@e2e/fixtures/selectors' -import { getPseudoPreviewWidgets } from '@e2e/helpers/promotedWidgets' +import { getPseudoPreviewWidgets } from '@e2e/fixtures/utils/promotedWidgets' const domPreviewSelector = '.image-preview' diff --git a/browser_tests/tests/subgraph/subgraphNested.spec.ts b/browser_tests/tests/subgraph/subgraphNested.spec.ts index 41323f3577..550534f8f1 100644 --- a/browser_tests/tests/subgraph/subgraphNested.spec.ts +++ b/browser_tests/tests/subgraph/subgraphNested.spec.ts @@ -3,6 +3,7 @@ import { expect } from '@playwright/test' import { comfyPageFixture as test, comfyExpect } from '@e2e/fixtures/ComfyPage' import { SubgraphHelper } from '@e2e/fixtures/helpers/SubgraphHelper' import { TestIds } from '@e2e/fixtures/selectors' +import { getPromotedWidgets } from '@e2e/fixtures/utils/promotedWidgets' test.describe('Nested Subgraphs', { tag: ['@subgraph'] }, () => { test.describe('Nested subgraph configure order', () => { @@ -190,4 +191,106 @@ test.describe('Nested Subgraphs', { tag: ['@subgraph'] }, () => { }) } ) + + test.describe( + 'Nested subgraph input target resolution', + { tag: ['@widget', '@vue-nodes'] }, + () => { + const WORKFLOW = 'subgraphs/subgraph-nested-promotion' + const OUTER_NODE_ID = '5' + const INNER_SUBGRAPH_NODE_ID = '6' + + test('Nested SubgraphNode promoted widgets render without resolution failures', async ({ + comfyPage + }) => { + const { warnings, dispose } = SubgraphHelper.collectConsoleWarnings( + comfyPage.page, + ['No link found', 'Failed to resolve legacy -1'] + ) + + try { + await comfyPage.workflow.loadWorkflow(WORKFLOW) + await comfyPage.vueNodes.waitForNodes() + + const outerNode = comfyPage.vueNodes.getNodeLocator(OUTER_NODE_ID) + await comfyExpect(outerNode).toBeVisible() + + const widgets = outerNode.getByTestId(TestIds.widgets.widget) + await comfyExpect( + widgets, + 'asset has 4 promoted widgets on outer subgraph node' + ).toHaveCount(4) + + expect(warnings).toEqual([]) + } finally { + dispose() + } + }) + + test('Promoted widgets from inner SubgraphNode are visible with correct values', async ({ + comfyPage + }) => { + await comfyPage.workflow.loadWorkflow(WORKFLOW) + await comfyPage.vueNodes.waitForNodes() + + const outerNode = comfyPage.vueNodes.getNodeLocator(OUTER_NODE_ID) + await comfyExpect(outerNode).toBeVisible() + + const widgets = outerNode.getByTestId(TestIds.widgets.widget) + await comfyExpect(widgets).toHaveCount(4) + + const valueWidget = outerNode + .getByRole('textbox', { name: 'value' }) + .first() + await comfyExpect(valueWidget).toBeVisible() + await comfyExpect(valueWidget).toHaveValue(/Inner 1/) + }) + + test('Promoted widgets from inner SubgraphNode carry correct source identity', async ({ + comfyPage + }) => { + await comfyPage.workflow.loadWorkflow(WORKFLOW) + await comfyPage.vueNodes.waitForNodes() + + await expect + .poll(async () => { + const widgets = await getPromotedWidgets(comfyPage, OUTER_NODE_ID) + return widgets + .filter( + ([sourceNodeId]) => sourceNodeId === INNER_SUBGRAPH_NODE_ID + ) + .map(([, sourceWidgetName]) => sourceWidgetName) + }) + .toContain('value') + }) + + test('Serialize and reload preserves nested promoted widget visibility', async ({ + comfyPage + }) => { + await comfyPage.workflow.loadWorkflow(WORKFLOW) + await comfyPage.vueNodes.waitForNodes() + + const outerNode = comfyPage.vueNodes.getNodeLocator(OUTER_NODE_ID) + const widgets = outerNode.getByTestId(TestIds.widgets.widget) + await comfyExpect( + widgets, + 'asset has 4 promoted widgets on outer subgraph node' + ).toHaveCount(4) + const initialCount = await widgets.count() + + await comfyPage.subgraph.serializeAndReload() + await comfyPage.vueNodes.waitForNodes() + + const outerNodeAfter = comfyPage.vueNodes.getNodeLocator(OUTER_NODE_ID) + const widgetsAfter = outerNodeAfter.getByTestId(TestIds.widgets.widget) + await comfyExpect(widgetsAfter).toHaveCount(initialCount) + + const valueWidget = outerNodeAfter + .getByRole('textbox', { name: 'value' }) + .first() + await comfyExpect(valueWidget).toBeVisible() + await comfyExpect(valueWidget).toHaveValue(/Inner 1/) + }) + } + ) }) diff --git a/browser_tests/tests/subgraph/subgraphPromotion.spec.ts b/browser_tests/tests/subgraph/subgraphPromotion.spec.ts index a00a674007..91657e4d50 100644 --- a/browser_tests/tests/subgraph/subgraphPromotion.spec.ts +++ b/browser_tests/tests/subgraph/subgraphPromotion.spec.ts @@ -3,11 +3,11 @@ import { expect } from '@playwright/test' import type { ComfyPage } from '@e2e/fixtures/ComfyPage' import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage' import { TestIds } from '@e2e/fixtures/selectors' -import { fitToViewInstant } from '@e2e/helpers/fitToView' +import { fitToViewInstant } from '@e2e/fixtures/utils/fitToView' import { getPromotedWidgetNames, getPromotedWidgetCount -} from '@e2e/helpers/promotedWidgets' +} from '@e2e/fixtures/utils/promotedWidgets' async function expectPromotedWidgetNamesToContain( comfyPage: ComfyPage, diff --git a/browser_tests/tests/subgraph/subgraphPromotionDom.spec.ts b/browser_tests/tests/subgraph/subgraphPromotionDom.spec.ts index 4ace589ffc..7ac09b5737 100644 --- a/browser_tests/tests/subgraph/subgraphPromotionDom.spec.ts +++ b/browser_tests/tests/subgraph/subgraphPromotionDom.spec.ts @@ -3,7 +3,7 @@ import { expect } from '@playwright/test' import type { ComfyPage } from '@e2e/fixtures/ComfyPage' import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage' import { SubgraphHelper } from '@e2e/fixtures/helpers/SubgraphHelper' -import { getPromotedWidgetNames } from '@e2e/helpers/promotedWidgets' +import { getPromotedWidgetNames } from '@e2e/fixtures/utils/promotedWidgets' const DOM_WIDGET_SELECTOR = '.comfy-multiline-input' const VISIBLE_DOM_WIDGET_SELECTOR = `${DOM_WIDGET_SELECTOR}:visible` diff --git a/browser_tests/tests/subgraph/subgraphSerialization.spec.ts b/browser_tests/tests/subgraph/subgraphSerialization.spec.ts index 52def911a6..c843317764 100644 --- a/browser_tests/tests/subgraph/subgraphSerialization.spec.ts +++ b/browser_tests/tests/subgraph/subgraphSerialization.spec.ts @@ -1,39 +1,46 @@ import { expect } from '@playwright/test' import type { ComfyPage } from '@e2e/fixtures/ComfyPage' -import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage' -import { getPromotedWidgets } from '@e2e/helpers/promotedWidgets' +import { comfyExpect, comfyPageFixture as test } from '@e2e/fixtures/ComfyPage' +import { SubgraphHelper } from '@e2e/fixtures/helpers/SubgraphHelper' +import { TestIds } from '@e2e/fixtures/selectors' +import type { PromotedWidgetEntry } from '@e2e/fixtures/utils/promotedWidgets' +import { + getPromotedWidgetCount, + getPromotedWidgetNames, + getPromotedWidgets +} from '@e2e/fixtures/utils/promotedWidgets' const DUPLICATE_IDS_WORKFLOW = 'subgraphs/subgraph-nested-duplicate-ids' const LEGACY_PREFIXED_WORKFLOW = 'subgraphs/nested-subgraph-legacy-prefixed-proxy-widgets' -test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => { - const getPromotedHostWidgetValues = async ( - comfyPage: ComfyPage, - nodeIds: string[] - ) => { - return comfyPage.page.evaluate((ids) => { - const graph = window.app!.canvas.graph! +async function expectPromotedWidgetsToResolveToInteriorNodes( + comfyPage: ComfyPage, + hostSubgraphNodeId: string, + widgets: PromotedWidgetEntry[] +) { + expect(widgets.length).toBeGreaterThan(0) + + const interiorNodeIds = widgets.map(([id]) => id) + const results = await comfyPage.page.evaluate( + ([hostId, ids]) => { + const graph = window.app!.graph! + const hostNode = graph.getNodeById(Number(hostId)) + if (!hostNode?.isSubgraphNode()) return ids.map(() => false) return ids.map((id) => { - const node = graph.getNodeById(id) - if ( - !node || - typeof node.isSubgraphNode !== 'function' || - !node.isSubgraphNode() - ) { - return { id, values: [] as unknown[] } - } - - return { - id, - values: (node.widgets ?? []).map((widget) => widget.value) - } + const interiorNode = hostNode.subgraph.getNodeById(Number(id)) + return interiorNode !== null && interiorNode !== undefined }) - }, nodeIds) - } + }, + [hostSubgraphNodeId, interiorNodeIds] as const + ) + expect(results).toEqual(widgets.map(() => true)) +} + +test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => { test('Promoted widget remains usable after serialize and reload', async ({ comfyPage }) => { @@ -86,54 +93,409 @@ test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => { await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(false) }) - test.describe('Legacy prefixed proxyWidget normalization', () => { - test.beforeEach(async ({ comfyPage }) => { - await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true) - }) - - test('Legacy-prefixed promoted widget renders with the normalized label after load', async ({ + test.describe('Deterministic proxyWidgets Hydrate', () => { + test('proxyWidgets entries map to real interior node IDs after load', async ({ comfyPage }) => { - await comfyPage.workflow.loadWorkflow(LEGACY_PREFIXED_WORKFLOW) - await comfyPage.vueNodes.waitForNodes() - - const outerNode = comfyPage.vueNodes.getNodeLocator('5') - await expect(outerNode).toBeVisible() - - const textarea = outerNode - .getByRole('textbox', { name: 'string_a' }) - .first() - await expect(textarea).toBeVisible() - await expect(textarea).toBeDisabled() - }) - - test('Multiple instances of the same subgraph keep distinct promoted widget values after load and reload', async ({ - comfyPage - }) => { - const workflowName = - 'subgraphs/subgraph-multi-instance-promoted-text-values' - const hostNodeIds = ['11', '12', '13'] - const expectedValues = ['Alpha\n', 'Beta\n', 'Gamma\n'] - - await comfyPage.workflow.loadWorkflow(workflowName) - - const initialValues = await getPromotedHostWidgetValues( - comfyPage, - hostNodeIds + await comfyPage.workflow.loadWorkflow( + 'subgraphs/subgraph-with-promoted-text-widget' ) - expect(initialValues.map(({ values }) => values[0])).toEqual( - expectedValues + + const widgets = await getPromotedWidgets(comfyPage, '11') + expect(widgets.length).toBeGreaterThan(0) + + for (const [interiorNodeId] of widgets) { + expect(Number(interiorNodeId)).toBeGreaterThan(0) + } + + await expectPromotedWidgetsToResolveToInteriorNodes( + comfyPage, + '11', + widgets + ) + }) + + test('proxyWidgets entries survive double round-trip without drift', async ({ + comfyPage + }) => { + await comfyPage.workflow.loadWorkflow( + 'subgraphs/subgraph-with-multiple-promoted-widgets' + ) + + const initialWidgets = await getPromotedWidgets(comfyPage, '11') + expect(initialWidgets.length).toBeGreaterThan(0) + await expectPromotedWidgetsToResolveToInteriorNodes( + comfyPage, + '11', + initialWidgets ) await comfyPage.subgraph.serializeAndReload() - const reloadedValues = await getPromotedHostWidgetValues( + const afterFirst = await getPromotedWidgets(comfyPage, '11') + await expectPromotedWidgetsToResolveToInteriorNodes( comfyPage, - hostNodeIds + '11', + afterFirst ) - expect(reloadedValues.map(({ values }) => values[0])).toEqual( - expectedValues + + await comfyPage.subgraph.serializeAndReload() + + const afterSecond = await getPromotedWidgets(comfyPage, '11') + await expectPromotedWidgetsToResolveToInteriorNodes( + comfyPage, + '11', + afterSecond + ) + + expect(afterFirst).toEqual(initialWidgets) + expect(afterSecond).toEqual(initialWidgets) + }) + + test('Compressed target_slot (-1) entries are hydrated to real IDs', async ({ + comfyPage + }) => { + await comfyPage.workflow.loadWorkflow( + 'subgraphs/subgraph-compressed-target-slot' + ) + + const widgets = await getPromotedWidgets(comfyPage, '2') + expect(widgets.length).toBeGreaterThan(0) + + for (const [interiorNodeId] of widgets) { + expect(interiorNodeId).not.toBe('-1') + expect(Number(interiorNodeId)).toBeGreaterThan(0) + } + + await expectPromotedWidgetsToResolveToInteriorNodes( + comfyPage, + '2', + widgets ) }) }) + + test.describe('Legacy And Round-Trip Coverage', () => { + let previousUseNewMenu: unknown + + test.beforeEach(async ({ comfyPage }) => { + previousUseNewMenu = + await comfyPage.settings.getSetting('Comfy.UseNewMenu') + await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled') + }) + + test.afterEach(async ({ comfyPage }) => { + await comfyPage.settings.setSetting( + 'Comfy.UseNewMenu', + previousUseNewMenu + ) + }) + + test('Legacy -1 proxyWidgets entries are hydrated to concrete interior node IDs', async ({ + comfyPage + }) => { + await comfyPage.workflow.loadWorkflow( + 'subgraphs/subgraph-compressed-target-slot' + ) + + const promotedWidgets = await getPromotedWidgets(comfyPage, '2') + expect(promotedWidgets.length).toBeGreaterThan(0) + expect( + promotedWidgets.some(([interiorNodeId]) => interiorNodeId === '-1') + ).toBe(false) + expect( + promotedWidgets.some( + ([interiorNodeId, widgetName]) => + interiorNodeId !== '-1' && widgetName === 'batch_size' + ) + ).toBe(true) + }) + + test('Promoted widgets survive serialize -> loadGraphData round-trip', async ({ + comfyPage + }) => { + await comfyPage.workflow.loadWorkflow( + 'subgraphs/subgraph-with-promoted-text-widget' + ) + + const beforePromoted = await getPromotedWidgetNames(comfyPage, '11') + expect(beforePromoted).toContain('text') + + await comfyPage.subgraph.serializeAndReload() + + const afterPromoted = await getPromotedWidgetNames(comfyPage, '11') + expect(afterPromoted).toContain('text') + + const widgetCount = await getPromotedWidgetCount(comfyPage, '11') + expect(widgetCount).toBeGreaterThan(0) + }) + + test('Multi-link input representative stays stable through save/reload', async ({ + comfyPage + }) => { + await comfyPage.workflow.loadWorkflow( + 'subgraphs/subgraph-with-multiple-promoted-widgets' + ) + + const beforeSnapshot = await getPromotedWidgets(comfyPage, '11') + expect(beforeSnapshot.length).toBeGreaterThan(0) + + await comfyPage.subgraph.serializeAndReload() + + const afterSnapshot = await getPromotedWidgets(comfyPage, '11') + expect(afterSnapshot).toEqual(beforeSnapshot) + }) + + test('Cloning a subgraph node keeps promoted widget entries on original and clone', async ({ + comfyPage + }) => { + await comfyPage.workflow.loadWorkflow( + 'subgraphs/subgraph-with-promoted-text-widget' + ) + + const originalNode = await comfyPage.nodeOps.getNodeRefById('11') + const originalPos = await originalNode.getPosition() + + await comfyPage.page.mouse.move(originalPos.x + 16, originalPos.y + 16) + await comfyPage.page.keyboard.down('Alt') + try { + await comfyPage.page.mouse.down() + await comfyPage.page.mouse.move(originalPos.x + 72, originalPos.y + 72) + await comfyPage.page.mouse.up() + } finally { + await comfyPage.page.keyboard.up('Alt') + } + + async function collectSubgraphNodeIds() { + return comfyPage.page.evaluate(() => { + const graph = window.app!.canvas.graph! + return graph.nodes + .filter( + (n) => + typeof n.isSubgraphNode === 'function' && n.isSubgraphNode() + ) + .map((n) => String(n.id)) + }) + } + + await expect + .poll(async () => (await collectSubgraphNodeIds()).length) + .toBeGreaterThan(1) + + const subgraphNodeIds = await collectSubgraphNodeIds() + for (const nodeId of subgraphNodeIds) { + const promotedWidgets = await getPromotedWidgets(comfyPage, nodeId) + expect(promotedWidgets.length).toBeGreaterThan(0) + expect( + promotedWidgets.some(([, widgetName]) => widgetName === 'text') + ).toBe(true) + } + }) + }) + + test.describe('Duplicate ID Remapping', () => { + test('All node IDs are globally unique after loading', async ({ + comfyPage + }) => { + await comfyPage.workflow.loadWorkflow(DUPLICATE_IDS_WORKFLOW) + + const result = await comfyPage.page.evaluate(() => { + const graph = window.app!.canvas.graph! + const allGraphs = [graph, ...graph.subgraphs.values()] + const allIds = allGraphs + .flatMap((g) => g._nodes) + .map((n) => n.id) + .filter((id): id is number => typeof id === 'number') + + return { allIds, uniqueCount: new Set(allIds).size } + }) + + expect(result.uniqueCount).toBe(result.allIds.length) + expect(result.allIds.length).toBeGreaterThanOrEqual(10) + }) + + test('Root graph node IDs are preserved as canonical', async ({ + comfyPage + }) => { + await comfyPage.workflow.loadWorkflow(DUPLICATE_IDS_WORKFLOW) + + const rootIds = await comfyPage.page.evaluate(() => { + const graph = window.app!.canvas.graph! + return graph._nodes + .map((n) => n.id) + .filter((id): id is number => typeof id === 'number') + .sort((a, b) => a - b) + }) + + expect(rootIds).toEqual([1, 2, 5]) + }) + + test('Promoted widget tuples are stable after full page reload boot path', async ({ + comfyPage + }) => { + await comfyPage.workflow.loadWorkflow(DUPLICATE_IDS_WORKFLOW) + + const beforeSnapshot = + await comfyPage.subgraph.getHostPromotedTupleSnapshot() + expect(beforeSnapshot.length).toBeGreaterThan(0) + expect( + beforeSnapshot.some(({ promotedWidgets }) => promotedWidgets.length > 0) + ).toBe(true) + + await comfyPage.page.reload() + await comfyPage.page.waitForFunction(() => !!window.app) + await comfyPage.workflow.loadWorkflow(DUPLICATE_IDS_WORKFLOW) + + await expect + .poll(() => comfyPage.subgraph.getHostPromotedTupleSnapshot(), { + timeout: 5_000 + }) + .toEqual(beforeSnapshot) + }) + + test('All links reference valid nodes in their graph', async ({ + comfyPage + }) => { + await comfyPage.workflow.loadWorkflow(DUPLICATE_IDS_WORKFLOW) + + const invalidLinks = await comfyPage.page.evaluate(() => { + const graph = window.app!.canvas.graph! + const labeledGraphs: [string, typeof graph][] = [ + ['root', graph], + ...[...graph.subgraphs.entries()].map( + ([id, sg]) => [`subgraph:${id}`, sg] as [string, typeof graph] + ) + ] + + const SENTINEL_IDS = new Set([-1, -10, -20]) + const isSentinelNodeId = (id: number | string): id is number => + typeof id === 'number' && SENTINEL_IDS.has(id) + + const checkEndpoint = ( + label: string, + kind: 'origin_id' | 'target_id', + id: number | string, + g: typeof graph + ): string | null => { + if (isSentinelNodeId(id)) return null + if (typeof id !== 'number' || !g._nodes_by_id[id]) { + return `${label}: ${kind} ${id} invalid or not found` + } + return null + } + + return labeledGraphs.flatMap(([label, g]) => + [...g._links.values()].flatMap((link) => + [ + checkEndpoint(label, 'origin_id', link.origin_id, g), + checkEndpoint(label, 'target_id', link.target_id, g) + ].filter((e): e is string => e !== null) + ) + ) + }) + + expect(invalidLinks).toEqual([]) + }) + + test('Subgraph navigation works after ID remapping', async ({ + comfyPage + }) => { + await comfyPage.workflow.loadWorkflow(DUPLICATE_IDS_WORKFLOW) + + const subgraphNode = await comfyPage.nodeOps.getNodeRefById('5') + await subgraphNode.navigateIntoSubgraph() + + await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true) + + await comfyPage.keyboard.press('Escape') + + await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(false) + }) + }) + + /** + * Regression test for legacy-prefixed proxyWidget normalization. + * + * Older serialized workflows stored proxyWidget entries with prefixed widget + * names like "6: 3: string_a" instead of plain "string_a". This caused + * resolution failures during configure, resulting in missing promoted widgets. + * + * The fixture contains an outer SubgraphNode (id 5) whose proxyWidgets array + * has a legacy-prefixed entry: ["6", "6: 3: string_a"]. After normalization + * the promoted widget should render with the clean name "string_a". + * + * See: https://github.com/Comfy-Org/ComfyUI_frontend/pull/10573 + */ + test.describe( + 'Legacy Prefixed proxyWidget Normalization', + { tag: ['@subgraph', '@widget'] }, + () => { + let previousVueNodesEnabled: unknown + + test.beforeEach(async ({ comfyPage }) => { + previousVueNodesEnabled = await comfyPage.settings.getSetting( + 'Comfy.VueNodes.Enabled' + ) + await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true) + }) + + test.afterEach(async ({ comfyPage }) => { + await comfyPage.settings.setSetting( + 'Comfy.VueNodes.Enabled', + previousVueNodesEnabled + ) + }) + + test('Loads without console warnings about failed widget resolution', async ({ + comfyPage + }) => { + const { warnings, dispose } = SubgraphHelper.collectConsoleWarnings( + comfyPage.page + ) + + try { + await comfyPage.workflow.loadWorkflow(LEGACY_PREFIXED_WORKFLOW) + + comfyExpect(warnings).toEqual([]) + } finally { + dispose() + } + }) + + test('Legacy-prefixed promoted widget renders with the normalized label after load', async ({ + comfyPage + }) => { + await comfyPage.workflow.loadWorkflow(LEGACY_PREFIXED_WORKFLOW) + await comfyPage.vueNodes.waitForNodes() + + const outerNode = comfyPage.vueNodes.getNodeLocator('5') + await expect(outerNode).toBeVisible() + + const textarea = outerNode + .getByRole('textbox', { name: 'string_a' }) + .first() + await expect(textarea).toBeVisible() + await expect(textarea).toBeDisabled() + }) + + test('No legacy-prefixed or disconnected widgets remain on the node', async ({ + comfyPage + }) => { + await comfyPage.workflow.loadWorkflow(LEGACY_PREFIXED_WORKFLOW) + await comfyPage.vueNodes.waitForNodes() + + const outerNode = comfyPage.vueNodes.getNodeLocator('5') + await expect(outerNode).toBeVisible() + + const widgetRows = outerNode.getByTestId(TestIds.widgets.widget) + await expect(widgetRows).toHaveCount(2) + + for (const row of await widgetRows.all()) { + await expect( + row.getByLabel('string_a', { exact: true }) + ).toBeVisible() + } + }) + } + ) }) diff --git a/browser_tests/tests/topbarMenuCommands.spec.ts b/browser_tests/tests/topbarMenuCommands.spec.ts index 0714412a0b..46f04f5160 100644 --- a/browser_tests/tests/topbarMenuCommands.spec.ts +++ b/browser_tests/tests/topbarMenuCommands.spec.ts @@ -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 }) => { diff --git a/browser_tests/tests/vueNodes/groups/groups.spec.ts b/browser_tests/tests/vueNodes/groups/groups.spec.ts index 5d55b0b79d..e5b8d586e1 100644 --- a/browser_tests/tests/vueNodes/groups/groups.spec.ts +++ b/browser_tests/tests/vueNodes/groups/groups.spec.ts @@ -121,10 +121,7 @@ test.describe('Vue Node Groups', { tag: ['@screenshot', '@vue-nodes'] }, () => { await comfyPage.page.getByText('Load Checkpoint').click() await comfyPage.page.getByText('KSampler').click({ modifiers: ['Control'] }) await comfyPage.page.keyboard.press(CREATE_GROUP_HOTKEY) - await comfyPage.expectScreenshot( - comfyPage.canvas, - 'vue-groups-create-group.png' - ) + await expect(comfyPage.page.getByTestId('node-title-input')).toBeVisible() }) test('should allow fitting group to contents', async ({ comfyPage }) => { diff --git a/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts b/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts index 535a8d270a..260c240f1a 100644 --- a/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts +++ b/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts @@ -7,7 +7,7 @@ import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage' import { getMiddlePoint } from '@e2e/fixtures/utils/litegraphUtils' -import { fitToViewInstant } from '@e2e/helpers/fitToView' +import { fitToViewInstant } from '@e2e/fixtures/utils/fitToView' async function getCenter(locator: Locator): Promise<{ x: number; y: number }> { const box = await locator.boundingBox() diff --git a/browser_tests/tests/vueNodes/interactions/node/bringToFront.spec.ts b/browser_tests/tests/vueNodes/interactions/node/bringToFront.spec.ts index 7437a15a0b..b068da6cd2 100644 --- a/browser_tests/tests/vueNodes/interactions/node/bringToFront.spec.ts +++ b/browser_tests/tests/vueNodes/interactions/node/bringToFront.spec.ts @@ -3,7 +3,7 @@ import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage' import type { ComfyPage } from '@e2e/fixtures/ComfyPage' -import { fitToViewInstant } from '@e2e/helpers/fitToView' +import { fitToViewInstant } from '@e2e/fixtures/utils/fitToView' test.describe( 'Vue Node Bring to Front', diff --git a/browser_tests/tests/vueNodes/interactions/node/contextMenu.spec.ts b/browser_tests/tests/vueNodes/interactions/node/contextMenu.spec.ts index 26c52d8554..03ea41097d 100644 --- a/browser_tests/tests/vueNodes/interactions/node/contextMenu.spec.ts +++ b/browser_tests/tests/vueNodes/interactions/node/contextMenu.spec.ts @@ -66,10 +66,8 @@ test.describe('Vue Node Context Menu', { tag: '@vue-nodes' }, () => { await openContextMenu(comfyPage, 'KSampler') await clickExactMenuItem(comfyPage, 'Rename') - const titleInput = comfyPage.page.getByTestId(TestIds.node.titleInput) - await titleInput.waitFor({ state: 'visible' }) - await titleInput.fill('My Renamed Sampler') - await titleInput.press('Enter') + await comfyPage.titleEditor.expectVisible() + await comfyPage.titleEditor.setTitle('My Renamed Sampler') await comfyPage.nextFrame() const renamedNode = diff --git a/browser_tests/tests/vueNodes/interactions/node/imagePreview.spec.ts b/browser_tests/tests/vueNodes/interactions/node/imagePreview.spec.ts index 1a47881014..0f22d70569 100644 --- a/browser_tests/tests/vueNodes/interactions/node/imagePreview.spec.ts +++ b/browser_tests/tests/vueNodes/interactions/node/imagePreview.spec.ts @@ -5,7 +5,7 @@ import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage' import { getPromotedWidgetNames, getPromotedWidgetCountByName -} from '@e2e/helpers/promotedWidgets' +} from '@e2e/fixtures/utils/promotedWidgets' test.describe('Vue Nodes Image Preview', { tag: '@vue-nodes' }, () => { async function loadImageOnNode(comfyPage: ComfyPage) { diff --git a/browser_tests/tests/vueNodes/interactions/node/imagePreview.spec.ts-snapshots/vue-node-multiple-promoted-previews-chromium-linux.png b/browser_tests/tests/vueNodes/interactions/node/imagePreview.spec.ts-snapshots/vue-node-multiple-promoted-previews-chromium-linux.png index 8c1f0fb75b..d9de686020 100644 Binary files a/browser_tests/tests/vueNodes/interactions/node/imagePreview.spec.ts-snapshots/vue-node-multiple-promoted-previews-chromium-linux.png and b/browser_tests/tests/vueNodes/interactions/node/imagePreview.spec.ts-snapshots/vue-node-multiple-promoted-previews-chromium-linux.png differ diff --git a/browser_tests/tests/vueNodes/interactions/node/rename.spec.ts b/browser_tests/tests/vueNodes/interactions/node/rename.spec.ts index 4e1b39e5c7..6fa4e6e301 100644 --- a/browser_tests/tests/vueNodes/interactions/node/rename.spec.ts +++ b/browser_tests/tests/vueNodes/interactions/node/rename.spec.ts @@ -2,7 +2,6 @@ import { comfyExpect as expect, comfyPageFixture as test } from '@e2e/fixtures/ComfyPage' -import { TestIds } from '@e2e/fixtures/selectors' test.describe('Vue Nodes Renaming', { tag: '@vue-nodes' }, () => { test('should display node title', async ({ comfyPage }) => { @@ -22,8 +21,8 @@ test.describe('Vue Nodes Renaming', { tag: '@vue-nodes' }, () => { // Test cancel with Escape await vueNode.title.dblclick() await comfyPage.nextFrame() - await vueNode.titleInput.fill('This Should Be Cancelled') - await vueNode.titleInput.press('Escape') + await vueNode.titleEditor.input.fill('This Should Be Cancelled') + await vueNode.titleEditor.cancel() await comfyPage.nextFrame() // Title should remain as the previously saved value @@ -40,9 +39,6 @@ test.describe('Vue Nodes Renaming', { tag: '@vue-nodes' }, () => { if (!nodeBbox) throw new Error('Node not found') await loadCheckpointNode.dblclick() - const editingTitleInput = comfyPage.page.getByTestId( - TestIds.node.titleInput - ) - await expect(editingTitleInput).toBeHidden() + await comfyPage.titleEditor.expectHidden() }) }) diff --git a/browser_tests/tests/vueNodes/nodeStates/error.spec.ts b/browser_tests/tests/vueNodes/nodeStates/error.spec.ts index f012532963..926d5e2594 100644 --- a/browser_tests/tests/vueNodes/nodeStates/error.spec.ts +++ b/browser_tests/tests/vueNodes/nodeStates/error.spec.ts @@ -1,9 +1,25 @@ +import { mergeTests } from '@playwright/test' + import { comfyExpect as expect, - comfyPageFixture as test + comfyPageFixture } from '@e2e/fixtures/ComfyPage' +import { + cleanupFakeModel, + dismissErrorOverlay, + enableErrorsOverlay +} from '@e2e/fixtures/helpers/ErrorsTabHelper' +import { + ExecutionHelper, + buildKSamplerError +} from '@e2e/fixtures/helpers/ExecutionHelper' +import { webSocketFixture } from '@e2e/fixtures/ws' + +const test = mergeTests(comfyPageFixture, webSocketFixture) const ERROR_CLASS = /ring-destructive-background/ +const UNKNOWN_NODE_ID = '1' +const INNER_EXECUTION_ID = '2:1' test.describe('Vue Node Error', { tag: '@vue-nodes' }, () => { test('should display error state when node is missing (node from workflow is not installed)', async ({ @@ -11,24 +27,202 @@ test.describe('Vue Node Error', { tag: '@vue-nodes' }, () => { }) => { await comfyPage.workflow.loadWorkflow('missing/missing_nodes') - // Expect error state on missing unknown node - const unknownNode = comfyPage.page - .locator('[data-node-id]') - .filter({ hasText: 'UNKNOWN NODE' }) - .getByTestId('node-inner-wrapper') - await expect(unknownNode).toHaveClass(ERROR_CLASS) + await expect( + comfyPage.vueNodes.getNodeInnerWrapper(UNKNOWN_NODE_ID) + ).toHaveClass(ERROR_CLASS) }) test('should display error state when node causes execution error', async ({ comfyPage }) => { await comfyPage.workflow.loadWorkflow('nodes/execution_error') + const raiseErrorId = + await comfyPage.vueNodes.getNodeIdByTitle('Raise Error') await comfyPage.runButton.click() - const raiseErrorNode = comfyPage.page - .locator('[data-node-id]') - .filter({ hasText: 'Raise Error' }) - .getByTestId('node-inner-wrapper') - await expect(raiseErrorNode).toHaveClass(ERROR_CLASS) + await expect( + comfyPage.vueNodes.getNodeInnerWrapper(raiseErrorId) + ).toHaveClass(ERROR_CLASS) + }) + + test.describe('validation errors', () => { + test.beforeEach(async ({ comfyPage }) => { + await enableErrorsOverlay(comfyPage) + await comfyPage.workflow.loadWorkflow('nodes/single_ksampler') + }) + + test('shows error ring when a validation error is returned for a node', async ({ + comfyPage + }) => { + const ksamplerId = await comfyPage.vueNodes.getNodeIdByTitle('KSampler') + const exec = new ExecutionHelper(comfyPage) + await exec.mockValidationFailure({ + [ksamplerId]: buildKSamplerError( + 'value_bigger_than_max', + 'steps', + 'steps: 99999 is bigger than max 10000' + ) + }) + + await comfyPage.runButton.click() + + await expect( + comfyPage.vueNodes.getNodeInnerWrapper(ksamplerId) + ).toHaveClass(ERROR_CLASS) + }) + + test('clears error ring when user edits an out-of-range number widget back into range', async ({ + comfyPage + }) => { + const ksamplerId = await comfyPage.vueNodes.getNodeIdByTitle('KSampler') + const innerWrapper = comfyPage.vueNodes.getNodeInnerWrapper(ksamplerId) + const exec = new ExecutionHelper(comfyPage) + + await test.step('queue with out-of-range steps to surface the error', async () => { + await exec.mockValidationFailure({ + [ksamplerId]: buildKSamplerError( + 'value_bigger_than_max', + 'steps', + 'steps: 99999 is bigger than max 10000' + ) + }) + await comfyPage.runButton.click() + await dismissErrorOverlay(comfyPage) + await expect(innerWrapper).toHaveClass(ERROR_CLASS) + }) + + await test.step('edit steps widget so the new value is within range', async () => { + const stepsWidget = comfyPage.vueNodes.getWidgetByName( + 'KSampler', + 'steps' + ) + const controls = comfyPage.vueNodes.getInputNumberControls(stepsWidget) + // ScrubableNumberInput commits on blur — explicit blur avoids a race + // with the keyup-Enter handler in case Enter is consumed elsewhere. + await controls.input.fill('25') + await controls.input.blur() + }) + + await expect(innerWrapper).not.toHaveClass(ERROR_CLASS) + }) + + test('clears error ring when user picks a different combo option', async ({ + comfyPage + }) => { + const ksamplerId = await comfyPage.vueNodes.getNodeIdByTitle('KSampler') + const innerWrapper = comfyPage.vueNodes.getNodeInnerWrapper(ksamplerId) + const exec = new ExecutionHelper(comfyPage) + + await test.step('queue with invalid sampler to surface the error', async () => { + await exec.mockValidationFailure({ + [ksamplerId]: buildKSamplerError( + 'value_not_in_list', + 'sampler_name', + 'sampler_name: bogus_sampler is not in list' + ) + }) + await comfyPage.runButton.click() + await dismissErrorOverlay(comfyPage) + await expect(innerWrapper).toHaveClass(ERROR_CLASS) + }) + + await test.step('select a different sampler option', async () => { + await comfyPage.vueNodes.selectComboOption( + 'KSampler', + 'sampler_name', + 'dpmpp_2m' + ) + }) + + await expect(innerWrapper).not.toHaveClass(ERROR_CLASS) + }) + }) + + test.describe('subgraph propagation', { tag: '@subgraph' }, () => { + test.beforeEach(async ({ comfyPage }) => { + await enableErrorsOverlay(comfyPage) + await cleanupFakeModel(comfyPage) + }) + + test('parent subgraph node shows error ring when an interior node is missing', async ({ + comfyPage + }) => { + await comfyPage.workflow.loadWorkflow('missing/missing_nodes_in_subgraph') + const subgraphParentId = await comfyPage.vueNodes.getNodeIdByTitle( + 'Subgraph with Missing Node' + ) + + await expect( + comfyPage.vueNodes.getNodeInnerWrapper(subgraphParentId) + ).toHaveClass(ERROR_CLASS) + }) + + test('parent subgraph node shows error ring when an interior node has a missing model', async ({ + comfyPage + }) => { + await comfyPage.workflow.loadWorkflow( + 'missing/missing_models_in_subgraph' + ) + const subgraphParentId = await comfyPage.vueNodes.getNodeIdByTitle( + 'Subgraph with Missing Model' + ) + + await expect( + comfyPage.vueNodes.getNodeInnerWrapper(subgraphParentId) + ).toHaveClass(ERROR_CLASS) + }) + + test('parent subgraph node shows error ring when an interior node fails execution', async ({ + comfyPage, + getWebSocket + }) => { + await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph') + const subgraphParentId = + await comfyPage.vueNodes.getNodeIdByTitle('New Subgraph') + const innerWrapper = + comfyPage.vueNodes.getNodeInnerWrapper(subgraphParentId) + await expect( + innerWrapper, + 'subgraph parent must mount before injecting WS execution_error' + ).toBeVisible() + await expect(innerWrapper).not.toHaveClass(ERROR_CLASS) + + const ws = await getWebSocket() + const exec = new ExecutionHelper(comfyPage, ws) + exec.executionError( + 'mocked-prompt', + INNER_EXECUTION_ID, + 'boom inside the subgraph' + ) + + await expect(innerWrapper).toHaveClass(ERROR_CLASS) + }) + + test('parent subgraph node shows error ring when interior node has a validation error', async ({ + comfyPage + }) => { + // Validation errors are keyed by execution id, so an interior error + // ("2:1") must propagate the ring up to the root-level subgraph + // container ("2") via errorAncestorExecutionIds. + await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph') + const subgraphParentId = + await comfyPage.vueNodes.getNodeIdByTitle('New Subgraph') + const innerWrapper = + comfyPage.vueNodes.getNodeInnerWrapper(subgraphParentId) + await expect(innerWrapper).toBeVisible() + await expect(innerWrapper).not.toHaveClass(ERROR_CLASS) + + const exec = new ExecutionHelper(comfyPage) + await exec.mockValidationFailure({ + [INNER_EXECUTION_ID]: buildKSamplerError( + 'value_bigger_than_max', + 'steps', + 'steps: 99999 is bigger than max 10000' + ) + }) + await comfyPage.runButton.click() + + await expect(innerWrapper).toHaveClass(ERROR_CLASS) + }) }) }) diff --git a/browser_tests/tests/vueNodes/rerouteNodeSize.spec.ts-snapshots/vue-reroute-node-compact-chromium-linux.png b/browser_tests/tests/vueNodes/rerouteNodeSize.spec.ts-snapshots/vue-reroute-node-compact-chromium-linux.png index 7728efc923..5448252363 100644 Binary files a/browser_tests/tests/vueNodes/rerouteNodeSize.spec.ts-snapshots/vue-reroute-node-compact-chromium-linux.png and b/browser_tests/tests/vueNodes/rerouteNodeSize.spec.ts-snapshots/vue-reroute-node-compact-chromium-linux.png differ diff --git a/browser_tests/tests/vueNodes/widgets/advancedWidgets.spec.ts b/browser_tests/tests/vueNodes/widgets/advancedWidgets.spec.ts index b77b2a9beb..8acccfd63d 100644 --- a/browser_tests/tests/vueNodes/widgets/advancedWidgets.spec.ts +++ b/browser_tests/tests/vueNodes/widgets/advancedWidgets.spec.ts @@ -2,6 +2,10 @@ import { comfyExpect as expect, comfyPageFixture as test } from '@e2e/fixtures/ComfyPage' +import type { ComfyPage } from '@e2e/fixtures/ComfyPage' + +const SHOW_ADVANCED_INPUTS = 'Show advanced inputs' +const HIDE_ADVANCED_INPUTS = 'Hide advanced inputs' test.describe('Advanced Widget Visibility', { tag: '@vue-nodes' }, () => { test.beforeEach(async ({ comfyPage }) => { @@ -20,15 +24,11 @@ test.describe('Advanced Widget Visibility', { tag: '@vue-nodes' }, () => { await comfyPage.vueNodes.waitForNodes() }) - function getNode( - comfyPage: Parameters<Parameters<typeof test>[2]>[0]['comfyPage'] - ) { + function getNode(comfyPage: ComfyPage) { return comfyPage.vueNodes.getNodeByTitle('ModelSamplingFlux') } - function getWidgets( - comfyPage: Parameters<Parameters<typeof test>[2]>[0]['comfyPage'] - ) { + function getWidgets(comfyPage: ComfyPage) { return getNode(comfyPage).locator('.lg-node-widget') } @@ -46,7 +46,7 @@ test.describe('Advanced Widget Visibility', { tag: '@vue-nodes' }, () => { await expect(node.getByLabel('base_shift', { exact: true })).toBeHidden() // "Show advanced inputs" button should be present - await expect(node.getByText('Show advanced inputs')).toBeVisible() + await expect(node.getByText(SHOW_ADVANCED_INPUTS)).toBeVisible() }) test('should show advanced widgets when per-node toggle is clicked', async ({ @@ -58,20 +58,41 @@ test.describe('Advanced Widget Visibility', { tag: '@vue-nodes' }, () => { await expect(widgets).toHaveCount(2) // Click the toggle button to show advanced widgets - await node.getByText('Show advanced inputs').click() + await node.getByText(SHOW_ADVANCED_INPUTS).click() await expect(widgets).toHaveCount(4) await expect(node.getByLabel('max_shift', { exact: true })).toBeVisible() await expect(node.getByLabel('base_shift', { exact: true })).toBeVisible() // Button text should change to "Hide advanced inputs" - await expect(node.getByText('Hide advanced inputs')).toBeVisible() + await expect(node.getByText(HIDE_ADVANCED_INPUTS)).toBeVisible() // Click again to hide - await node.getByText('Hide advanced inputs').click() + await node.getByText(HIDE_ADVANCED_INPUTS).click() await expect(widgets).toHaveCount(2) }) + test('should hide advanced footer button while collapsed', async ({ + comfyPage + }) => { + const node = getNode(comfyPage) + const showAdvancedButton = node.getByText(SHOW_ADVANCED_INPUTS) + const vueNode = + await comfyPage.vueNodes.getFixtureByTitle('ModelSamplingFlux') + + await expect(showAdvancedButton).toBeVisible() + + await vueNode.toggleCollapse() + await comfyPage.nextFrame() + + await expect(showAdvancedButton).toBeHidden() + + await vueNode.toggleCollapse() + await comfyPage.nextFrame() + + await expect(showAdvancedButton).toBeVisible() + }) + test('should show advanced widgets when global setting is enabled', async ({ comfyPage }) => { @@ -92,6 +113,6 @@ test.describe('Advanced Widget Visibility', { tag: '@vue-nodes' }, () => { await expect(node.getByLabel('base_shift', { exact: true })).toBeVisible() // The toggle button should not be shown when global setting is active - await expect(node.getByText('Show advanced inputs')).toBeHidden() + await expect(node.getByText(SHOW_ADVANCED_INPUTS)).toBeHidden() }) }) diff --git a/browser_tests/tests/vueNodes/widgets/legacy.spec.ts b/browser_tests/tests/vueNodes/widgets/legacy.spec.ts new file mode 100644 index 0000000000..9b5ac3adf8 --- /dev/null +++ b/browser_tests/tests/vueNodes/widgets/legacy.spec.ts @@ -0,0 +1,50 @@ +import { + comfyPageFixture as test, + comfyExpect as expect +} from '@e2e/fixtures/ComfyPage' + +test('@vue-nodes In App Mode, widget width updates with panel size', async ({ + comfyPage, + comfyMouse +}) => { + await test.step('setup', async () => { + await comfyPage.nodeOps.addNode('DevToolsNodeWithLegacyWidget', undefined, { + x: 0, + y: 0 + }) + await comfyPage.appMode.enterAppModeWithInputs([['10', 'legacy_widget']]) + }) + + const getWidth = () => + comfyPage.page.evaluate( + () => graph!.getNodeById(10)!.widgets![0].width ?? 0 + ) + + await test.step('Mouse clicks resolve to button regions', async () => { + const legacyWidget = comfyPage.appMode.linearWidgets.locator('canvas') + const { width, height } = (await legacyWidget.boundingBox())! + + const nodeRef = await comfyPage.nodeOps.getNodeRefById(10) + const legacyWidgetRef = await nodeRef.getWidget(0) + expect(await legacyWidgetRef.getValue()).toBe(0) + await legacyWidget.click({ position: { x: 20, y: height / 2 } }) + await expect.poll(() => legacyWidgetRef.getValue()).toBe(-1) + await legacyWidget.click({ position: { x: width - 20, y: height / 2 } }) + await expect.poll(() => legacyWidgetRef.getValue()).toBe(0) + }) + + await test.step('Resize to update width', async () => { + const initialWidth = await getWidth() + expect(initialWidth).toBeGreaterThan(0) + + const gutter = comfyPage.page.getByRole('separator') + + await expect(gutter).toBeVisible() + await comfyMouse.resizeByDragging(gutter, { x: -200 }) + await expect.poll(getWidth).toBeGreaterThan(initialWidth) + const intermediateWidth = await getWidth() + + await comfyMouse.resizeByDragging(gutter, { x: 100 }) + await expect.poll(getWidth).toBeLessThan(intermediateWidth) + }) +}) diff --git a/browser_tests/tests/vueNodes/widgets/widgetBoundingBox.spec.ts b/browser_tests/tests/vueNodes/widgets/widgetBoundingBox.spec.ts new file mode 100644 index 0000000000..d1b59185f7 --- /dev/null +++ b/browser_tests/tests/vueNodes/widgets/widgetBoundingBox.spec.ts @@ -0,0 +1,185 @@ +import { + comfyExpect as expect, + comfyPageFixture as test +} from '@e2e/fixtures/ComfyPage' +import { WidgetBoundingBoxFixture } from '@e2e/fixtures/components/WidgetBoundingBox' + +const NODE_ID = '1' + +test.describe('Widget Bounding Box', { tag: ['@widget', '@vue-nodes'] }, () => { + test.beforeEach(async ({ comfyPage }) => { + await comfyPage.workflow.loadWorkflow('widgets/image_crop_widget') + }) + + test( + 'Renders all four coordinate inputs with workflow values', + { tag: '@smoke' }, + async ({ comfyPage }) => { + const node = comfyPage.vueNodes.getNodeLocator(NODE_ID) + const boundingBox = new WidgetBoundingBoxFixture(node) + + await expect(boundingBox.root).toBeVisible() + await expect(boundingBox.x.input).toHaveValue('0') + await expect(boundingBox.y.input).toHaveValue('0') + await expect(boundingBox.width.input).toHaveValue('512') + await expect(boundingBox.height.input).toHaveValue('512') + } + ) + + test('Typing into each coordinate updates only that coordinate', async ({ + comfyPage + }) => { + const node = comfyPage.vueNodes.getNodeLocator(NODE_ID) + const boundingBox = new WidgetBoundingBoxFixture(node) + + await test.step('type X', async () => { + await boundingBox.x.type(25) + await expect(boundingBox.x.input).toHaveValue('25') + await expect.soft(boundingBox.y.input).toHaveValue('0') + await expect.soft(boundingBox.width.input).toHaveValue('512') + await expect.soft(boundingBox.height.input).toHaveValue('512') + }) + + await test.step('type Y', async () => { + await boundingBox.y.type(40) + await expect(boundingBox.y.input).toHaveValue('40') + await expect.soft(boundingBox.x.input).toHaveValue('25') + await expect.soft(boundingBox.width.input).toHaveValue('512') + await expect.soft(boundingBox.height.input).toHaveValue('512') + }) + + await test.step('type Width', async () => { + await boundingBox.width.type(200) + await expect(boundingBox.width.input).toHaveValue('200') + await expect.soft(boundingBox.x.input).toHaveValue('25') + await expect.soft(boundingBox.y.input).toHaveValue('40') + await expect.soft(boundingBox.height.input).toHaveValue('512') + }) + + await test.step('type Height', async () => { + await boundingBox.height.type(300) + await expect(boundingBox.height.input).toHaveValue('300') + await expect.soft(boundingBox.x.input).toHaveValue('25') + await expect.soft(boundingBox.y.input).toHaveValue('40') + await expect.soft(boundingBox.width.input).toHaveValue('200') + }) + }) + + test('Negative X/Y values are clamped to min=0', async ({ comfyPage }) => { + const node = comfyPage.vueNodes.getNodeLocator(NODE_ID) + const boundingBox = new WidgetBoundingBoxFixture(node) + + await boundingBox.x.type(50) + await expect(boundingBox.x.input).toHaveValue('50') + await boundingBox.x.type('-10') + await expect(boundingBox.x.input).toHaveValue('0') + + await boundingBox.y.type(75) + await expect(boundingBox.y.input).toHaveValue('75') + await boundingBox.y.type('-50') + await expect(boundingBox.y.input).toHaveValue('0') + }) + + test('Width/Height values below 1 are clamped to min=1', async ({ + comfyPage + }) => { + const node = comfyPage.vueNodes.getNodeLocator(NODE_ID) + const boundingBox = new WidgetBoundingBoxFixture(node) + + await boundingBox.width.type(0) + await expect(boundingBox.width.input).toHaveValue('1') + + await boundingBox.height.type('-5') + await expect(boundingBox.height.input).toHaveValue('1') + }) + + test('Increment and decrement buttons change coordinate by step', async ({ + comfyPage + }) => { + const node = comfyPage.vueNodes.getNodeLocator(NODE_ID) + const boundingBox = new WidgetBoundingBoxFixture(node) + + await test.step('increment X from 0 to 2', async () => { + await boundingBox.x.increment() + await boundingBox.x.increment() + await expect(boundingBox.x.input).toHaveValue('2') + }) + + await test.step('decrement X from 2 to 1', async () => { + await boundingBox.x.decrement() + await expect(boundingBox.x.input).toHaveValue('1') + }) + + await test.step('decrement Width from 512 to 510', async () => { + await boundingBox.width.decrement() + await boundingBox.width.decrement() + await expect(boundingBox.width.input).toHaveValue('510') + }) + + await test.step('increment Height from 512 to 513', async () => { + await boundingBox.height.increment() + await expect(boundingBox.height.input).toHaveValue('513') + }) + }) + + test('Arrow keys step the focused input; PageUp/PageDown step by 10', async ({ + comfyPage + }) => { + const node = comfyPage.vueNodes.getNodeLocator(NODE_ID) + const boundingBox = new WidgetBoundingBoxFixture(node) + + await boundingBox.width.focus() + + await boundingBox.width.input.press('ArrowUp') + await expect(boundingBox.width.input).toHaveValue('513') + + await boundingBox.width.input.press('ArrowDown') + await boundingBox.width.input.press('ArrowDown') + await expect(boundingBox.width.input).toHaveValue('511') + + await boundingBox.width.input.press('PageUp') + await expect(boundingBox.width.input).toHaveValue('521') + + await boundingBox.width.input.press('PageDown') + await expect(boundingBox.width.input).toHaveValue('511') + }) + + test('Decrement button is disabled when value equals min', async ({ + comfyPage + }) => { + const node = comfyPage.vueNodes.getNodeLocator(NODE_ID) + const boundingBox = new WidgetBoundingBoxFixture(node) + + await test.step('X at 0 disables decrement', async () => { + await expect(boundingBox.x.input).toHaveValue('0') + await expect(boundingBox.x.decrementButton).toBeDisabled() + await expect(boundingBox.x.incrementButton).toBeEnabled() + }) + + await test.step('Width at 1 disables decrement', async () => { + await boundingBox.width.type(1) + await expect(boundingBox.width.input).toHaveValue('1') + await expect(boundingBox.width.decrementButton).toBeDisabled() + await expect(boundingBox.width.incrementButton).toBeEnabled() + }) + + await test.step('Incrementing X re-enables decrement', async () => { + await boundingBox.x.increment() + await expect(boundingBox.x.decrementButton).toBeEnabled() + }) + }) + + test('Non-numeric input reverts to previous value on blur', async ({ + comfyPage + }) => { + const node = comfyPage.vueNodes.getNodeLocator(NODE_ID) + const boundingBox = new WidgetBoundingBoxFixture(node) + + await boundingBox.x.type(42) + await expect(boundingBox.x.input).toHaveValue('42') + + await boundingBox.x.input.fill('not a number') + await boundingBox.x.input.blur() + await expect(boundingBox.x.input).toHaveValue('42') + }) +}) diff --git a/browser_tests/tests/workflowSettings.spec.ts b/browser_tests/tests/workflowSettings.spec.ts new file mode 100644 index 0000000000..190d8005f3 --- /dev/null +++ b/browser_tests/tests/workflowSettings.spec.ts @@ -0,0 +1,205 @@ +import type { Page, Request } from '@playwright/test' + +import type { + ComfyApiWorkflow, + NodeId +} from '@/platform/workflow/validation/schemas/workflowSchema' +import type { ComfyPage } from '@e2e/fixtures/ComfyPage' +import { + comfyExpect as expect, + comfyPageFixture as test +} from '@e2e/fixtures/ComfyPage' + +function isUserdataWorkflowSave(request: Request): boolean { + return ( + request.method() === 'POST' && + /\/api\/userdata\/workflows%2F[^?]+\.json/.test(request.url()) + ) +} + +function collectSaves(page: Page): Disposable & { readonly saves: string[] } { + const saves: string[] = [] + function onRequest(request: Request) { + if (isUserdataWorkflowSave(request)) saves.push(request.url()) + } + page.on('request', onRequest) + return { + saves, + [Symbol.dispose]() { + page.off('request', onRequest) + } + } +} + +async function waitForSave(page: Page, timeout: number): Promise<boolean> { + return page + .waitForRequest(isUserdataWorkflowSave, { timeout }) + .then(() => true) + .catch(() => false) +} + +/** + * Drag the first node so the change tracker dispatches `graphChanged`. + */ +async function triggerGraphChange(comfyPage: ComfyPage): Promise<void> { + const node = await comfyPage.nodeOps.getFirstNodeRef() + if (!node) throw new Error('Default workflow expected to have a first node') + const titlePos = await node.getTitlePosition() + const absFrom = await comfyPage.canvasOps.toAbsolute(titlePos) + const absTo = { x: absFrom.x + 120, y: absFrom.y + 120 } + await comfyPage.canvasOps.dragAndDrop(absFrom, absTo) + await expect + .poll(() => comfyPage.workflow.isCurrentWorkflowModified()) + .toBe(true) +} + +async function setupAutoSaveAfterDelay( + comfyPage: ComfyPage, + delayMs: number +): Promise<void> { + await comfyPage.menu.topbar.saveWorkflow('autosave') + await comfyPage.settings.setSetting('Comfy.Workflow.AutoSaveDelay', delayMs) + await comfyPage.settings.setSetting('Comfy.Workflow.AutoSave', 'after delay') +} + +test.describe('Workflow settings', { tag: '@canvas' }, () => { + test.describe('Comfy.Workflow.AutoSave', () => { + test.beforeEach(async ({ comfyPage }) => { + await comfyPage.workflow.setupWorkflowsDirectory({}) + await comfyPage.settings.setSetting('Comfy.Workflow.AutoSave', 'off') + }) + + test("'off' does not save modified workflow after delay", async ({ + comfyPage + }) => { + await comfyPage.menu.topbar.saveWorkflow('autosave') + await comfyPage.settings.setSetting('Comfy.Workflow.AutoSaveDelay', 50) + + await triggerGraphChange(comfyPage) + + // Within a window an order of magnitude longer than AutoSaveDelay, the + // off watcher must not write back. + const sawSave = await waitForSave(comfyPage.page, 500) + expect( + sawSave, + 'AutoSave=off must not write back after a graph change' + ).toBe(false) + }) + + test("'after delay' saves the workflow after a graph change", async ({ + comfyPage + }) => { + await setupAutoSaveAfterDelay(comfyPage, 100) + + const savePromise = comfyPage.page.waitForRequest( + isUserdataWorkflowSave, + { timeout: 4000 } + ) + await triggerGraphChange(comfyPage) + await savePromise + + await expect + .poll(() => comfyPage.workflow.isCurrentWorkflowModified()) + .toBe(false) + }) + }) + + test.describe('Comfy.Workflow.AutoSaveDelay', () => { + test.beforeEach(async ({ comfyPage }) => { + await comfyPage.workflow.setupWorkflowsDirectory({}) + await comfyPage.settings.setSetting('Comfy.Workflow.AutoSave', 'off') + }) + + test('long delay defers save until at least the configured duration has elapsed', async ({ + comfyPage + }) => { + const LONG_DELAY_MS = 1000 + const EARLY_WINDOW_MS = 500 + + await setupAutoSaveAfterDelay(comfyPage, LONG_DELAY_MS) + + using tracker = collectSaves(comfyPage.page) + + await triggerGraphChange(comfyPage) + + // No save fires within a window comfortably shorter than the delay. + const sawEarlySave = await waitForSave(comfyPage.page, EARLY_WINDOW_MS) + expect( + sawEarlySave, + `No save should fire within ${EARLY_WINDOW_MS}ms when the configured delay is ${LONG_DELAY_MS}ms` + ).toBe(false) + + // Eventually the save does fire. + await comfyPage.page.waitForRequest(isUserdataWorkflowSave, { + timeout: 3000 + }) + expect(tracker.saves).toHaveLength(1) + }) + }) + + test.describe('Comfy.Workflow.SortNodeIdOnSave', () => { + async function getSerializedNodeIds( + comfyPage: ComfyPage + ): Promise<NodeId[]> { + return (await comfyPage.workflow.getExportedWorkflow()).nodes.map( + (n) => n.id + ) + } + + function ascendingById(ids: NodeId[]): NodeId[] { + return [...ids].sort((a, b) => Number(a) - Number(b)) + } + + test('false preserves the graph insertion order', async ({ comfyPage }) => { + await comfyPage.workflow.loadWorkflow('default') + + await comfyPage.settings.setSetting( + 'Comfy.Workflow.SortNodeIdOnSave', + false + ) + const ids = await getSerializedNodeIds(comfyPage) + + expect(ids, 'default workflow nodes already sorted').not.toEqual( + ascendingById(ids) + ) + }) + + test('true sorts nodes by id ascending', async ({ comfyPage }) => { + await comfyPage.settings.setSetting( + 'Comfy.Workflow.SortNodeIdOnSave', + true + ) + const ids = await getSerializedNodeIds(comfyPage) + expect(ids).toEqual(ascendingById(ids)) + }) + + test('toggling sort preserves node set in both workflow JSON and API prompt', async ({ + comfyPage + }) => { + await comfyPage.settings.setSetting( + 'Comfy.Workflow.SortNodeIdOnSave', + false + ) + const expectedIds = ascendingById(await getSerializedNodeIds(comfyPage)) + + await comfyPage.settings.setSetting( + 'Comfy.Workflow.SortNodeIdOnSave', + true + ) + + // Workflow JSON nodes (the surface controlled by SortNodeIdOnSave) must + // still contain the same set of ids — sort changes order, not membership. + expect(ascendingById(await getSerializedNodeIds(comfyPage))).toEqual( + expectedIds + ) + + // The API prompt is independently derived from execution order, but it + // must enumerate the same node set regardless of the sort flag. + const apiPrompt: ComfyApiWorkflow = + await comfyPage.workflow.getExportedWorkflow({ api: true }) + expect(ascendingById(Object.keys(apiPrompt).map(Number))).toEqual( + expectedIds + ) + }) + }) +}) diff --git a/docs/testing/unit-testing.md b/docs/testing/unit-testing.md index e2da21875b..a47012ffa2 100644 --- a/docs/testing/unit-testing.md +++ b/docs/testing/unit-testing.md @@ -257,6 +257,8 @@ it('should validate node definition', () => { ## Mocking Composables with Reactive State +> **Don't mock `vue-i18n`.** Mount with a real `createI18n` plugin instance instead — see [Don't Mock `vue-i18n` in `vitest-patterns.md`](./vitest-patterns.md#dont-mock-vue-i18n--use-a-real-plugin). This section applies to composables you own. + When mocking composables that return reactive refs, define the mock implementation inline in `vi.mock()`'s factory function. This ensures stable singleton instances across all test invocations. ### Rules diff --git a/docs/testing/vitest-patterns.md b/docs/testing/vitest-patterns.md index 8ced7d9036..697a028838 100644 --- a/docs/testing/vitest-patterns.md +++ b/docs/testing/vitest-patterns.md @@ -30,9 +30,42 @@ describe('MyStore', () => { **Why `stubActions: false`?** By default, testing pinia stubs all actions. Set to `false` when testing actual store behavior. -## i18n in Component Tests +## Don't Mock `vue-i18n` — Use a Real Plugin -Use real `createI18n` with empty messages instead of mocking `vue-i18n`. See `SearchBox.test.ts` for example. +Mount with a real `createI18n` instance instead of mocking `vue-i18n`. The plugin is cheap, owned by a third party (don't mock what you don't own), and a real instance exercises the same translation key resolution and pluralization logic that production uses. + +This applies to **all tests** that touch a component or composable calling `useI18n()` — not just component tests. + +```typescript +import { createI18n } from 'vue-i18n' + +const i18n = createI18n({ + legacy: false, + locale: 'en', + messages: { en: {} } // empty — assertions key off the translation key, not the rendered string +}) + +// Component tests: pass via global plugins +mount(MyComponent, { global: { plugins: [i18n] } }) + +// Composable tests: provide via a host component (see useMediaAssetActions.test.ts pattern) +const app = createApp(HostComponent) +app.use(i18n) +``` + +Real example: [`src/components/searchbox/v2/__test__/testUtils.ts`](../../src/components/searchbox/v2/__test__/testUtils.ts) exports a shared `testI18n` instance. + +### Asserting on translation keys + +With empty messages, `t('foo.bar')` returns `'foo.bar'` (the key). Assert against the key directly — no need to mock `t`: + +```typescript +expect(toastSpy).toHaveBeenCalledWith( + expect.objectContaining({ detail: 'mediaAsset.selection.exportStarted' }) +) +``` + +For pluralization / interpolation arguments, spy on the consumer (e.g. the toast `add` fn) and inspect the captured payload, rather than spying on `t` itself. ## Mock Patterns diff --git a/eslint.config.ts b/eslint.config.ts index 50dffff9b7..e2b06e3d0a 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -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'], @@ -475,6 +500,11 @@ export default defineConfig([ { group: ['./**', '../**'], message: 'Use the @e2e/ path alias instead of relative imports.' + }, + { + group: ['@e2e/helpers', '@e2e/helpers/*'], + message: + 'browser_tests/helpers/ was removed. Use @e2e/fixtures/utils/, @e2e/fixtures/components/, or @e2e/fixtures/helpers/ instead.' } ] } @@ -493,6 +523,11 @@ export default defineConfig([ { group: ['./**', '../**'], message: 'Use the @e2e/ path alias instead of relative imports.' + }, + { + group: ['@e2e/helpers', '@e2e/helpers/*'], + message: + 'browser_tests/helpers/ was removed. Use @e2e/fixtures/utils/, @e2e/fixtures/components/, or @e2e/fixtures/helpers/ instead.' } ] } diff --git a/knip.config.ts b/knip.config.ts index 3ff8b597f9..b204587958 100644 --- a/knip.config.ts +++ b/knip.config.ts @@ -17,6 +17,9 @@ const config: KnipConfig = { entry: ['src/i18n.ts'], project: ['src/**/*.{js,ts,vue}'] }, + 'packages/design-system': { + project: ['src/**/*.{css,js,ts}'] + }, 'packages/tailwind-utils': { project: ['src/**/*.{js,ts}'] }, @@ -51,8 +54,13 @@ const config: KnipConfig = { '.github/workflows/ci-oss-assets-validation.yaml', // Pending integration in stacked PR 'src/components/sidebar/tabs/nodeLibrary/CustomNodesPanel.vue', + // Marketing media tooling — adopted by pages in a follow-up PR + 'apps/website/src/components/common/SiteVideo.vue', + 'apps/website/src/utils/marketingImage.ts', // Agent review check config, not part of the build - '.agents/checks/eslint.strict.config.js' + '.agents/checks/eslint.strict.config.js', + // Devtools extensions, included dynamically + 'tools/devtools/web/**' ], vite: { config: ['vite?(.*).config.mts'] diff --git a/package.json b/package.json index 224a567276..2748b0cb63 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@comfyorg/comfyui-frontend", - "version": "1.44.11", + "version": "1.44.15", "private": true, "description": "Official front-end implementation of ComfyUI", "homepage": "https://comfy.org", @@ -83,6 +83,7 @@ "@tiptap/extension-table-row": "catalog:", "@tiptap/pm": "catalog:", "@tiptap/starter-kit": "catalog:", + "@vee-validate/zod": "catalog:", "@vueuse/core": "catalog:", "@vueuse/integrations": "catalog:", "@vueuse/router": "^14.2.0", @@ -113,6 +114,7 @@ "three": "^0.170.0", "tiptap-markdown": "^0.8.10", "typegpu": "catalog:", + "vee-validate": "catalog:", "vue": "catalog:", "vue-i18n": "catalog:", "vue-router": "catalog:", diff --git a/packages/design-system/src/css/_palette.css b/packages/design-system/src/css/_palette.css index 9a2356aa39..3c7e697628 100644 --- a/packages/design-system/src/css/_palette.css +++ b/packages/design-system/src/css/_palette.css @@ -27,6 +27,23 @@ --color-smoke-700: #a0a0a0; --color-smoke-800: #8a8a8a; + /* Plum */ + --color-plum-300: #afa3db; + --color-plum-400: #8d7fc5; + --color-plum-500: #6b5ca8; + --color-plum-600: #49378b; + + /* Ink */ + --color-ink-100: #5c5362; + --color-ink-200: #4f4754; + --color-ink-300: #413b45; + --color-ink-400: #353139; + --color-ink-500: #312c34; + --color-ink-600: #29252c; + --color-ink-700: #232025; + --color-ink-800: #19161a; + --color-ink-900: #151317; + --color-white: #ffffff; --color-black: #000000; diff --git a/packages/design-system/src/css/style.css b/packages/design-system/src/css/style.css index 86e7d8298b..5106ed90e9 100644 --- a/packages/design-system/src/css/style.css +++ b/packages/design-system/src/css/style.css @@ -41,10 +41,6 @@ --color-sand-300: #888682; --color-sand-400: #eed7ac; - --color-slate-100: #9c9eab; - --color-slate-200: #9fa2bd; - --color-slate-300: #5b5e7d; - --color-azure-300: #78bae9; --color-azure-400: #31b9f4; --color-azure-600: #0b8ce9; @@ -53,7 +49,6 @@ --color-jade-400: #47e469; --color-jade-600: #00cd72; - --color-graphite-400: #9c9eab; --color-gold-400: #fcbf64; --color-gold-500: #fdab34; @@ -73,10 +68,6 @@ --color-danger-100: #c02323; --color-danger-200: #d62952; - --color-coral-red-600: #973a40; - --color-coral-red-500: #c53f49; - --color-coral-red-400: #dd424e; - --color-bypass: #6a246a; --color-error: #962a2a; @@ -212,7 +203,7 @@ --node-component-slot-dot-outline-opacity: 5%; --node-component-slot-dot-outline: var(--color-black); --node-component-slot-text: var(--color-ash-800); - --node-component-surface-highlight: var(--color-ash-500); + --node-component-surface-highlight: var(--color-smoke-800); --node-component-surface-hovered: var(--color-smoke-200); --node-component-surface-selected: var(--color-charcoal-200); --node-component-surface: var(--color-white); @@ -231,7 +222,7 @@ --node-stroke-error: var(--color-error); --node-stroke-executing: var(--color-azure-600); - --text-secondary: var(--color-ash-500); + --text-secondary: var(--color-smoke-800); --text-primary: var(--color-charcoal-700); --input-surface: rgb(0 0 0 / 0.15); @@ -268,7 +259,7 @@ --secondary-background-selected ); --component-node-widget-background-disabled: var(--color-alpha-ash-500-20); - --component-node-widget-background-highlighted: var(--color-ash-500); + --component-node-widget-background-highlighted: var(--color-smoke-800); --component-node-widget-promoted: var(--color-purple-700); --component-node-widget-advanced: var(--color-azure-400); @@ -348,19 +339,19 @@ --node-component-border-error: var(--color-danger-100); --node-component-border-executing: var(--color-blue-500); --node-component-border-selected: var(--color-charcoal-200); - --node-component-header-icon: var(--color-slate-300); + --node-component-header-icon: var(--color-smoke-800); --node-component-header-surface: var(--color-charcoal-800); --node-component-outline: var(--color-white); --node-component-ring: rgb(var(--color-smoke-500) / 20%); --node-component-slot-dot-outline-opacity: 10%; --node-component-slot-dot-outline: var(--color-white); - --node-component-slot-text: var(--color-slate-200); - --node-component-surface-highlight: var(--color-slate-100); + --node-component-slot-text: var(--color-smoke-700); + --node-component-surface-highlight: var(--color-smoke-800); --node-component-surface-hovered: var(--color-charcoal-600); --node-component-surface-selected: var(--color-charcoal-200); --node-component-surface: var(--color-charcoal-600); --node-component-tooltip: var(--color-white); - --node-component-tooltip-border: var(--color-slate-300); + --node-component-tooltip-border: var(--color-charcoal-200); --node-component-tooltip-surface: var(--color-charcoal-800); --node-component-widget-skeleton-surface: var(--color-zinc-800); --node-component-disabled: var(--color-alpha-charcoal-600-30); @@ -378,7 +369,7 @@ ); --color-interface-panel-job-progress-border: var(--base-foreground); - --text-secondary: var(--color-slate-100); + --text-secondary: var(--color-smoke-700); --text-primary: var(--color-white); --input-surface: rgb(130 130 130 / 0.1); @@ -418,7 +409,7 @@ --component-node-widget-background-disabled: var( --color-alpha-charcoal-600-30 ); - --component-node-widget-background-highlighted: var(--color-graphite-400); + --component-node-widget-background-highlighted: var(--color-smoke-800); --component-node-widget-promoted: var(--color-purple-700); --component-node-widget-advanced: var(--color-azure-600); diff --git a/packages/ingest-types/src/types.gen.ts b/packages/ingest-types/src/types.gen.ts index 7b307a84ec..5af8c4b07d 100644 --- a/packages/ingest-types/src/types.gen.ts +++ b/packages/ingest-types/src/types.gen.ts @@ -3825,14 +3825,14 @@ export type CreateAssetExportData = { /** * Strategy for naming files in the ZIP: * - group_by_job_id: Group assets by job ID as a parent directory (e.g., "833a1b5c-beab-436a-ae8e-f07e7cd7b2c4/ComfyUI_00001_.png") - * - prepend_job_id: Prepend job ID to filenames for uniqueness (e.g., "833a1b5c-beab-436a-ae8e-f07e7cd7b2c4_ComfyUI_00001_.png") + * - group_by_job_time: Group assets by job execution time as parent directories * - preserve: Use original asset names, skip duplicates (first one wins) * - asset_id: Use the asset ID as the filename (e.g., "833a1b5c-beab-436a-ae8e-f07e7cd7b2c4.png") * */ naming_strategy?: | 'group_by_job_id' - | 'prepend_job_id' + | 'group_by_job_time' | 'preserve' | 'asset_id' /** diff --git a/packages/ingest-types/src/zod.gen.ts b/packages/ingest-types/src/zod.gen.ts index 20788c35ac..1ad5981dd8 100644 --- a/packages/ingest-types/src/zod.gen.ts +++ b/packages/ingest-types/src/zod.gen.ts @@ -1818,7 +1818,7 @@ export const zCreateAssetExportData = z.object({ job_ids: z.array(z.string()).optional(), asset_ids: z.array(z.string()).optional(), naming_strategy: z - .enum(['group_by_job_id', 'prepend_job_id', 'preserve', 'asset_id']) + .enum(['group_by_job_id', 'group_by_job_time', 'preserve', 'asset_id']) .optional(), job_asset_name_filters: z.record(z.array(z.string()).min(1)).optional() }), diff --git a/packages/registry-types/src/comfyRegistryTypes.ts b/packages/registry-types/src/comfyRegistryTypes.ts index 6f47737570..b13ba80dfc 100644 --- a/packages/registry-types/src/comfyRegistryTypes.ts +++ b/packages/registry-types/src/comfyRegistryTypes.ts @@ -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; @@ -4062,6 +4086,22 @@ export interface paths { patch?: never; trace?: never; }; + "/proxy/seedance/virtual-library/assets": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["seedanceVirtualLibraryCreateAsset"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/seedance/complete": { parameters: { query?: never; @@ -13553,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 @@ -14426,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 @@ -14435,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; @@ -14442,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; }; @@ -14490,6 +14567,16 @@ export interface components { code: string; message: string; }; + SeedanceVirtualLibraryCreateAssetRequest: { + /** @description Publicly accessible URL of the image asset to upload to the caller's virtual portrait library. */ + url: string; + /** @description Client-supplied content hash used as the per-customer dedup key. Re-submitting the same hash returns the existing asset id without re-uploading to BytePlus. */ + hash: string; + }; + SeedanceVirtualLibraryCreateAssetResponse: { + /** @description BytePlus-issued asset id. Clients poll seedanceGetAsset with this until status == Active. */ + asset_id: string; + }; WanVideoGenerationRequest: { /** * @description The ID of the model to call @@ -30249,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: { @@ -30271,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; @@ -30303,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; @@ -30338,10 +30492,7 @@ export interface operations { }; seedanceGetAsset: { parameters: { - query?: { - /** @description BytePlus project name. Defaults to "default" if omitted. Must match the ProjectName used at create time. */ - project_name?: string; - }; + query?: never; header?: never; path: { /** @description BytePlus-issued asset id returned by seedanceCreateAsset */ @@ -30371,6 +30522,39 @@ export interface operations { }; }; }; + seedanceVirtualLibraryCreateAsset: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SeedanceVirtualLibraryCreateAssetRequest"]; + }; + }; + responses: { + /** @description Asset creation accepted (asynchronous — poll seedanceGetAsset) */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SeedanceVirtualLibraryCreateAssetResponse"]; + }; + }; + /** @description Error 4xx/5xx */ + default: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; seedanceVisualValidateCallback: { parameters: { query: { diff --git a/playwright.config.ts b/playwright.config.ts index d5243425f6..85ea7595ff 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -8,7 +8,10 @@ const maybeLocalOptions: PlaywrightTestConfig = process.env.PLAYWRIGHT_LOCAL workers: 1, use: { trace: 'on', - video: 'on' + video: 'on', + launchOptions: { + slowMo: Number(process.env.SLOW_MO) || 0 + } } } : { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 81ada7a44c..3bf369cc14 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -162,6 +162,9 @@ catalogs: '@types/three': specifier: ^0.169.0 version: 0.169.0 + '@vee-validate/zod': + specifier: ^4.15.1 + version: 4.15.1 '@vercel/analytics': specifier: ^2.0.1 version: 2.0.1 @@ -360,6 +363,9 @@ catalogs: unplugin-vue-components: specifier: ^30.0.0 version: 30.0.0 + vee-validate: + specifier: ^4.15.1 + version: 4.15.1 vite-plugin-dts: specifier: ^4.5.4 version: 4.5.4 @@ -497,6 +503,9 @@ importers: '@tiptap/starter-kit': specifier: 'catalog:' version: 2.27.2 + '@vee-validate/zod': + specifier: 'catalog:' + version: 4.15.1(vue@3.5.13(typescript@5.9.3))(zod@3.25.76) '@vueuse/core': specifier: 'catalog:' version: 14.2.0(vue@3.5.13(typescript@5.9.3)) @@ -587,6 +596,9 @@ importers: typegpu: specifier: 'catalog:' version: 0.8.2 + vee-validate: + specifier: 'catalog:' + version: 4.15.1(vue@3.5.13(typescript@5.9.3)) vue: specifier: 'catalog:' version: 3.5.13(typescript@5.9.3) @@ -949,6 +961,9 @@ importers: lenis: specifier: 'catalog:' version: 1.3.21(react@19.2.4)(vue@3.5.13(typescript@5.9.3)) + posthog-js: + specifier: 'catalog:' + version: 1.358.1 vue: specifier: 'catalog:' version: 3.5.13(typescript@5.9.3) @@ -4721,6 +4736,11 @@ packages: peerDependencies: valibot: ^1.2.0 + '@vee-validate/zod@4.15.1': + resolution: {integrity: sha512-329Z4TDBE5Vx0FdbA8S4eR9iGCFFUNGbxjpQ20ff5b5wGueScjocUIx9JHPa79LTG06RnlUR4XogQsjN4tecKA==} + peerDependencies: + zod: ^3.24.0 + '@vercel/analytics@2.0.1': resolution: {integrity: sha512-MTQG6V9qQrt1tsDeF+2Uoo5aPjqbVPys1xvnIftXSJYG2SrwXRHnqEvVoYID7BTruDz4lCd2Z7rM1BdkUehk2g==} peerDependencies: @@ -9593,6 +9613,11 @@ packages: typescript: optional: true + vee-validate@4.15.1: + resolution: {integrity: sha512-DkFsiTwEKau8VIxyZBGdO6tOudD+QoUBPuHj3e6QFqmbfCRj1ArmYWue9lEp6jLSWBIw4XPlDLjFIZNLdRAMSg==} + peerDependencies: + vue: ^3.4.26 + vfile-location@5.0.3: resolution: {integrity: sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==} @@ -14038,6 +14063,14 @@ snapshots: dependencies: valibot: 1.2.0(typescript@5.9.3) + '@vee-validate/zod@4.15.1(vue@3.5.13(typescript@5.9.3))(zod@3.25.76)': + dependencies: + type-fest: 4.41.0 + vee-validate: 4.15.1(vue@3.5.13(typescript@5.9.3)) + zod: 3.25.76 + transitivePeerDependencies: + - vue + '@vercel/analytics@2.0.1(react@19.2.4)(vue-router@4.4.3(vue@3.5.13(typescript@5.9.3)))(vue@3.5.13(typescript@5.9.3))': optionalDependencies: react: 19.2.4 @@ -14156,7 +14189,7 @@ snapshots: sirv: 3.0.2 tinyglobby: 0.2.15 tinyrainbow: 3.0.3 - vitest: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@25.0.3)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2) + vitest: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2) '@vitest/utils@3.2.4': dependencies: @@ -20051,6 +20084,12 @@ snapshots: optionalDependencies: typescript: 5.9.3 + vee-validate@4.15.1(vue@3.5.13(typescript@5.9.3)): + dependencies: + '@vue/devtools-api': 7.7.9 + type-fest: 4.41.0 + vue: 3.5.13(typescript@5.9.3) + vfile-location@5.0.3: dependencies: '@types/unist': 3.0.3 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 29b3a82afa..2059d90ab8 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -55,6 +55,7 @@ catalog: '@types/node': ^24.1.0 '@types/semver': ^7.7.0 '@types/three': ^0.169.0 + '@vee-validate/zod': ^4.15.1 '@vercel/analytics': ^2.0.1 '@vitejs/plugin-vue': ^6.0.0 '@vitest/coverage-v8': ^4.0.16 @@ -121,6 +122,7 @@ catalog: unplugin-icons: ^22.5.0 unplugin-typegpu: 0.8.0 unplugin-vue-components: ^30.0.0 + vee-validate: ^4.15.1 vite: ^8.0.0 vite-plugin-dts: ^4.5.4 vite-plugin-html: ^3.2.2 diff --git a/scripts/generate-embedded-metadata-test-files.py b/scripts/generate-embedded-metadata-test-files.py new file mode 100644 index 0000000000..2b57e296d3 --- /dev/null +++ b/scripts/generate-embedded-metadata-test-files.py @@ -0,0 +1,177 @@ +#!/usr/bin/env python3 +""" +Generate test fixture files for metadata parser tests. + +Each fixture embeds the same workflow and prompt JSON, matching the +format the ComfyUI backend uses to write metadata. + +Prerequisites: + source ~/ComfyUI/.venv/bin/activate + python3 scripts/generate-embedded-metadata-test-files.py + +Output: src/scripts/metadata/__fixtures__/ +""" + +import json +import os +import struct +import subprocess + +import av +from PIL import Image + +REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +FIXTURES_DIR = os.path.join(REPO_ROOT, 'src', 'scripts', 'metadata', '__fixtures__') + +WORKFLOW = { + 'nodes': [ + { + 'id': 1, + 'type': 'KSampler', + 'pos': [100, 100], + 'size': [200, 200], + } + ] +} +PROMPT = {'1': {'class_type': 'KSampler', 'inputs': {}}} + +WORKFLOW_JSON = json.dumps(WORKFLOW, separators=(',', ':')) +PROMPT_JSON = json.dumps(PROMPT, separators=(',', ':')) + + +def out(name: str) -> str: + return os.path.join(FIXTURES_DIR, name) + + +def report(name: str): + size = os.path.getsize(out(name)) + print(f' {name} ({size} bytes)') + + +def make_1x1_image() -> Image.Image: + return Image.new('RGB', (1, 1), (255, 0, 0)) + + +def build_exif_bytes() -> bytes: + """Build EXIF bytes matching the backend's tag assignments. + + Backend: 0x010F (Make) = "workflow:<json>", 0x0110 (Model) = "prompt:<json>" + """ + img = make_1x1_image() + exif = img.getexif() + exif[0x010F] = f'workflow:{WORKFLOW_JSON}' + exif[0x0110] = f'prompt:{PROMPT_JSON}' + return exif.tobytes() + + +def inject_exif_prefix_in_webp(path: str): + """Prepend Exif\\0\\0 to the EXIF chunk in a WEBP file. + + PIL always strips this prefix, so we re-inject it to test that code path. + """ + data = bytearray(open(path, 'rb').read()) + off = 12 + while off < len(data): + chunk_type = data[off:off + 4] + chunk_len = struct.unpack_from('<I', data, off + 4)[0] + if chunk_type == b'EXIF': + prefix = b'Exif\x00\x00' + data[off + 8:off + 8] = prefix + struct.pack_into('<I', data, off + 4, chunk_len + len(prefix)) + riff_size = struct.unpack_from('<I', data, 4)[0] + struct.pack_into('<I', data, 4, riff_size + len(prefix)) + break + off += 8 + chunk_len + (chunk_len % 2) + with open(path, 'wb') as f: + f.write(data) + + +def generate_av_fixture( + name: str, + fmt: str, + codec: str, + rate: int = 44100, + options: dict | None = None, +): + """Generate an audio fixture via PyAV container.metadata[], matching the backend.""" + path = out(name) + container = av.open(path, mode='w', format=fmt, options=options or {}) + stream = container.add_stream(codec, rate=rate) + stream.layout = 'mono' + + container.metadata['prompt'] = PROMPT_JSON + container.metadata['workflow'] = WORKFLOW_JSON + + sample_fmt = stream.codec_context.codec.audio_formats[0].name + samples = stream.codec_context.frame_size or 1024 + frame = av.AudioFrame(format=sample_fmt, layout='mono', samples=samples) + frame.rate = rate + frame.pts = 0 + for packet in stream.encode(frame): + container.mux(packet) + for packet in stream.encode(): + container.mux(packet) + container.close() + report(name) + + +def generate_webp(): + img = make_1x1_image() + exif = build_exif_bytes() + + img.save(out('with_metadata.webp'), 'WEBP', exif=exif) + report('with_metadata.webp') + + img.save(out('with_metadata_exif_prefix.webp'), 'WEBP', exif=exif) + inject_exif_prefix_in_webp(out('with_metadata_exif_prefix.webp')) + report('with_metadata_exif_prefix.webp') + + +def generate_avif(): + img = make_1x1_image() + exif = build_exif_bytes() + img.save(out('with_metadata.avif'), 'AVIF', exif=exif) + report('with_metadata.avif') + + +def generate_flac(): + generate_av_fixture('with_metadata.flac', 'flac', 'flac') + + +def generate_opus(): + generate_av_fixture('with_metadata.opus', 'opus', 'libopus', rate=48000) + + +def generate_mp3(): + generate_av_fixture('with_metadata.mp3', 'mp3', 'libmp3lame') + + +def generate_mp4(): + """Generate MP4 via ffmpeg CLI with QuickTime keys/ilst metadata.""" + path = out('with_metadata.mp4') + subprocess.run([ + 'ffmpeg', '-y', '-loglevel', 'error', + '-f', 'lavfi', '-i', 'anullsrc=r=44100:cl=mono', + '-t', '0.01', '-c:a', 'aac', '-b:a', '32k', + '-movflags', 'use_metadata_tags', + '-metadata', f'prompt={PROMPT_JSON}', + '-metadata', f'workflow={WORKFLOW_JSON}', + path, + ], check=True) + report('with_metadata.mp4') + + +def generate_webm(): + generate_av_fixture('with_metadata.webm', 'webm', 'libvorbis') + + +if __name__ == '__main__': + print('Generating fixtures...') + generate_webp() + generate_avif() + generate_flac() + generate_opus() + generate_mp3() + generate_mp4() + generate_webm() + print('Done.') diff --git a/src/components/boundingbox/WidgetBoundingBox.vue b/src/components/boundingbox/WidgetBoundingBox.vue index e5010cd136..77e597ab0a 100644 --- a/src/components/boundingbox/WidgetBoundingBox.vue +++ b/src/components/boundingbox/WidgetBoundingBox.vue @@ -1,5 +1,8 @@ <template> - <div class="grid grid-cols-[auto_1fr] gap-x-2 gap-y-1"> + <div + class="grid grid-cols-[auto_1fr] gap-x-2 gap-y-1" + data-testid="bounding-box" + > <label class="content-center text-xs text-node-component-slot-text"> {{ $t('boundingBox.x') }} </label> diff --git a/src/components/builder/AppModeWidgetList.vue b/src/components/builder/AppModeWidgetList.vue index e199cc9514..8ab4e9bc4f 100644 --- a/src/components/builder/AppModeWidgetList.vue +++ b/src/components/builder/AppModeWidgetList.vue @@ -153,17 +153,15 @@ function nodeToNodeData(node: LGraphNode) { } } -async function handleDragDrop(e: DragEvent) { - for (const { nodeData } of mappedSelections.value) { - if (!nodeData?.onDragOver?.(e)) continue - - const rawResult = nodeData?.onDragDrop?.(e) - if (rawResult === false) continue - - e.stopPropagation() - e.preventDefault() - if ((await rawResult) === true) return +async function handleDragDrop() { + const onDragDrop = async (e: DragEvent) => { + for (const { nodeData } of mappedSelections.value) + if (nodeData?.onDragOver?.(e) && (await nodeData.onDragDrop?.(e))) + return true + return false } + + app.dragOverNode = { id: -1, onDragDrop } } defineExpose({ handleDragDrop }) diff --git a/src/components/common/EditableText.test.ts b/src/components/common/EditableText.test.ts index a12a236918..fcba920ec9 100644 --- a/src/components/common/EditableText.test.ts +++ b/src/components/common/EditableText.test.ts @@ -1,7 +1,5 @@ import { fireEvent, render, screen } from '@testing-library/vue' import userEvent from '@testing-library/user-event' -import PrimeVue from 'primevue/config' -import InputText from 'primevue/inputtext' import { describe, expect, it, vi } from 'vitest' import EditableText from './EditableText.vue' @@ -17,10 +15,6 @@ describe('EditableText', () => { const user = userEvent.setup() render(EditableText, { - global: { - plugins: [PrimeVue], - components: { InputText } - }, props: { ...props, ...(callbacks.onEdit && { onEdit: callbacks.onEdit }), diff --git a/src/components/common/EditableText.vue b/src/components/common/EditableText.vue index 806ee945fd..bb89aa250a 100644 --- a/src/components/common/EditableText.vue +++ b/src/components/common/EditableText.vue @@ -1,24 +1,18 @@ <template> - <div class="editable-text"> + <div class="editable-text inline"> <component :is="labelType" v-if="!isEditing" :class="labelClass"> {{ modelValue }} </component> - <!-- Avoid double triggering finishEditing event when keydown.enter is triggered --> - <InputText + <Input v-else ref="inputRef" - v-model:model-value="inputValue" + v-model="inputValue" v-focus type="text" - size="small" - fluid - :pt="{ - root: { - onBlur: finishEditing, - ...inputAttrs - } - }" - @keydown.enter.capture.stop="blurInputElement" + class="h-full rounded-none p-0 focus-visible:ring-0" + v-bind="inputAttrs" + @blur="finishEditing" + @keydown.enter.capture.stop="inputRef?.blur()" @keydown.escape.capture.stop="cancelEditing" @click.stop @contextmenu.stop @@ -29,9 +23,10 @@ </template> <script setup lang="ts"> -import InputText from 'primevue/inputtext' import { nextTick, ref, watch } from 'vue' +import Input from '@/components/ui/input/Input.vue' + const { modelValue, isEditing = false, @@ -48,30 +43,23 @@ const { const emit = defineEmits(['edit', 'cancel']) const inputValue = ref<string>(modelValue) -const inputRef = ref<InstanceType<typeof InputText> | undefined>() +const inputRef = ref<InstanceType<typeof Input>>() const isCanceling = ref(false) -const blurInputElement = () => { - // @ts-expect-error - $el is an internal property of the InputText component - inputRef.value?.$el.blur() -} -const finishEditing = () => { - // Don't save if we're canceling +function finishEditing() { if (!isCanceling.value) { emit('edit', inputValue.value) } isCanceling.value = false } -const cancelEditing = () => { - // Set canceling flag to prevent blur from saving + +function cancelEditing() { isCanceling.value = true - // Reset to original value inputValue.value = modelValue - // Emit cancel event emit('cancel') - // Blur the input to exit edit mode - blurInputElement() + inputRef.value?.blur() } + watch( () => isEditing, async (newVal) => { @@ -82,27 +70,14 @@ watch( const fileName = inputValue.value.includes('.') ? inputValue.value.split('.').slice(0, -1).join('.') : inputValue.value - const start = 0 - const end = fileName.length - // @ts-expect-error - $el is an internal property of the InputText component - const inputElement = inputRef.value.$el - inputElement.setSelectionRange?.(start, end) + inputRef.value.setSelectionRange(0, fileName.length) }) } }, { immediate: true } ) + const vFocus = { mounted: (el: HTMLElement) => el.focus() } </script> - -<style scoped> -.editable-text { - display: inline; -} -.editable-text input { - width: 100%; - box-sizing: border-box; -} -</style> diff --git a/src/components/dialog/content/PromptDialogContent.test.ts b/src/components/dialog/content/PromptDialogContent.test.ts new file mode 100644 index 0000000000..f395039f1f --- /dev/null +++ b/src/components/dialog/content/PromptDialogContent.test.ts @@ -0,0 +1,85 @@ +import { fireEvent, render, screen } from '@testing-library/vue' +import userEvent from '@testing-library/user-event' +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { ComponentProps } from 'vue-component-type-helpers' +import { createI18n } from 'vue-i18n' + +import { useDialogStore } from '@/stores/dialogStore' +import PromptDialogContent from './PromptDialogContent.vue' + +type Props = ComponentProps<typeof PromptDialogContent> + +const i18n = createI18n({ + legacy: false, + locale: 'en', + messages: { en: {} }, + missingWarn: false, + fallbackWarn: false +}) + +describe('PromptDialogContent', () => { + beforeEach(() => { + setActivePinia(createPinia()) + }) + + function renderComponent(props: Partial<Props> = {}) { + const user = userEvent.setup() + render(PromptDialogContent, { + global: { plugins: [i18n] }, + props: { + message: 'Enter a name', + defaultValue: '', + onConfirm: vi.fn(), + ...props + } as Props + }) + return { user } + } + + it('pre-fills the input with defaultValue', () => { + renderComponent({ defaultValue: 'my workflow' }) + expect(screen.getByRole('textbox')).toHaveValue('my workflow') + }) + + it('calls onConfirm and closes dialog when Confirm is clicked', async () => { + const onConfirm = vi.fn() + const { user } = renderComponent({ defaultValue: 'original', onConfirm }) + const closeSpy = vi.spyOn(useDialogStore(), 'closeDialog') + + await user.clear(screen.getByRole('textbox')) + await user.type(screen.getByRole('textbox'), 'renamed') + await user.click(screen.getByRole('button', { name: /confirm/i })) + + expect(onConfirm).toHaveBeenCalledWith('renamed') + expect(closeSpy).toHaveBeenCalledOnce() + }) + + it('calls onConfirm when Enter is pressed inside the input', async () => { + const onConfirm = vi.fn() + const { user } = renderComponent({ defaultValue: 'original', onConfirm }) + + await user.clear(screen.getByRole('textbox')) + await user.type(screen.getByRole('textbox'), 'via enter') + await user.keyboard('{Enter}') + + expect(onConfirm).toHaveBeenCalledWith('via enter') + }) + + it('closes dialog when Confirm button is clicked', async () => { + const { user } = renderComponent({ defaultValue: '' }) + const closeSpy = vi.spyOn(useDialogStore(), 'closeDialog') + + await user.click(screen.getByRole('button', { name: /confirm/i })) + + expect(closeSpy).toHaveBeenCalledOnce() + }) + + it('selects all text when the input is focused', async () => { + renderComponent({ defaultValue: 'pre-filled text', onConfirm: vi.fn() }) + const input = screen.getByRole('textbox') as HTMLInputElement + const spy = vi.spyOn(input, 'setSelectionRange') + await fireEvent.focus(input) + expect(spy).toHaveBeenCalledWith(0, 'pre-filled text'.length) + }) +}) diff --git a/src/components/dialog/content/PromptDialogContent.vue b/src/components/dialog/content/PromptDialogContent.vue index 92c721a244..3497520f02 100644 --- a/src/components/dialog/content/PromptDialogContent.vue +++ b/src/components/dialog/content/PromptDialogContent.vue @@ -1,49 +1,43 @@ <template> <div class="prompt-dialog-content flex flex-col gap-2 pt-8"> - <FloatLabel> - <InputText + <label class="flex flex-col gap-1 text-sm text-muted-foreground"> + {{ message }} + <Input ref="inputRef" v-model="inputValue" + type="text" :placeholder autofocus - @keyup.enter="onConfirm" - @focus="selectAllText" + @keyup.enter="handleConfirm" + @focus="inputRef?.selectAll()" /> - <label>{{ message }}</label> - </FloatLabel> - <Button @click="onConfirm"> + </label> + <Button @click="handleConfirm"> {{ $t('g.confirm') }} </Button> </div> </template> <script setup lang="ts"> -import FloatLabel from 'primevue/floatlabel' -import InputText from 'primevue/inputtext' import { ref } from 'vue' import Button from '@/components/ui/button/Button.vue' +import Input from '@/components/ui/input/Input.vue' import { useDialogStore } from '@/stores/dialogStore' -const props = defineProps<{ +const { message, defaultValue, onConfirm, placeholder } = defineProps<{ message: string defaultValue: string onConfirm: (value: string) => void placeholder?: string }>() -const inputValue = ref<string>(props.defaultValue) +const inputValue = ref<string>(defaultValue) -const onConfirm = () => { - props.onConfirm(inputValue.value) +function handleConfirm() { + onConfirm(inputValue.value) useDialogStore().closeDialog() } -const inputRef = ref<InstanceType<typeof InputText> | undefined>() -const selectAllText = () => { - if (!inputRef.value) return - // @ts-expect-error - $el is an internal property of the InputText component - const inputElement = inputRef.value.$el - inputElement.setSelectionRange(0, inputElement.value.length) -} +const inputRef = ref<InstanceType<typeof Input>>() </script> diff --git a/src/components/dialog/content/signin/SignUpForm.test.ts b/src/components/dialog/content/signin/SignUpForm.test.ts new file mode 100644 index 0000000000..f28cf157ac --- /dev/null +++ b/src/components/dialog/content/signin/SignUpForm.test.ts @@ -0,0 +1,110 @@ +import { Form, FormField } from '@primevue/forms' +import { render, screen } from '@testing-library/vue' +import Button from '@/components/ui/button/Button.vue' +import PrimeVue from 'primevue/config' +import InputText from 'primevue/inputtext' +import Password from 'primevue/password' +import ProgressSpinner from 'primevue/progressspinner' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { ref } from 'vue' +import { createI18n } from 'vue-i18n' + +import enMessages from '@/locales/en/main.json' with { type: 'json' } + +import SignUpForm from './SignUpForm.vue' + +vi.mock('firebase/app', () => ({ + initializeApp: vi.fn(), + getApp: vi.fn() +})) + +vi.mock('firebase/auth', () => ({ + getAuth: vi.fn(), + setPersistence: vi.fn(), + browserLocalPersistence: {}, + onAuthStateChanged: vi.fn(), + signInWithEmailAndPassword: vi.fn(), + signOut: vi.fn() +})) + +const mockLoadingRef = ref(false) +vi.mock('@/stores/authStore', () => ({ + useAuthStore: vi.fn(() => ({ + get loading() { + return mockLoadingRef.value + } + })) +})) + +describe('SignUpForm', () => { + beforeEach(() => { + mockLoadingRef.value = false + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + function renderComponent() { + const i18n = createI18n({ + legacy: false, + locale: 'en', + messages: { en: enMessages } + }) + return render(SignUpForm, { + global: { + plugins: [PrimeVue, i18n], + components: { + Form, + FormField, + Button, + InputText, + Password, + ProgressSpinner + } + } + }) + } + + describe('Password manager autofill attributes', () => { + it('renders email input with attributes Chrome needs to recognize the field', () => { + renderComponent() + + const emailInput = screen.getByPlaceholderText( + enMessages.auth.signup.emailPlaceholder + ) + expect(emailInput).toHaveAttribute('id', 'comfy-org-sign-up-email') + expect(emailInput).toHaveAttribute('name', 'email') + expect(emailInput).toHaveAttribute('autocomplete', 'email') + expect(emailInput).toHaveAttribute('type', 'email') + }) + + it('renders password input with new-password autofill attributes', () => { + renderComponent() + + const passwordInput = screen.getByPlaceholderText( + enMessages.auth.signup.passwordPlaceholder + ) + expect(passwordInput).toHaveAttribute('id', 'comfy-org-sign-up-password') + expect(passwordInput).toHaveAttribute('name', 'password') + expect(passwordInput).toHaveAttribute('autocomplete', 'new-password') + }) + + it('renders confirm-password input with distinct name and new-password autocomplete', () => { + renderComponent() + + const confirmPasswordInput = screen.getByPlaceholderText( + enMessages.auth.login.confirmPasswordPlaceholder + ) + expect(confirmPasswordInput).toHaveAttribute( + 'id', + 'comfy-org-sign-up-confirm-password' + ) + expect(confirmPasswordInput).toHaveAttribute('name', 'confirmPassword') + expect(confirmPasswordInput).toHaveAttribute( + 'autocomplete', + 'new-password' + ) + }) + }) +}) diff --git a/src/components/dialog/content/signin/SignUpForm.vue b/src/components/dialog/content/signin/SignUpForm.vue index f8b8d4aebb..e03eb60b81 100644 --- a/src/components/dialog/content/signin/SignUpForm.vue +++ b/src/components/dialog/content/signin/SignUpForm.vue @@ -15,9 +15,10 @@ </label> <InputText pt:root:id="comfy-org-sign-up-email" + pt:root:name="email" pt:root:autocomplete="email" class="h-10" - type="text" + type="email" :placeholder="t('auth.signup.emailPlaceholder')" :invalid="$field.invalid" /> diff --git a/src/components/graph/GraphCanvas.vue b/src/components/graph/GraphCanvas.vue index cbc6418f4f..cd4909417c 100644 --- a/src/components/graph/GraphCanvas.vue +++ b/src/components/graph/GraphCanvas.vue @@ -8,11 +8,6 @@ v-if="workflowTabsPosition === 'Topbar'" class="workflow-tabs-container pointer-events-auto relative h-(--workflow-tabs-height) w-full" > - <!-- Native drag area for Electron --> - <div - v-if="isNativeWindow() && workflowTabsPosition !== 'Topbar'" - class="app-drag fixed top-0 left-0 z-10 h-(--comfy-topbar-height) w-full" - /> <div class="flex h-full items-center border-b border-interface-stroke bg-comfy-menu-bg shadow-interface" > @@ -189,7 +184,6 @@ import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore' import { useSearchBoxStore } from '@/stores/workspace/searchBoxStore' import { useAppMode } from '@/composables/useAppMode' import { useWorkspaceStore } from '@/stores/workspaceStore' -import { isNativeWindow } from '@/utils/envUtil' import { forEachNode } from '@/utils/graphTraversalUtil' import SelectionRectangle from './SelectionRectangle.vue' diff --git a/src/components/load3d/Load3D.test.ts b/src/components/load3d/Load3D.test.ts new file mode 100644 index 0000000000..a1d1500e7c --- /dev/null +++ b/src/components/load3d/Load3D.test.ts @@ -0,0 +1,254 @@ +import { render, screen } from '@testing-library/vue' +import userEvent from '@testing-library/user-event' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ref } from 'vue' +import { createI18n } from 'vue-i18n' + +import Load3D from '@/components/load3d/Load3D.vue' +import type { ComponentWidget } from '@/scripts/domWidget' + +const { load3dState, resolveNodeMock, settingGetMock } = vi.hoisted(() => ({ + load3dState: { + current: null as ReturnType<typeof buildLoad3dStub> | null + }, + resolveNodeMock: vi.fn(), + settingGetMock: vi.fn() +})) + +function buildLoad3dStub() { + return { + sceneConfig: ref({}), + modelConfig: ref({}), + cameraConfig: ref({}), + lightConfig: ref({}), + isRecording: ref(false), + isPreview: ref(false), + canFitToViewer: ref(true), + canUseGizmo: ref(true), + canUseLighting: ref(true), + canExport: ref(true), + materialModes: ref(['original', 'normal', 'wireframe']), + hasSkeleton: ref(false), + hasRecording: ref(false), + recordingDuration: ref(0), + animations: ref<Array<{ name: string; index: number }>>([]), + playing: ref(false), + selectedSpeed: ref(1), + selectedAnimation: ref(0), + animationProgress: ref(0), + animationDuration: ref(0), + loading: ref(false), + loadingMessage: ref(''), + initializeLoad3d: vi.fn(), + handleMouseEnter: vi.fn(), + handleMouseLeave: vi.fn(), + handleStartRecording: vi.fn(), + handleStopRecording: vi.fn(), + handleExportRecording: vi.fn(), + handleClearRecording: vi.fn(), + handleSeek: vi.fn(), + handleBackgroundImageUpdate: vi.fn(), + handleHDRIFileUpdate: vi.fn(), + handleExportModel: vi.fn(), + handleModelDrop: vi.fn(), + handleToggleGizmo: vi.fn(), + handleSetGizmoMode: vi.fn(), + handleResetGizmoTransform: vi.fn(), + handleFitToViewer: vi.fn(), + cleanup: vi.fn() + } +} + +vi.mock('@/composables/useLoad3d', () => ({ + useLoad3d: () => load3dState.current +})) + +vi.mock('@/platform/settings/settingStore', () => ({ + useSettingStore: () => ({ get: settingGetMock }) +})) + +vi.mock('@/utils/litegraphUtil', () => ({ + resolveNode: resolveNodeMock +})) + +const i18n = createI18n({ + legacy: false, + locale: 'en', + messages: { + en: { + load3d: { fitToViewer: 'Fit to viewer' } + } + } +}) + +type RenderOptions = { + widget?: unknown + nodeId?: number | string + stateOverrides?: Partial<ReturnType<typeof buildLoad3dStub>> + enable3DViewer?: boolean +} + +const MOCK_NODE = { id: 'node', type: 'Load3D' } + +function renderLoad3D(options: RenderOptions = {}) { + const stub = buildLoad3dStub() + if (options.stateOverrides) { + Object.assign(stub, options.stateOverrides) + } + load3dState.current = stub + + settingGetMock.mockImplementation((key: string) => + key === 'Comfy.Load3D.3DViewerEnable' + ? (options.enable3DViewer ?? false) + : undefined + ) + + return { + ...render(Load3D, { + props: { + widget: (options.widget ?? { + node: MOCK_NODE + }) as unknown as ComponentWidget<string[]>, + nodeId: options.nodeId + }, + global: { + plugins: [i18n], + stubs: { + Load3DControls: { + name: 'Load3DControls', + template: '<div data-testid="load3d-controls" />' + }, + Load3DScene: { + name: 'Load3DScene', + template: '<div data-testid="load3d-scene" />' + }, + AnimationControls: { + name: 'AnimationControls', + template: '<div data-testid="animation-controls" />' + }, + RecordingControls: { + name: 'RecordingControls', + template: '<div data-testid="recording-controls" />' + }, + ViewerControls: { + name: 'ViewerControls', + template: '<div data-testid="viewer-controls" />' + }, + Button: { + name: 'Button', + props: ['ariaLabel'], + template: + '<button type="button" :aria-label="ariaLabel"><slot /></button>' + } + }, + directives: { + tooltip: () => {} + } + } + }), + stub + } +} + +describe('Load3D', () => { + beforeEach(() => { + vi.clearAllMocks() + load3dState.current = null + }) + + describe('node resolution', () => { + it('uses widget.node when the widget is a ComponentWidget', () => { + renderLoad3D({ widget: { node: MOCK_NODE } }) + + expect(screen.getByTestId('load3d-scene')).toBeInTheDocument() + expect(resolveNodeMock).not.toHaveBeenCalled() + }) + + it('falls back to resolveNode(nodeId) when the widget lacks a node', async () => { + resolveNodeMock.mockReturnValue(MOCK_NODE) + renderLoad3D({ widget: {}, nodeId: 42 }) + + expect(resolveNodeMock).toHaveBeenCalledWith(42) + expect(await screen.findByTestId('load3d-scene')).toBeInTheDocument() + }) + + it('does not render Load3DScene when no node can be resolved', async () => { + resolveNodeMock.mockReturnValue(null) + renderLoad3D({ widget: {}, nodeId: 99 }) + + await Promise.resolve() + expect(screen.queryByTestId('load3d-scene')).not.toBeInTheDocument() + }) + }) + + describe('capability-driven chrome', () => { + it('shows the fit-to-viewer button when canFitToViewer is true', () => { + renderLoad3D({ stateOverrides: { canFitToViewer: ref(true) } }) + expect( + screen.getByRole('button', { name: 'Fit to viewer' }) + ).toBeInTheDocument() + }) + + it('hides the fit-to-viewer button when canFitToViewer is false', () => { + renderLoad3D({ stateOverrides: { canFitToViewer: ref(false) } }) + expect( + screen.queryByRole('button', { name: 'Fit to viewer' }) + ).not.toBeInTheDocument() + }) + + it('invokes handleFitToViewer when the fit button is clicked', async () => { + const { stub } = renderLoad3D() + const user = userEvent.setup() + + await user.click(screen.getByRole('button', { name: 'Fit to viewer' })) + + expect(stub.handleFitToViewer).toHaveBeenCalledOnce() + }) + }) + + describe('viewer controls', () => { + it('renders ViewerControls when the 3D viewer setting is enabled', () => { + renderLoad3D({ enable3DViewer: true }) + expect(screen.getByTestId('viewer-controls')).toBeInTheDocument() + }) + + it('hides ViewerControls when the 3D viewer setting is disabled', () => { + renderLoad3D({ enable3DViewer: false }) + expect(screen.queryByTestId('viewer-controls')).not.toBeInTheDocument() + }) + + it('hides ViewerControls when there is no node even if the setting is on', () => { + resolveNodeMock.mockReturnValue(null) + renderLoad3D({ widget: {}, nodeId: 1, enable3DViewer: true }) + expect(screen.queryByTestId('viewer-controls')).not.toBeInTheDocument() + }) + }) + + describe('recording controls', () => { + it('renders RecordingControls in regular (non-preview) mode', () => { + renderLoad3D({ stateOverrides: { isPreview: ref(false) } }) + expect(screen.getByTestId('recording-controls')).toBeInTheDocument() + }) + + it('hides RecordingControls in preview mode', () => { + renderLoad3D({ stateOverrides: { isPreview: ref(true) } }) + expect(screen.queryByTestId('recording-controls')).not.toBeInTheDocument() + }) + }) + + describe('animation controls', () => { + it('renders AnimationControls when animations are present', () => { + renderLoad3D({ + stateOverrides: { + animations: ref([{ name: 'idle', index: 0 }]) + } + }) + expect(screen.getByTestId('animation-controls')).toBeInTheDocument() + }) + + it('hides AnimationControls when the animation list is empty', () => { + renderLoad3D() + expect(screen.queryByTestId('animation-controls')).not.toBeInTheDocument() + }) + }) +}) diff --git a/src/components/load3d/Load3D.vue b/src/components/load3d/Load3D.vue index eea2148595..a0244a4fe8 100644 --- a/src/components/load3d/Load3D.vue +++ b/src/components/load3d/Load3D.vue @@ -22,8 +22,10 @@ v-model:model-config="modelConfig" v-model:camera-config="cameraConfig" v-model:light-config="lightConfig" - :is-splat-model="isSplatModel" - :is-ply-model="isPlyModel" + :can-use-gizmo="canUseGizmo" + :can-use-lighting="canUseLighting" + :can-export="canExport" + :material-modes="materialModes" :has-skeleton="hasSkeleton" @update-background-image="handleBackgroundImageUpdate" @export-model="handleExportModel" @@ -43,7 +45,10 @@ @seek="handleSeek" /> </div> - <div class="pointer-events-auto absolute top-12 right-2 z-20"> + <div + v-if="canFitToViewer" + class="pointer-events-auto absolute top-12 right-2 z-20" + > <div class="flex flex-col rounded-lg bg-backdrop/30"> <Button v-tooltip.left="{ @@ -138,8 +143,11 @@ const { // other state isRecording, isPreview, - isSplatModel, - isPlyModel, + canFitToViewer, + canUseGizmo, + canUseLighting, + canExport, + materialModes, hasSkeleton, hasRecording, recordingDuration, diff --git a/src/components/load3d/Load3DControls.test.ts b/src/components/load3d/Load3DControls.test.ts new file mode 100644 index 0000000000..edb429bd38 --- /dev/null +++ b/src/components/load3d/Load3DControls.test.ts @@ -0,0 +1,404 @@ +import { render, screen } from '@testing-library/vue' +import userEvent from '@testing-library/user-event' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createI18n } from 'vue-i18n' + +import Load3DControls from '@/components/load3d/Load3DControls.vue' +import type { + CameraConfig, + LightConfig, + MaterialMode, + ModelConfig, + SceneConfig +} from '@/extensions/core/load3d/interfaces' + +vi.mock('@/composables/useDismissableOverlay', () => ({ + useDismissableOverlay: vi.fn() +})) + +const i18n = createI18n({ + legacy: false, + locale: 'en', + messages: { + en: { + menu: { showMenu: 'Show menu' }, + load3d: { + scene: 'Scene', + model: 'Model', + camera: 'Camera', + light: 'Light', + gizmo: { label: 'Gizmo' }, + export: 'Export' + } + } + } +}) + +const childStubs = { + SceneControls: { + name: 'SceneControls', + emits: ['update-background-image'], + template: `<div data-testid="scene-controls"> + <button data-testid="scene-emit-bg" @click="$emit('update-background-image', null)" /> + </div>` + }, + ModelControls: { + name: 'ModelControls', + template: '<div data-testid="model-controls" />' + }, + CameraControls: { + name: 'CameraControls', + template: '<div data-testid="camera-controls" />' + }, + LightControls: { + name: 'LightControls', + template: '<div data-testid="light-controls" />' + }, + HDRIControls: { + name: 'HDRIControls', + emits: ['update-hdri-file'], + template: `<div data-testid="hdri-controls"> + <button data-testid="hdri-emit-file" @click="$emit('update-hdri-file', null)" /> + </div>` + }, + ExportControls: { + name: 'ExportControls', + emits: ['export-model'], + template: `<div data-testid="export-controls"> + <button data-testid="export-emit-glb" @click="$emit('export-model', 'glb')" /> + </div>` + }, + GizmoControls: { + name: 'GizmoControls', + emits: ['toggle-gizmo', 'set-gizmo-mode', 'reset-gizmo-transform'], + template: `<div data-testid="gizmo-controls"> + <button data-testid="gizmo-emit-toggle" @click="$emit('toggle-gizmo', true)" /> + <button data-testid="gizmo-emit-mode" @click="$emit('set-gizmo-mode', 'rotate')" /> + <button data-testid="gizmo-emit-reset" @click="$emit('reset-gizmo-transform')" /> + </div>` + } +} + +const defaultSceneConfig: SceneConfig = { + showGrid: true, + backgroundColor: '#000000', + backgroundImage: '', + backgroundRenderMode: 'tiled' +} + +const defaultModelConfig: ModelConfig = { + upDirection: 'original', + materialMode: 'original', + showSkeleton: false, + gizmo: { + enabled: false, + mode: 'translate', + position: { x: 0, y: 0, z: 0 }, + rotation: { x: 0, y: 0, z: 0 }, + scale: { x: 1, y: 1, z: 1 } + } +} + +const defaultCameraConfig: CameraConfig = { + cameraType: 'perspective', + fov: 75 +} + +const defaultLightConfig: LightConfig = { + intensity: 5, + hdri: { + enabled: false, + hdriPath: '', + showAsBackground: false, + intensity: 1 + } +} + +type RenderProps = { + sceneConfig?: SceneConfig + modelConfig?: ModelConfig + cameraConfig?: CameraConfig + lightConfig?: LightConfig + canUseGizmo?: boolean + canUseLighting?: boolean + canExport?: boolean + materialModes?: readonly MaterialMode[] + hasSkeleton?: boolean + onUpdateBackgroundImage?: (file: File | null) => void + onExportModel?: (format: string) => void + onUpdateHdriFile?: (file: File | null) => void + onToggleGizmo?: (enabled: boolean) => void + onSetGizmoMode?: (mode: string) => void + onResetGizmoTransform?: () => void +} + +function renderControls(overrides: RenderProps = {}) { + const result = render(Load3DControls, { + props: { + sceneConfig: defaultSceneConfig, + modelConfig: defaultModelConfig, + cameraConfig: defaultCameraConfig, + lightConfig: defaultLightConfig, + canUseGizmo: true, + canUseLighting: true, + canExport: true, + materialModes: ['original', 'normal', 'wireframe'], + hasSkeleton: false, + ...overrides + }, + global: { + plugins: [i18n], + stubs: childStubs, + directives: { + tooltip: () => {} + } + } + }) + return { ...result, user: userEvent.setup() } +} + +async function openMenu(user: ReturnType<typeof userEvent.setup>) { + await user.click(screen.getByRole('button', { name: 'Show menu' })) +} + +describe('Load3DControls', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('category menu', () => { + it('renders SceneControls by default', () => { + renderControls() + expect(screen.getByTestId('scene-controls')).toBeInTheDocument() + }) + + it('keeps the category menu closed until the trigger is clicked', async () => { + const { user } = renderControls() + + expect( + screen.queryByRole('button', { name: 'Scene' }) + ).not.toBeInTheDocument() + + await openMenu(user) + + expect(screen.getByRole('button', { name: 'Scene' })).toBeInTheDocument() + }) + + it('shows every category when all capabilities are enabled', async () => { + const { user } = renderControls() + await openMenu(user) + + for (const label of [ + 'Scene', + 'Model', + 'Camera', + 'Light', + 'Gizmo', + 'Export' + ]) { + expect(screen.getByRole('button', { name: label })).toBeInTheDocument() + } + }) + + it('omits the light category when canUseLighting is false', async () => { + const { user } = renderControls({ canUseLighting: false }) + await openMenu(user) + + expect( + screen.queryByRole('button', { name: 'Light' }) + ).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Scene' })).toBeInTheDocument() + }) + + it('omits the gizmo category when canUseGizmo is false', async () => { + const { user } = renderControls({ canUseGizmo: false }) + await openMenu(user) + + expect( + screen.queryByRole('button', { name: 'Gizmo' }) + ).not.toBeInTheDocument() + }) + + it('omits the export category when canExport is false', async () => { + const { user } = renderControls({ canExport: false }) + await openMenu(user) + + expect( + screen.queryByRole('button', { name: 'Export' }) + ).not.toBeInTheDocument() + }) + + it('selecting a category closes the menu and swaps the visible control', async () => { + const { user } = renderControls() + await openMenu(user) + + await user.click(screen.getByRole('button', { name: 'Model' })) + + expect( + screen.queryByRole('button', { name: 'Scene' }) + ).not.toBeInTheDocument() + expect(screen.getByTestId('model-controls')).toBeInTheDocument() + expect(screen.queryByTestId('scene-controls')).not.toBeInTheDocument() + }) + }) + + describe('control visibility', () => { + async function selectCategory( + user: ReturnType<typeof userEvent.setup>, + label: string + ) { + await openMenu(user) + await user.click(screen.getByRole('button', { name: label })) + } + + it.each([ + ['Model', 'model-controls'], + ['Camera', 'camera-controls'] + ])('%s category renders only %s', async (label, testId) => { + const { user } = renderControls() + await selectCategory(user, label) + + expect(screen.getByTestId(testId)).toBeInTheDocument() + expect(screen.queryByTestId('scene-controls')).not.toBeInTheDocument() + }) + + it('Light category renders both LightControls and HDRIControls', async () => { + const { user } = renderControls() + await selectCategory(user, 'Light') + + expect(screen.getByTestId('light-controls')).toBeInTheDocument() + expect(screen.getByTestId('hdri-controls')).toBeInTheDocument() + }) + + it('Gizmo category renders GizmoControls', async () => { + const { user } = renderControls() + await selectCategory(user, 'Gizmo') + + expect(screen.getByTestId('gizmo-controls')).toBeInTheDocument() + }) + + it('Export category renders ExportControls', async () => { + const { user } = renderControls() + await selectCategory(user, 'Export') + + expect(screen.getByTestId('export-controls')).toBeInTheDocument() + }) + + it('hides all controls when the corresponding v-model is undefined', () => { + renderControls({ + sceneConfig: undefined, + modelConfig: undefined, + cameraConfig: undefined, + lightConfig: undefined + }) + + expect(screen.queryByTestId('scene-controls')).not.toBeInTheDocument() + }) + }) + + describe('capability desync handling', () => { + it('hides the active panel and resets to scene when its capability is dropped at runtime', async () => { + const { user, rerender } = renderControls() + + await openMenu(user) + await user.click(screen.getByRole('button', { name: 'Light' })) + expect(screen.getByTestId('light-controls')).toBeInTheDocument() + + await rerender({ canUseLighting: false }) + + expect(screen.queryByTestId('light-controls')).not.toBeInTheDocument() + expect(screen.getByTestId('scene-controls')).toBeInTheDocument() + + await openMenu(user) + expect( + screen.queryByRole('button', { name: 'Light' }) + ).not.toBeInTheDocument() + }) + + it.each([ + ['Gizmo', 'gizmo-controls', 'canUseGizmo' as const], + ['Export', 'export-controls', 'canExport' as const] + ])( + 'hides the %s panel when its capability flips off at runtime', + async (label, testId, capabilityProp) => { + const { user, rerender } = renderControls() + + await openMenu(user) + await user.click(screen.getByRole('button', { name: label })) + expect(screen.getByTestId(testId)).toBeInTheDocument() + + await rerender({ [capabilityProp]: false }) + + expect(screen.queryByTestId(testId)).not.toBeInTheDocument() + expect(screen.getByTestId('scene-controls')).toBeInTheDocument() + } + ) + + it('does not reset activeCategory when capabilities change but the active one is still available', async () => { + const { user, rerender } = renderControls() + + await openMenu(user) + await user.click(screen.getByRole('button', { name: 'Camera' })) + expect(screen.getByTestId('camera-controls')).toBeInTheDocument() + + await rerender({ canUseLighting: false, canUseGizmo: false }) + + expect(screen.getByTestId('camera-controls')).toBeInTheDocument() + expect(screen.queryByTestId('scene-controls')).not.toBeInTheDocument() + }) + }) + + describe('event forwarding', () => { + it('forwards updateBackgroundImage from SceneControls', async () => { + const onUpdateBackgroundImage = vi.fn() + const { user } = renderControls({ onUpdateBackgroundImage }) + + await user.click(screen.getByTestId('scene-emit-bg')) + + expect(onUpdateBackgroundImage).toHaveBeenCalledWith(null) + }) + + it('forwards exportModel from ExportControls', async () => { + const onExportModel = vi.fn() + const { user } = renderControls({ onExportModel }) + await openMenu(user) + await user.click(screen.getByRole('button', { name: 'Export' })) + + await user.click(screen.getByTestId('export-emit-glb')) + + expect(onExportModel).toHaveBeenCalledWith('glb') + }) + + it('forwards updateHdriFile from HDRIControls', async () => { + const onUpdateHdriFile = vi.fn() + const { user } = renderControls({ onUpdateHdriFile }) + await openMenu(user) + await user.click(screen.getByRole('button', { name: 'Light' })) + + await user.click(screen.getByTestId('hdri-emit-file')) + + expect(onUpdateHdriFile).toHaveBeenCalledWith(null) + }) + + it('forwards gizmo events from GizmoControls', async () => { + const onToggleGizmo = vi.fn() + const onSetGizmoMode = vi.fn() + const onResetGizmoTransform = vi.fn() + const { user } = renderControls({ + onToggleGizmo, + onSetGizmoMode, + onResetGizmoTransform + }) + await openMenu(user) + await user.click(screen.getByRole('button', { name: 'Gizmo' })) + + await user.click(screen.getByTestId('gizmo-emit-toggle')) + await user.click(screen.getByTestId('gizmo-emit-mode')) + await user.click(screen.getByTestId('gizmo-emit-reset')) + + expect(onToggleGizmo).toHaveBeenCalledWith(true) + expect(onSetGizmoMode).toHaveBeenCalledWith('rotate') + expect(onResetGizmoTransform).toHaveBeenCalledOnce() + }) + }) +}) diff --git a/src/components/load3d/Load3DControls.vue b/src/components/load3d/Load3DControls.vue index f23c18368c..2fb1efd458 100644 --- a/src/components/load3d/Load3DControls.vue +++ b/src/components/load3d/Load3DControls.vue @@ -63,8 +63,7 @@ v-model:material-mode="modelConfig!.materialMode" v-model:up-direction="modelConfig!.upDirection" v-model:show-skeleton="modelConfig!.showSkeleton" - :hide-material-mode="isSplatModel" - :is-ply-model="isPlyModel" + :material-modes="materialModes" :has-skeleton="hasSkeleton" /> @@ -105,7 +104,7 @@ </template> <script setup lang="ts"> -import { computed, ref } from 'vue' +import { computed, ref, watch } from 'vue' import CameraControls from '@/components/load3d/controls/CameraControls.vue' import { useDismissableOverlay } from '@/composables/useDismissableOverlay' @@ -120,18 +119,23 @@ import type { CameraConfig, GizmoMode, LightConfig, + MaterialMode, ModelConfig, SceneConfig } from '@/extensions/core/load3d/interfaces' import { cn } from '@comfyorg/tailwind-utils' const { - isSplatModel = false, - isPlyModel = false, + canUseGizmo = true, + canUseLighting = true, + canExport = true, + materialModes = ['original', 'normal', 'wireframe'], hasSkeleton = false } = defineProps<{ - isSplatModel?: boolean - isPlyModel?: boolean + canUseGizmo?: boolean + canUseLighting?: boolean + canExport?: boolean + materialModes?: readonly MaterialMode[] hasSkeleton?: boolean }>() @@ -163,13 +167,23 @@ const categoryLabels: Record<string, string> = { } const availableCategories = computed(() => { - if (isSplatModel) { - return ['scene', 'model', 'camera'] - } - - return ['scene', 'model', 'camera', 'light', 'gizmo', 'export'] + const categories = ['scene', 'model', 'camera'] + if (canUseLighting) categories.push('light') + if (canUseGizmo) categories.push('gizmo') + if (canExport) categories.push('export') + return categories }) +watch( + availableCategories, + (categories) => { + if (!categories.includes(activeCategory.value)) { + activeCategory.value = 'scene' + } + }, + { immediate: true } +) + const showSceneControls = computed( () => activeCategory.value === 'scene' && !!sceneConfig.value ) @@ -181,13 +195,16 @@ const showCameraControls = computed( ) const showLightControls = computed( () => + canUseLighting && activeCategory.value === 'light' && !!lightConfig.value && !!modelConfig.value ) -const showExportControls = computed(() => activeCategory.value === 'export') +const showExportControls = computed( + () => canExport && activeCategory.value === 'export' +) const showGizmoControls = computed( - () => activeCategory.value === 'gizmo' && !!modelConfig.value + () => canUseGizmo && activeCategory.value === 'gizmo' && !!modelConfig.value ) const toggleMenu = () => { diff --git a/src/components/load3d/Load3DScene.test.ts b/src/components/load3d/Load3DScene.test.ts new file mode 100644 index 0000000000..a45c9d3fe0 --- /dev/null +++ b/src/components/load3d/Load3DScene.test.ts @@ -0,0 +1,153 @@ +import { render, screen } from '@testing-library/vue' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { Ref } from 'vue' +import { ref } from 'vue' + +import Load3DScene from '@/components/load3d/Load3DScene.vue' + +const dragState = vi.hoisted(() => ({ + isDragging: null as Ref<boolean> | null, + dragMessage: null as Ref<string> | null, + handleDragOver: vi.fn(), + handleDragLeave: vi.fn(), + handleDrop: vi.fn(), + capturedOptions: null as { + onModelDrop?: (file: File) => Promise<void> + disabled?: { value?: boolean } | boolean + } | null +})) + +vi.mock('@/composables/useLoad3dDrag', () => ({ + useLoad3dDrag: (options: unknown) => { + dragState.capturedOptions = options as typeof dragState.capturedOptions + return { + isDragging: dragState.isDragging!, + dragMessage: dragState.dragMessage!, + handleDragOver: dragState.handleDragOver, + handleDragLeave: dragState.handleDragLeave, + handleDrop: dragState.handleDrop + } + } +})) + +vi.mock('@/components/common/LoadingOverlay.vue', () => ({ + default: { + name: 'LoadingOverlayStub', + props: ['loading', 'loadingMessage'], + template: ` + <div data-testid="loading-overlay"> + <span v-if="loading">{{ loadingMessage }}</span> + </div> + ` + } +})) + +type RenderOpts = { + loading?: boolean + loadingMessage?: string + isPreview?: boolean + onModelDrop?: (file: File) => void | Promise<void> + initializeLoad3d?: (container: HTMLElement) => Promise<void> + cleanup?: () => void +} + +function renderComponent(opts: RenderOpts = {}) { + const initializeLoad3d = + opts.initializeLoad3d ?? vi.fn().mockResolvedValue(undefined) + const cleanup = opts.cleanup ?? vi.fn() + + const utils = render(Load3DScene, { + props: { + initializeLoad3d, + cleanup, + loading: opts.loading ?? false, + loadingMessage: opts.loadingMessage ?? '', + onModelDrop: opts.onModelDrop, + isPreview: opts.isPreview ?? false + } + }) + + return { ...utils, initializeLoad3d, cleanup } +} + +describe('Load3DScene', () => { + beforeEach(() => { + dragState.isDragging = ref(false) + dragState.dragMessage = ref('') + dragState.handleDragOver.mockReset() + dragState.handleDragLeave.mockReset() + dragState.handleDrop.mockReset() + dragState.capturedOptions = null + }) + + it('renders the loading overlay child', () => { + renderComponent() + expect(screen.getByTestId('loading-overlay')).toBeInTheDocument() + }) + + it('forwards loading + loadingMessage props to the overlay', () => { + renderComponent({ loading: true, loadingMessage: 'Loading model…' }) + + expect(screen.getByText('Loading model…')).toBeInTheDocument() + }) + + it('calls initializeLoad3d with the container element on mount', async () => { + const initializeLoad3d = vi.fn().mockResolvedValue(undefined) + renderComponent({ initializeLoad3d }) + + expect(initializeLoad3d).toHaveBeenCalledOnce() + expect(initializeLoad3d.mock.calls[0][0]).toBeInstanceOf(HTMLElement) + }) + + it('calls cleanup when unmounted', () => { + const cleanup = vi.fn() + const { unmount } = renderComponent({ cleanup }) + + unmount() + + expect(cleanup).toHaveBeenCalledOnce() + }) + + it('does not render the drag overlay when not dragging', () => { + dragState.isDragging!.value = false + dragState.dragMessage!.value = 'Drop' + renderComponent() + + expect(screen.queryByText('Drop')).not.toBeInTheDocument() + }) + + it('renders the drag overlay with the drag message while dragging in non-preview mode', () => { + dragState.isDragging!.value = true + dragState.dragMessage!.value = 'Drop to load model' + renderComponent({ isPreview: false }) + + expect(screen.getByText('Drop to load model')).toBeInTheDocument() + }) + + it('hides the drag overlay even while dragging when in preview mode', () => { + dragState.isDragging!.value = true + dragState.dragMessage!.value = 'Drop to load model' + renderComponent({ isPreview: true }) + + expect(screen.queryByText('Drop to load model')).not.toBeInTheDocument() + }) + + it('forwards a dropped file through useLoad3dDrag to the onModelDrop prop', async () => { + const onModelDrop = vi.fn() + renderComponent({ onModelDrop }) + + const file = new File(['m'], 'model.glb') + await dragState.capturedOptions!.onModelDrop!(file) + + expect(onModelDrop).toHaveBeenCalledWith(file) + }) + + it('does not throw when a file is dropped without an onModelDrop handler', async () => { + renderComponent({ onModelDrop: undefined }) + + const file = new File(['m'], 'model.glb') + await expect( + dragState.capturedOptions!.onModelDrop!(file) + ).resolves.toBeUndefined() + }) +}) diff --git a/src/components/load3d/Load3dViewerContent.test.ts b/src/components/load3d/Load3dViewerContent.test.ts new file mode 100644 index 0000000000..b5bf6b5fb9 --- /dev/null +++ b/src/components/load3d/Load3dViewerContent.test.ts @@ -0,0 +1,361 @@ +import { render, screen } from '@testing-library/vue' +import userEvent from '@testing-library/user-event' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { ref } from 'vue' +import { createI18n } from 'vue-i18n' + +import Load3dViewerContent from '@/components/load3d/Load3dViewerContent.vue' +import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode' +import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils' + +class NoopMutationObserver { + observe() {} + disconnect() {} + takeRecords(): MutationRecord[] { + return [] + } +} + +const { + viewerState, + dragState, + capturedDragOptions, + dialogCloseMock, + serviceSourceLoad3d, + getLoad3dAsyncMock +} = vi.hoisted(() => ({ + viewerState: { + current: null as ReturnType<typeof buildViewerStub> | null + }, + dragState: { + current: null as ReturnType<typeof buildDragStub> | null + }, + capturedDragOptions: { + current: null as { onModelDrop?: (file: File) => Promise<void> } | null + }, + dialogCloseMock: vi.fn(), + serviceSourceLoad3d: { + current: null as unknown + }, + getLoad3dAsyncMock: vi.fn() +})) + +function buildViewerStub() { + return { + backgroundColor: ref('#282828'), + showGrid: ref(true), + cameraType: ref('perspective'), + fov: ref(75), + lightIntensity: ref(1), + backgroundImage: ref(''), + hasBackgroundImage: ref(false), + backgroundRenderMode: ref('tiled'), + upDirection: ref('original'), + materialMode: ref('original'), + gizmoEnabled: ref(false), + gizmoMode: ref('translate'), + isPreview: ref(false), + isStandaloneMode: ref(false), + canUseGizmo: ref(true), + canUseLighting: ref(true), + canExport: ref(true), + materialModes: ref(['original', 'normal', 'wireframe']), + animations: ref<Array<{ name: string; index: number }>>([]), + playing: ref(false), + selectedSpeed: ref(1), + selectedAnimation: ref(0), + animationProgress: ref(0), + animationDuration: ref(0), + initializeViewer: vi.fn().mockResolvedValue(undefined), + initializeStandaloneViewer: vi.fn().mockResolvedValue(undefined), + exportModel: vi.fn(), + handleResize: vi.fn(), + handleMouseEnter: vi.fn(), + handleMouseLeave: vi.fn(), + restoreInitialState: vi.fn(), + refreshViewport: vi.fn(), + handleBackgroundImageUpdate: vi.fn(), + handleModelDrop: vi.fn().mockResolvedValue(undefined), + handleSeek: vi.fn(), + resetGizmoTransform: vi.fn() + } +} + +function buildDragStub() { + return { + isDragging: ref(false), + dragMessage: ref(''), + handleDragOver: vi.fn(), + handleDragLeave: vi.fn(), + handleDrop: vi.fn() + } +} + +vi.mock('@/composables/useLoad3dViewer', () => ({ + useLoad3dViewer: () => viewerState.current +})) + +vi.mock('@/composables/useLoad3dDrag', () => ({ + useLoad3dDrag: (opts: { onModelDrop?: (file: File) => Promise<void> }) => { + capturedDragOptions.current = opts + return dragState.current + } +})) + +vi.mock('@/services/load3dService', () => ({ + useLoad3dService: () => ({ + getOrCreateViewerSync: () => viewerState.current, + getLoad3dAsync: getLoad3dAsyncMock + }) +})) + +vi.mock('@/stores/dialogStore', () => ({ + useDialogStore: () => ({ closeDialog: dialogCloseMock }) +})) + +const i18n = createI18n({ + legacy: false, + locale: 'en', + messages: { + en: { + g: { cancel: 'Cancel' } + } + } +}) + +type RenderOptions = { + node?: LGraphNode + modelUrl?: string + viewerOverrides?: Partial<ReturnType<typeof buildViewerStub>> + dragOverrides?: Partial<ReturnType<typeof buildDragStub>> +} + +const MOCK_NODE = createMockLGraphNode({ id: 'node-1', type: 'Load3D' }) + +async function renderViewerContent(options: RenderOptions = {}) { + const viewerStub = buildViewerStub() + if (options.viewerOverrides) { + Object.assign(viewerStub, options.viewerOverrides) + } + viewerState.current = viewerStub + + const dragStub = buildDragStub() + if (options.dragOverrides) { + Object.assign(dragStub, options.dragOverrides) + } + dragState.current = dragStub + + getLoad3dAsyncMock.mockResolvedValue(serviceSourceLoad3d.current) + + const result = render(Load3dViewerContent, { + props: { + node: options.node, + modelUrl: options.modelUrl + }, + global: { + plugins: [i18n], + stubs: { + AnimationControls: { + name: 'AnimationControls', + template: '<div data-testid="animation-controls" />' + }, + CameraControls: { + name: 'CameraControls', + template: '<div data-testid="camera-controls" />' + }, + ExportControls: { + name: 'ExportControls', + template: '<div data-testid="export-controls" />' + }, + GizmoControls: { + name: 'GizmoControls', + template: '<div data-testid="gizmo-controls" />' + }, + LightControls: { + name: 'LightControls', + template: '<div data-testid="light-controls" />' + }, + ModelControls: { + name: 'ModelControls', + template: '<div data-testid="model-controls" />' + }, + SceneControls: { + name: 'SceneControls', + template: '<div data-testid="scene-controls" />' + }, + Button: { + name: 'Button', + template: '<button type="button"><slot /></button>' + } + } + } + }) + return { + ...result, + viewer: viewerStub, + drag: dragStub, + user: userEvent.setup() + } +} + +describe('Load3dViewerContent', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.stubGlobal('MutationObserver', NoopMutationObserver) + viewerState.current = null + dragState.current = null + capturedDragOptions.current = null + serviceSourceLoad3d.current = null + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + describe('initialization', () => { + it('invokes initializeStandaloneViewer when a modelUrl is provided without a node', async () => { + const { viewer } = await renderViewerContent({ + modelUrl: 'api/view?filename=cube.glb' + }) + + await vi.waitFor(() => + expect(viewer.initializeStandaloneViewer).toHaveBeenCalledWith( + expect.any(HTMLElement), + 'api/view?filename=cube.glb' + ) + ) + expect(viewer.initializeViewer).not.toHaveBeenCalled() + }) + + it('invokes initializeViewer with the source load3d when a node is provided', async () => { + const source = { id: 'source-load3d' } + serviceSourceLoad3d.current = source + const { viewer } = await renderViewerContent({ node: MOCK_NODE }) + + await vi.waitFor(() => + expect(viewer.initializeViewer).toHaveBeenCalledWith( + expect.any(HTMLElement), + source + ) + ) + expect(getLoad3dAsyncMock).toHaveBeenCalledWith(MOCK_NODE) + expect(viewer.initializeStandaloneViewer).not.toHaveBeenCalled() + }) + + it('skips initializeViewer if the source load3d cannot be resolved', async () => { + serviceSourceLoad3d.current = null + const { viewer } = await renderViewerContent({ node: MOCK_NODE }) + + await vi.waitFor(() => + expect(getLoad3dAsyncMock).toHaveBeenCalledWith(MOCK_NODE) + ) + expect(viewer.initializeViewer).not.toHaveBeenCalled() + }) + }) + + describe('capability gating', () => { + it('hides LightControls when canUseLighting is false', async () => { + await renderViewerContent({ + node: MOCK_NODE, + viewerOverrides: { canUseLighting: ref(false) } + }) + + expect(screen.queryByTestId('light-controls')).not.toBeInTheDocument() + }) + + it('hides GizmoControls when canUseGizmo is false', async () => { + await renderViewerContent({ + node: MOCK_NODE, + viewerOverrides: { canUseGizmo: ref(false) } + }) + + expect(screen.queryByTestId('gizmo-controls')).not.toBeInTheDocument() + }) + + it('hides ExportControls when canExport is false', async () => { + await renderViewerContent({ + node: MOCK_NODE, + viewerOverrides: { canExport: ref(false) } + }) + + expect(screen.queryByTestId('export-controls')).not.toBeInTheDocument() + }) + + it('renders all capability-gated controls when all flags are true', async () => { + await renderViewerContent({ node: MOCK_NODE }) + + expect(screen.getByTestId('light-controls')).toBeInTheDocument() + expect(screen.getByTestId('gizmo-controls')).toBeInTheDocument() + expect(screen.getByTestId('export-controls')).toBeInTheDocument() + }) + }) + + describe('animation controls', () => { + it('hides AnimationControls when the animation list is empty', async () => { + await renderViewerContent({ node: MOCK_NODE }) + expect(screen.queryByTestId('animation-controls')).not.toBeInTheDocument() + }) + + it('shows AnimationControls when animations are present', async () => { + await renderViewerContent({ + node: MOCK_NODE, + viewerOverrides: { + animations: ref([{ name: 'idle', index: 0 }]) + } + }) + expect(screen.getByTestId('animation-controls')).toBeInTheDocument() + }) + }) + + describe('drag overlay', () => { + it('is hidden by default', async () => { + await renderViewerContent({ node: MOCK_NODE }) + expect(screen.queryByText(/drag/i)).not.toBeInTheDocument() + }) + + it('renders the drag message when useLoad3dDrag reports dragging', async () => { + await renderViewerContent({ + node: MOCK_NODE, + dragOverrides: { + isDragging: ref(true), + dragMessage: ref('Drop to load') + } + }) + + expect(screen.getByText('Drop to load')).toBeInTheDocument() + }) + }) + + describe('drag integration', () => { + it('routes a dropped file through useLoad3dDrag back to viewer.handleModelDrop', async () => { + const { viewer } = await renderViewerContent({ node: MOCK_NODE }) + const file = new File(['cube'], 'cube.glb') + + await capturedDragOptions.current!.onModelDrop!(file) + + expect(viewer.handleModelDrop).toHaveBeenCalledWith(file) + }) + }) + + describe('cancel button', () => { + it('closes the dialog in node mode and restores initial viewer state', async () => { + const { user, viewer } = await renderViewerContent({ node: MOCK_NODE }) + + await user.click(screen.getByRole('button', { name: /Cancel/ })) + + expect(viewer.restoreInitialState).toHaveBeenCalledOnce() + expect(dialogCloseMock).toHaveBeenCalledOnce() + }) + + it('closes the dialog in standalone mode without touching initial state', async () => { + const { user, viewer } = await renderViewerContent({ + modelUrl: 'api/view?filename=cube.glb' + }) + + await user.click(screen.getByRole('button', { name: /Cancel/ })) + + expect(viewer.restoreInitialState).not.toHaveBeenCalled() + expect(dialogCloseMock).toHaveBeenCalledOnce() + }) + }) +}) diff --git a/src/components/load3d/Load3dViewerContent.vue b/src/components/load3d/Load3dViewerContent.vue index b57bf38cc5..c2730ddd82 100644 --- a/src/components/load3d/Load3dViewerContent.vue +++ b/src/components/load3d/Load3dViewerContent.vue @@ -56,8 +56,7 @@ <ModelControls v-model:up-direction="viewer.upDirection.value" v-model:material-mode="viewer.materialMode.value" - :hide-material-mode="viewer.isSplatModel.value" - :is-ply-model="viewer.isPlyModel.value" + :material-modes="viewer.materialModes.value" /> </div> @@ -68,13 +67,13 @@ /> </div> - <div v-if="!viewer.isSplatModel.value" class="space-y-4 p-2"> + <div v-if="viewer.canUseLighting.value" class="space-y-4 p-2"> <LightControls v-model:light-intensity="viewer.lightIntensity.value" /> </div> - <div class="space-y-4 p-2"> + <div v-if="viewer.canUseGizmo.value" class="space-y-4 p-2"> <GizmoControls v-model:gizmo-enabled="viewer.gizmoEnabled.value" v-model:gizmo-mode="viewer.gizmoMode.value" @@ -82,7 +81,7 @@ /> </div> - <div v-if="!viewer.isSplatModel.value" class="space-y-4 p-2"> + <div v-if="viewer.canExport.value" class="space-y-4 p-2"> <ExportControls @export-model="viewer.exportModel" /> </div> </div> diff --git a/src/components/load3d/controls/AnimationControls.test.ts b/src/components/load3d/controls/AnimationControls.test.ts new file mode 100644 index 0000000000..ae1dbbb9b4 --- /dev/null +++ b/src/components/load3d/controls/AnimationControls.test.ts @@ -0,0 +1,205 @@ +import { render, screen } from '@testing-library/vue' +import userEvent from '@testing-library/user-event' +import { describe, expect, it, vi } from 'vitest' +import { ref } from 'vue' +import { createI18n } from 'vue-i18n' + +import AnimationControls from '@/components/load3d/controls/AnimationControls.vue' + +vi.mock('primevue/select', () => ({ + default: { + name: 'Select', + props: ['modelValue', 'options', 'optionLabel', 'optionValue'], + emits: ['update:modelValue'], + template: ` + <select + :value="modelValue" + @change="$emit('update:modelValue', isNaN(Number($event.target.value)) ? $event.target.value : Number($event.target.value))" + > + <option v-for="opt in options" :key="opt[optionValue]" :value="opt[optionValue]"> + {{ opt[optionLabel] }} + </option> + </select> + ` + } +})) + +vi.mock('@/components/ui/slider/Slider.vue', () => ({ + default: { + name: 'UiSlider', + props: ['modelValue', 'min', 'max', 'step'], + emits: ['update:modelValue'], + template: ` + <input + type="range" + role="slider" + :value="Array.isArray(modelValue) ? modelValue[0] : modelValue" + :min="min" + :max="max" + :step="step" + @input="$emit('update:modelValue', [Number($event.target.value)])" + /> + ` + } +})) + +const i18n = createI18n({ + legacy: false, + locale: 'en', + messages: { en: { g: { playPause: 'Play / pause' } } } +}) + +type Animation = { name: string; index: number } + +type RenderOpts = { + animations?: Animation[] + playing?: boolean + selectedSpeed?: number + selectedAnimation?: number + animationProgress?: number + animationDuration?: number + onSeek?: (progress: number) => void +} + +function renderComponent(opts: RenderOpts = {}) { + const animations = ref<Animation[]>(opts.animations ?? []) + const playing = ref<boolean>(opts.playing ?? false) + const selectedSpeed = ref<number>(opts.selectedSpeed ?? 1) + const selectedAnimation = ref<number>(opts.selectedAnimation ?? 0) + const animationProgress = ref<number>(opts.animationProgress ?? 0) + const animationDuration = ref<number>(opts.animationDuration ?? 10) + + const utils = render(AnimationControls, { + props: { + animations: animations.value, + 'onUpdate:animations': (v: Animation[] | undefined) => { + if (v) animations.value = v + }, + playing: playing.value, + 'onUpdate:playing': (v: boolean | undefined) => { + if (v !== undefined) playing.value = v + }, + selectedSpeed: selectedSpeed.value, + 'onUpdate:selectedSpeed': (v: number | undefined) => { + if (v !== undefined) selectedSpeed.value = v + }, + selectedAnimation: selectedAnimation.value, + 'onUpdate:selectedAnimation': (v: number | undefined) => { + if (v !== undefined) selectedAnimation.value = v + }, + animationProgress: animationProgress.value, + 'onUpdate:animationProgress': (v: number | undefined) => { + if (v !== undefined) animationProgress.value = v + }, + animationDuration: animationDuration.value, + 'onUpdate:animationDuration': (v: number | undefined) => { + if (v !== undefined) animationDuration.value = v + }, + onSeek: opts.onSeek + }, + global: { plugins: [i18n] } + }) + + return { + ...utils, + animations, + playing, + selectedSpeed, + selectedAnimation, + animationProgress, + user: userEvent.setup() + } +} + +describe('AnimationControls', () => { + it('renders nothing when the animation list is empty', () => { + renderComponent({ animations: [] }) + + expect( + screen.queryByRole('button', { name: 'Play / pause' }) + ).not.toBeInTheDocument() + }) + + it('renders the play / speed / track / progress widgets when animations are present', () => { + renderComponent({ + animations: [ + { name: 'idle', index: 0 }, + { name: 'walk', index: 1 } + ] + }) + + expect( + screen.getByRole('button', { name: 'Play / pause' }) + ).toBeInTheDocument() + expect(screen.getAllByRole('combobox')).toHaveLength(2) + expect(screen.getByRole('slider')).toBeInTheDocument() + }) + + it('flips playing to true via v-model when starting from a paused state', async () => { + const { user, playing } = renderComponent({ + animations: [{ name: 'idle', index: 0 }], + playing: false + }) + + await user.click(screen.getByRole('button', { name: 'Play / pause' })) + + expect(playing.value).toBe(true) + }) + + it('flips playing to false via v-model when starting from a playing state', async () => { + const { user, playing } = renderComponent({ + animations: [{ name: 'idle', index: 0 }], + playing: true + }) + + await user.click(screen.getByRole('button', { name: 'Play / pause' })) + + expect(playing.value).toBe(false) + }) + + it('updates animationProgress and emits seek with the new progress when the slider moves', () => { + const onSeek = vi.fn() + const { animationProgress } = renderComponent({ + animations: [{ name: 'idle', index: 0 }], + animationProgress: 0, + onSeek + }) + + const slider = screen.getByRole('slider') as HTMLInputElement + slider.value = '37.5' + slider.dispatchEvent(new Event('input', { bubbles: true })) + + expect(animationProgress.value).toBe(37.5) + expect(onSeek).toHaveBeenCalledWith(37.5) + }) + + it('formats the time display under one minute as Ns', () => { + renderComponent({ + animations: [{ name: 'idle', index: 0 }], + animationDuration: 30, + animationProgress: 50 // half of 30s = 15s + }) + + expect(screen.getByText('15.0s / 30.0s')).toBeInTheDocument() + }) + + it('formats the time display over one minute as M:SS.S', () => { + renderComponent({ + animations: [{ name: 'idle', index: 0 }], + animationDuration: 90, + animationProgress: 50 // half of 90s = 45s, total 1:30.0 + }) + + expect(screen.getByText('45.0s / 1:30.0')).toBeInTheDocument() + }) + + it('shows 0s for currentTime when animationDuration is 0', () => { + renderComponent({ + animations: [{ name: 'idle', index: 0 }], + animationDuration: 0, + animationProgress: 50 + }) + + expect(screen.getByText('0.0s / 0.0s')).toBeInTheDocument() + }) +}) diff --git a/src/components/load3d/controls/CameraControls.test.ts b/src/components/load3d/controls/CameraControls.test.ts new file mode 100644 index 0000000000..964663f6e0 --- /dev/null +++ b/src/components/load3d/controls/CameraControls.test.ts @@ -0,0 +1,84 @@ +import { render, screen } from '@testing-library/vue' +import userEvent from '@testing-library/user-event' +import { describe, expect, it, vi } from 'vitest' +import { ref } from 'vue' +import { createI18n } from 'vue-i18n' + +import CameraControls from '@/components/load3d/controls/CameraControls.vue' +import type { CameraType } from '@/extensions/core/load3d/interfaces' + +vi.mock('@/components/load3d/controls/PopupSlider.vue', () => ({ + default: { + name: 'PopupSlider', + props: ['tooltipText', 'modelValue'], + template: '<div data-testid="popup-slider">{{ tooltipText }}</div>' + } +})) + +const i18n = createI18n({ + legacy: false, + locale: 'en', + messages: { + en: { load3d: { switchCamera: 'Switch camera', fov: 'FOV' } } + } +}) + +function renderComponent(initial: { type?: CameraType; fov?: number } = {}) { + const cameraType = ref<CameraType>(initial.type ?? 'perspective') + const fov = ref<number>(initial.fov ?? 75) + + const utils = render(CameraControls, { + props: { + cameraType: cameraType.value, + 'onUpdate:cameraType': (v: CameraType | undefined) => { + if (v) cameraType.value = v + }, + fov: fov.value, + 'onUpdate:fov': (v: number | undefined) => { + if (v !== undefined) fov.value = v + } + }, + global: { + plugins: [i18n], + directives: { tooltip: () => {} } + } + }) + + return { ...utils, cameraType, fov, user: userEvent.setup() } +} + +describe('CameraControls', () => { + it('renders the switch-camera button', () => { + renderComponent() + + expect( + screen.getByRole('button', { name: 'Switch camera' }) + ).toBeInTheDocument() + }) + + it('shows the FOV PopupSlider only for the perspective camera', () => { + renderComponent({ type: 'perspective' }) + expect(screen.getByTestId('popup-slider')).toBeInTheDocument() + }) + + it('hides the FOV PopupSlider for the orthographic camera', () => { + renderComponent({ type: 'orthographic' }) + expect(screen.queryByTestId('popup-slider')).not.toBeInTheDocument() + }) + + it('toggles cameraType from perspective to orthographic when the button is clicked', async () => { + const { user, cameraType } = renderComponent({ type: 'perspective' }) + + await user.click(screen.getByRole('button', { name: 'Switch camera' })) + + expect(cameraType.value).toBe('orthographic') + }) + + it('toggles cameraType from orthographic to perspective when the button is clicked', async () => { + const { user, cameraType } = renderComponent({ type: 'orthographic' }) + + await user.click(screen.getByRole('button', { name: 'Switch camera' })) + + expect(cameraType.value).toBe('perspective') + }) +}) diff --git a/src/components/load3d/controls/ExportControls.test.ts b/src/components/load3d/controls/ExportControls.test.ts new file mode 100644 index 0000000000..b1296a09e4 --- /dev/null +++ b/src/components/load3d/controls/ExportControls.test.ts @@ -0,0 +1,78 @@ +import { render, screen } from '@testing-library/vue' +import userEvent from '@testing-library/user-event' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { createI18n } from 'vue-i18n' + +import ExportControls from '@/components/load3d/controls/ExportControls.vue' + +const i18n = createI18n({ + legacy: false, + locale: 'en', + messages: { + en: { load3d: { exportModel: 'Export model' } } + } +}) + +function renderComponent(onExportModel?: (format: string) => void) { + const utils = render(ExportControls, { + props: { onExportModel }, + global: { + plugins: [i18n], + directives: { tooltip: () => {} } + } + }) + return { ...utils, user: userEvent.setup() } +} + +describe('ExportControls', () => { + afterEach(() => { + document.body.innerHTML = '' + }) + + it('renders the trigger button without exposing the format list initially', () => { + renderComponent() + + expect( + screen.getByRole('button', { name: 'Export model' }) + ).toBeInTheDocument() + expect( + screen.queryByRole('button', { name: 'GLB' }) + ).not.toBeInTheDocument() + }) + + it('reveals all three export format buttons when the trigger is clicked', async () => { + const { user } = renderComponent() + + await user.click(screen.getByRole('button', { name: 'Export model' })) + + for (const label of ['GLB', 'OBJ', 'STL']) { + expect(screen.getByRole('button', { name: label })).toBeVisible() + } + }) + + it('emits exportModel with the chosen format and hides the popup', async () => { + const onExportModel = vi.fn() + const { user } = renderComponent(onExportModel) + + await user.click(screen.getByRole('button', { name: 'Export model' })) + await user.click(screen.getByRole('button', { name: 'OBJ' })) + + expect(onExportModel).toHaveBeenCalledWith('obj') + expect( + screen.queryByRole('button', { name: 'GLB' }) + ).not.toBeInTheDocument() + }) + + it('hides the popup when a click happens outside the trigger', async () => { + const { user } = renderComponent() + + await user.click(screen.getByRole('button', { name: 'Export model' })) + expect(screen.getByRole('button', { name: 'GLB' })).toBeVisible() + + await user.click(document.body) + + expect( + screen.queryByRole('button', { name: 'GLB' }) + ).not.toBeInTheDocument() + }) +}) diff --git a/src/components/load3d/controls/HDRIControls.test.ts b/src/components/load3d/controls/HDRIControls.test.ts new file mode 100644 index 0000000000..1729b0ceb4 --- /dev/null +++ b/src/components/load3d/controls/HDRIControls.test.ts @@ -0,0 +1,197 @@ +/* eslint-disable testing-library/no-container, testing-library/no-node-access -- hidden file input has no role/label, queried by selector */ +import { render, screen } from '@testing-library/vue' +import userEvent from '@testing-library/user-event' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { ref } from 'vue' +import { createI18n } from 'vue-i18n' + +import HDRIControls from '@/components/load3d/controls/HDRIControls.vue' +import type { HDRIConfig } from '@/extensions/core/load3d/interfaces' + +const addAlert = vi.fn() +vi.mock('@/platform/updates/common/toastStore', () => ({ + useToastStore: () => ({ addAlert }) +})) + +const i18n = createI18n({ + legacy: false, + locale: 'en', + messages: { + en: { + load3d: { + hdri: { + label: 'HDRI', + uploadFile: 'Upload HDRI', + changeFile: 'Change HDRI', + showAsBackground: 'Show as background', + removeFile: 'Remove HDRI' + } + }, + toastMessages: { unsupportedHDRIFormat: 'Unsupported HDRI format' } + } + } +}) + +const defaultConfig: HDRIConfig = { + enabled: false, + hdriPath: '', + showAsBackground: false, + intensity: 1 +} + +type RenderOpts = { + config?: HDRIConfig + hasBackgroundImage?: boolean + onUpdateHdriFile?: (file: File | null) => void +} + +function renderComponent(opts: RenderOpts = {}) { + const config = ref<HDRIConfig>(opts.config ?? { ...defaultConfig }) + + const utils = render(HDRIControls, { + props: { + hdriConfig: config.value, + 'onUpdate:hdriConfig': (v: HDRIConfig | undefined) => { + if (v) config.value = v + }, + hasBackgroundImage: opts.hasBackgroundImage ?? false, + onUpdateHdriFile: opts.onUpdateHdriFile + }, + global: { + plugins: [i18n], + directives: { tooltip: () => {} } + } + }) + + return { ...utils, config, user: userEvent.setup() } +} + +describe('HDRIControls', () => { + beforeEach(() => { + addAlert.mockClear() + }) + + afterEach(() => { + document.body.innerHTML = '' + }) + + describe('initial render', () => { + it('renders the upload button when no HDRI is loaded', () => { + renderComponent() + + expect( + screen.getByRole('button', { name: 'Upload HDRI' }) + ).toBeInTheDocument() + expect( + screen.queryByRole('button', { name: 'HDRI' }) + ).not.toBeInTheDocument() + }) + + it('renders the change / toggle / show-as-bg / remove buttons when an HDRI is loaded', () => { + renderComponent({ + config: { ...defaultConfig, hdriPath: '/api/hdri/test.hdr' } + }) + + expect( + screen.getByRole('button', { name: 'Change HDRI' }) + ).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'HDRI' })).toBeInTheDocument() + expect( + screen.getByRole('button', { name: 'Show as background' }) + ).toBeInTheDocument() + expect( + screen.getByRole('button', { name: 'Remove HDRI' }) + ).toBeInTheDocument() + }) + + it('hides the entire control when a background image is set and no HDRI is loaded', () => { + const { container } = renderComponent({ + hasBackgroundImage: true, + config: { ...defaultConfig, hdriPath: '' } + }) + + expect(container.querySelector('button')).toBeNull() + }) + + it('still renders when a background image is set but an HDRI is loaded', () => { + renderComponent({ + hasBackgroundImage: true, + config: { ...defaultConfig, hdriPath: '/api/hdri/test.hdr' } + }) + + expect( + screen.getByRole('button', { name: 'Change HDRI' }) + ).toBeInTheDocument() + }) + }) + + describe('toggle buttons', () => { + it('flips enabled in the v-model when the HDRI button is clicked', async () => { + const { user, config } = renderComponent({ + config: { ...defaultConfig, hdriPath: '/api/hdri/test.hdr' } + }) + + await user.click(screen.getByRole('button', { name: 'HDRI' })) + + expect(config.value.enabled).toBe(true) + }) + + it('flips showAsBackground in the v-model when the show-as-background button is clicked', async () => { + const { user, config } = renderComponent({ + config: { ...defaultConfig, hdriPath: '/api/hdri/test.hdr' } + }) + + await user.click( + screen.getByRole('button', { name: 'Show as background' }) + ) + + expect(config.value.showAsBackground).toBe(true) + }) + }) + + describe('file events', () => { + it('emits updateHdriFile(null) when the remove button is clicked', async () => { + const onUpdateHdriFile = vi.fn() + const { user } = renderComponent({ + config: { ...defaultConfig, hdriPath: '/api/hdri/test.hdr' }, + onUpdateHdriFile + }) + + await user.click(screen.getByRole('button', { name: 'Remove HDRI' })) + + expect(onUpdateHdriFile).toHaveBeenCalledWith(null) + }) + + it('emits updateHdriFile with the picked file when its extension is supported', async () => { + const onUpdateHdriFile = vi.fn() + const { container } = renderComponent({ onUpdateHdriFile }) + + const fileInput = container.querySelector( + 'input[type="file"]' + ) as HTMLInputElement + const file = new File(['hdri-data'], 'sky.hdr', { + type: 'application/octet-stream' + }) + Object.defineProperty(fileInput, 'files', { value: [file] }) + fileInput.dispatchEvent(new Event('change')) + + expect(onUpdateHdriFile).toHaveBeenCalledWith(file) + expect(addAlert).not.toHaveBeenCalled() + }) + + it('rejects unsupported file extensions with a toast and no emit', async () => { + const onUpdateHdriFile = vi.fn() + const { container } = renderComponent({ onUpdateHdriFile }) + + const fileInput = container.querySelector( + 'input[type="file"]' + ) as HTMLInputElement + const file = new File(['data'], 'photo.jpg', { type: 'image/jpeg' }) + Object.defineProperty(fileInput, 'files', { value: [file] }) + fileInput.dispatchEvent(new Event('change')) + + expect(onUpdateHdriFile).not.toHaveBeenCalled() + expect(addAlert).toHaveBeenCalledWith('Unsupported HDRI format') + }) + }) +}) diff --git a/src/components/load3d/controls/LightControls.test.ts b/src/components/load3d/controls/LightControls.test.ts new file mode 100644 index 0000000000..bf1368656b --- /dev/null +++ b/src/components/load3d/controls/LightControls.test.ts @@ -0,0 +1,193 @@ +import { render, screen } from '@testing-library/vue' +import userEvent from '@testing-library/user-event' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { ref } from 'vue' +import { createI18n } from 'vue-i18n' + +import LightControls from '@/components/load3d/controls/LightControls.vue' +import type { + HDRIConfig, + MaterialMode +} from '@/extensions/core/load3d/interfaces' + +const settingValues: Record<string, unknown> = { + 'Comfy.Load3D.LightIntensityMaximum': 10, + 'Comfy.Load3D.LightIntensityMinimum': 1, + 'Comfy.Load3D.LightAdjustmentIncrement': 0.5 +} + +vi.mock('@/platform/settings/settingStore', () => ({ + useSettingStore: () => ({ + get: (key: string) => settingValues[key] + }) +})) + +vi.mock('@/composables/useDismissableOverlay', () => ({ + useDismissableOverlay: vi.fn() +})) + +vi.mock('@/components/ui/slider/Slider.vue', () => ({ + default: { + name: 'UiSlider', + props: ['modelValue', 'min', 'max', 'step'], + emits: ['update:modelValue'], + template: ` + <input + type="range" + role="slider" + :value="Array.isArray(modelValue) ? modelValue[0] : modelValue" + :min="min" + :max="max" + :step="step" + @input="$emit('update:modelValue', [Number($event.target.value)])" + /> + ` + } +})) + +const i18n = createI18n({ + legacy: false, + locale: 'en', + messages: { en: { load3d: { lightIntensity: 'Light intensity' } } } +}) + +type RenderOpts = { + lightIntensity?: number + materialMode?: MaterialMode + hdriConfig?: HDRIConfig + embedded?: boolean +} + +function renderComponent(opts: RenderOpts = {}) { + const lightIntensity = ref<number>(opts.lightIntensity ?? 5) + const materialMode = ref<MaterialMode>(opts.materialMode ?? 'original') + const hdriConfig = ref<HDRIConfig | undefined>(opts.hdriConfig) + + const utils = render(LightControls, { + props: { + lightIntensity: lightIntensity.value, + 'onUpdate:lightIntensity': (v: number | undefined) => { + if (v !== undefined) lightIntensity.value = v + }, + materialMode: materialMode.value, + 'onUpdate:materialMode': (v: MaterialMode | undefined) => { + if (v) materialMode.value = v + }, + hdriConfig: hdriConfig.value, + 'onUpdate:hdriConfig': (v: HDRIConfig | undefined) => { + hdriConfig.value = v + }, + embedded: opts.embedded ?? false + }, + global: { + plugins: [i18n], + directives: { tooltip: () => {} } + } + }) + + return { + ...utils, + lightIntensity, + hdriConfig, + user: userEvent.setup() + } +} + +describe('LightControls', () => { + afterEach(() => { + document.body.innerHTML = '' + }) + + describe('material mode gating', () => { + it('renders the intensity control when materialMode is original', () => { + renderComponent({ materialMode: 'original' }) + + expect( + screen.getByRole('button', { name: 'Light intensity' }) + ).toBeInTheDocument() + }) + + it.each(['normal', 'wireframe'] as const)( + 'hides the intensity control when materialMode is %s', + (mode) => { + renderComponent({ materialMode: mode }) + + expect( + screen.queryByRole('button', { name: 'Light intensity' }) + ).not.toBeInTheDocument() + } + ) + }) + + describe('default (non-HDRI) mode', () => { + it('feeds the slider with the setting-store min / max / step', async () => { + const { user } = renderComponent({ lightIntensity: 5 }) + await user.click(screen.getByRole('button', { name: 'Light intensity' })) + + const slider = screen.getByRole('slider') as HTMLInputElement + expect(slider.min).toBe('1') + expect(slider.max).toBe('10') + expect(slider.step).toBe('0.5') + }) + + it('updates lightIntensity v-model when the slider changes', async () => { + const { user, lightIntensity } = renderComponent({ lightIntensity: 5 }) + await user.click(screen.getByRole('button', { name: 'Light intensity' })) + + const slider = screen.getByRole('slider') as HTMLInputElement + slider.value = '7.5' + slider.dispatchEvent(new Event('input', { bubbles: true })) + + expect(lightIntensity.value).toBe(7.5) + }) + }) + + describe('HDRI active mode', () => { + const hdriConfig: HDRIConfig = { + enabled: true, + hdriPath: '/api/hdri/test.hdr', + showAsBackground: false, + intensity: 2 + } + + it('reads the slider min / max / step from the HDRI range (0..5 step 0.1)', async () => { + const { user } = renderComponent({ hdriConfig }) + await user.click(screen.getByRole('button', { name: 'Light intensity' })) + + const slider = screen.getByRole('slider') as HTMLInputElement + expect(slider.min).toBe('0') + expect(slider.max).toBe('5') + expect(slider.step).toBe('0.1') + }) + + it('writes back to hdriConfig.intensity instead of lightIntensity when the slider changes', async () => { + const { + user, + lightIntensity, + hdriConfig: cfg + } = renderComponent({ + lightIntensity: 5, + hdriConfig + }) + await user.click(screen.getByRole('button', { name: 'Light intensity' })) + + const slider = screen.getByRole('slider') as HTMLInputElement + slider.value = '3.5' + slider.dispatchEvent(new Event('input', { bubbles: true })) + + expect(cfg.value?.intensity).toBe(3.5) + expect(lightIntensity.value).toBe(5) // unchanged + }) + }) + + describe('embedded mode', () => { + it('renders the slider inline without the trigger button when embedded is true', () => { + renderComponent({ embedded: true }) + + expect( + screen.queryByRole('button', { name: 'Light intensity' }) + ).not.toBeInTheDocument() + expect(screen.getByRole('slider')).toBeInTheDocument() + }) + }) +}) diff --git a/src/components/load3d/controls/ModelControls.test.ts b/src/components/load3d/controls/ModelControls.test.ts new file mode 100644 index 0000000000..2ee0336fd5 --- /dev/null +++ b/src/components/load3d/controls/ModelControls.test.ts @@ -0,0 +1,185 @@ +import { render, screen } from '@testing-library/vue' +import userEvent from '@testing-library/user-event' +import { afterEach, describe, expect, it } from 'vitest' +import { ref } from 'vue' +import { createI18n } from 'vue-i18n' + +import ModelControls from '@/components/load3d/controls/ModelControls.vue' +import type { + MaterialMode, + UpDirection +} from '@/extensions/core/load3d/interfaces' + +const i18n = createI18n({ + legacy: false, + locale: 'en', + messages: { + en: { + load3d: { + upDirection: 'Up direction', + materialMode: 'Material mode', + showSkeleton: 'Show skeleton', + materialModes: { + original: 'Original', + normal: 'Normal', + wireframe: 'Wireframe', + pointCloud: 'Point cloud', + depth: 'Depth' + } + } + } + } +}) + +type RenderOpts = { + upDirection?: UpDirection + materialMode?: MaterialMode + showSkeleton?: boolean + materialModes?: readonly MaterialMode[] + hasSkeleton?: boolean +} + +function renderComponent(opts: RenderOpts = {}) { + const upDirection = ref<UpDirection>(opts.upDirection ?? 'original') + const materialMode = ref<MaterialMode>(opts.materialMode ?? 'original') + const showSkeleton = ref<boolean>(opts.showSkeleton ?? false) + + const utils = render(ModelControls, { + props: { + upDirection: upDirection.value, + 'onUpdate:upDirection': (v: UpDirection | undefined) => { + if (v) upDirection.value = v + }, + materialMode: materialMode.value, + 'onUpdate:materialMode': (v: MaterialMode | undefined) => { + if (v) materialMode.value = v + }, + showSkeleton: showSkeleton.value, + 'onUpdate:showSkeleton': (v: boolean | undefined) => { + if (v !== undefined) showSkeleton.value = v + }, + materialModes: opts.materialModes ?? ['original', 'normal', 'wireframe'], + hasSkeleton: opts.hasSkeleton ?? false + }, + global: { + plugins: [i18n], + directives: { tooltip: () => {} } + } + }) + + return { + ...utils, + upDirection, + materialMode, + showSkeleton, + user: userEvent.setup() + } +} + +describe('ModelControls', () => { + afterEach(() => { + document.body.innerHTML = '' + }) + + describe('up direction', () => { + it('renders the up-direction trigger and opens the popup with all 7 directions', async () => { + const { user } = renderComponent() + await user.click(screen.getByRole('button', { name: 'Up direction' })) + + for (const label of ['ORIGINAL', '-X', '+X', '-Y', '+Y', '-Z', '+Z']) { + expect(screen.getByRole('button', { name: label })).toBeVisible() + } + }) + + it('updates upDirection v-model when a direction is selected', async () => { + const { user, upDirection } = renderComponent() + await user.click(screen.getByRole('button', { name: 'Up direction' })) + await user.click(screen.getByRole('button', { name: '+X' })) + + expect(upDirection.value).toBe('+x') + }) + }) + + describe('material mode', () => { + it('renders the material-mode trigger when materialModes is non-empty', () => { + renderComponent({ materialModes: ['original', 'normal'] }) + + expect( + screen.getByRole('button', { name: 'Material mode' }) + ).toBeInTheDocument() + }) + + it('hides the material-mode trigger when materialModes is empty', () => { + renderComponent({ materialModes: [] }) + + expect( + screen.queryByRole('button', { name: 'Material mode' }) + ).not.toBeInTheDocument() + }) + + it('renders one popup option per entry in materialModes', async () => { + const { user } = renderComponent({ + materialModes: ['original', 'pointCloud', 'normal', 'wireframe'] + }) + await user.click(screen.getByRole('button', { name: 'Material mode' })) + + expect(screen.getByRole('button', { name: 'Original' })).toBeVisible() + expect(screen.getByRole('button', { name: 'Point cloud' })).toBeVisible() + expect(screen.getByRole('button', { name: 'Normal' })).toBeVisible() + expect(screen.getByRole('button', { name: 'Wireframe' })).toBeVisible() + }) + + it('updates materialMode v-model when a mode is selected', async () => { + const { user, materialMode } = renderComponent({ + materialModes: ['original', 'normal'] + }) + await user.click(screen.getByRole('button', { name: 'Material mode' })) + await user.click(screen.getByRole('button', { name: 'Normal' })) + + expect(materialMode.value).toBe('normal') + }) + }) + + describe('skeleton', () => { + it('hides the skeleton button when hasSkeleton is false', () => { + renderComponent({ hasSkeleton: false }) + + expect( + screen.queryByRole('button', { name: 'Show skeleton' }) + ).not.toBeInTheDocument() + }) + + it('renders the skeleton button when hasSkeleton is true', () => { + renderComponent({ hasSkeleton: true }) + + expect( + screen.getByRole('button', { name: 'Show skeleton' }) + ).toBeInTheDocument() + }) + + it('flips showSkeleton v-model when the skeleton button is clicked', async () => { + const { user, showSkeleton } = renderComponent({ + hasSkeleton: true, + showSkeleton: false + }) + await user.click(screen.getByRole('button', { name: 'Show skeleton' })) + + expect(showSkeleton.value).toBe(true) + }) + }) + + describe('popup mutual exclusion', () => { + it('closes the up-direction popup when the material-mode trigger is clicked', async () => { + const { user } = renderComponent() + + await user.click(screen.getByRole('button', { name: 'Up direction' })) + expect(screen.getByRole('button', { name: 'ORIGINAL' })).toBeVisible() + + await user.click(screen.getByRole('button', { name: 'Material mode' })) + + expect( + screen.queryByRole('button', { name: 'ORIGINAL' }) + ).not.toBeInTheDocument() + }) + }) +}) diff --git a/src/components/load3d/controls/ModelControls.vue b/src/components/load3d/controls/ModelControls.vue index b3943bc2fc..7d369ee32f 100644 --- a/src/components/load3d/controls/ModelControls.vue +++ b/src/components/load3d/controls/ModelControls.vue @@ -37,7 +37,7 @@ </div> </div> - <div v-if="!hideMaterialMode" class="show-material-mode relative"> + <div v-if="materialModes.length > 0" class="show-material-mode relative"> <Button v-tooltip.right="{ value: t('load3d.materialMode'), @@ -93,7 +93,7 @@ </template> <script setup lang="ts"> -import { computed, onMounted, onUnmounted, ref } from 'vue' +import { onMounted, onUnmounted, ref } from 'vue' import { useI18n } from 'vue-i18n' import Button from '@/components/ui/button/Button.vue' @@ -105,12 +105,10 @@ import { cn } from '@comfyorg/tailwind-utils' const { t } = useI18n() const { - hideMaterialMode = false, - isPlyModel = false, + materialModes = ['original', 'normal', 'wireframe'], hasSkeleton = false } = defineProps<{ - hideMaterialMode?: boolean - isPlyModel?: boolean + materialModes?: readonly MaterialMode[] hasSkeleton?: boolean }>() @@ -131,22 +129,6 @@ const upDirections: UpDirection[] = [ '+z' ] -const materialModes = computed(() => { - const modes: MaterialMode[] = [ - 'original', - 'normal', - 'wireframe' - //'depth' disable for now - ] - - // Only show pointCloud mode for PLY files (point cloud rendering) - if (isPlyModel) { - modes.splice(1, 0, 'pointCloud') - } - - return modes -}) - function toggleUpDirection() { showUpDirection.value = !showUpDirection.value showMaterialMode.value = false diff --git a/src/components/load3d/controls/PopupSlider.test.ts b/src/components/load3d/controls/PopupSlider.test.ts new file mode 100644 index 0000000000..560895d33a --- /dev/null +++ b/src/components/load3d/controls/PopupSlider.test.ts @@ -0,0 +1,126 @@ +import { render, screen } from '@testing-library/vue' +import userEvent from '@testing-library/user-event' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { ref } from 'vue' + +import PopupSlider from '@/components/load3d/controls/PopupSlider.vue' + +vi.mock('primevue/slider', () => ({ + default: { + name: 'Slider', + props: ['modelValue', 'min', 'max', 'step'], + emits: ['update:modelValue'], + template: ` + <input + type="range" + role="slider" + :value="modelValue" + :min="min" + :max="max" + :step="step" + @input="$emit('update:modelValue', Number($event.target.value))" + /> + ` + } +})) + +function renderComponent( + props: { + tooltipText?: string + icon?: string + min?: number + max?: number + step?: number + initial?: number + } = {} +) { + const value = ref<number>(props.initial ?? 50) + const utils = render(PopupSlider, { + props: { + tooltipText: props.tooltipText ?? 'FOV', + icon: props.icon, + min: props.min, + max: props.max, + step: props.step, + modelValue: value.value, + 'onUpdate:modelValue': (v: number | undefined) => { + if (v !== undefined) value.value = v + } + }, + global: { + directives: { tooltip: () => {} } + } + }) + return { ...utils, value, user: userEvent.setup() } +} + +describe('PopupSlider', () => { + afterEach(() => { + document.body.innerHTML = '' + }) + + it('keeps the slider hidden from the accessibility tree until the trigger is clicked', () => { + renderComponent({ tooltipText: 'FOV' }) + + expect(screen.queryByRole('slider')).not.toBeInTheDocument() + }) + + it('reveals the slider when the trigger is clicked and hides it again on a second click', async () => { + const { user } = renderComponent({ tooltipText: 'FOV' }) + + await user.click(screen.getByRole('button', { name: 'FOV' })) + expect(screen.getByRole('slider')).toBeVisible() + + await user.click(screen.getByRole('button', { name: 'FOV' })) + expect(screen.queryByRole('slider')).not.toBeInTheDocument() + }) + + it('hides the slider when the user clicks outside the popup', async () => { + const { user } = renderComponent({ tooltipText: 'FOV' }) + + await user.click(screen.getByRole('button', { name: 'FOV' })) + expect(screen.getByRole('slider')).toBeVisible() + + await user.click(document.body) + expect(screen.queryByRole('slider')).not.toBeInTheDocument() + }) + + it('forwards default min / max / step (10 / 150 / 1) when none are provided', async () => { + const { user } = renderComponent({ tooltipText: 'FOV' }) + await user.click(screen.getByRole('button', { name: 'FOV' })) + const slider = screen.getByRole('slider') as HTMLInputElement + + expect(slider.min).toBe('10') + expect(slider.max).toBe('150') + expect(slider.step).toBe('1') + }) + + it('uses caller-provided min / max / step over the defaults', async () => { + const { user } = renderComponent({ + tooltipText: 'Light', + min: 0, + max: 5, + step: 0.25 + }) + await user.click(screen.getByRole('button', { name: 'Light' })) + const slider = screen.getByRole('slider') as HTMLInputElement + + expect(slider.min).toBe('0') + expect(slider.max).toBe('5') + expect(slider.step).toBe('0.25') + }) + + it('updates the v-model when the slider value changes', async () => { + const { user, value } = renderComponent({ + tooltipText: 'FOV', + initial: 50 + }) + await user.click(screen.getByRole('button', { name: 'FOV' })) + const slider = screen.getByRole('slider') as HTMLInputElement + + slider.value = '120' + slider.dispatchEvent(new Event('input', { bubbles: true })) + + expect(value.value).toBe(120) + }) +}) diff --git a/src/components/load3d/controls/RecordingControls.test.ts b/src/components/load3d/controls/RecordingControls.test.ts new file mode 100644 index 0000000000..4d93ac1ccc --- /dev/null +++ b/src/components/load3d/controls/RecordingControls.test.ts @@ -0,0 +1,205 @@ +import { render, screen } from '@testing-library/vue' +import userEvent from '@testing-library/user-event' +import { describe, expect, it, vi } from 'vitest' +import { ref } from 'vue' +import { createI18n } from 'vue-i18n' + +import RecordingControls from '@/components/load3d/controls/RecordingControls.vue' + +const i18n = createI18n({ + legacy: false, + locale: 'en', + messages: { + en: { + load3d: { + startRecording: 'Start recording', + stopRecording: 'Stop recording', + exportRecording: 'Export recording', + clearRecording: 'Clear recording' + } + } + } +}) + +type RenderOpts = { + hasRecording?: boolean + isRecording?: boolean + recordingDuration?: number + onStartRecording?: () => void + onStopRecording?: () => void + onExportRecording?: () => void + onClearRecording?: () => void +} + +function renderComponent(opts: RenderOpts = {}) { + const hasRecording = ref<boolean>(opts.hasRecording ?? false) + const isRecording = ref<boolean>(opts.isRecording ?? false) + const recordingDuration = ref<number>(opts.recordingDuration ?? 0) + + const utils = render(RecordingControls, { + props: { + hasRecording: hasRecording.value, + 'onUpdate:hasRecording': (v: boolean | undefined) => { + if (v !== undefined) hasRecording.value = v + }, + isRecording: isRecording.value, + 'onUpdate:isRecording': (v: boolean | undefined) => { + if (v !== undefined) isRecording.value = v + }, + recordingDuration: recordingDuration.value, + 'onUpdate:recordingDuration': (v: number | undefined) => { + if (v !== undefined) recordingDuration.value = v + }, + onStartRecording: opts.onStartRecording, + onStopRecording: opts.onStopRecording, + onExportRecording: opts.onExportRecording, + onClearRecording: opts.onClearRecording + }, + global: { + plugins: [i18n], + directives: { tooltip: () => {} } + } + }) + + return { ...utils, user: userEvent.setup() } +} + +describe('RecordingControls', () => { + it('shows the start-recording button initially', () => { + renderComponent() + + expect( + screen.getByRole('button', { name: 'Start recording' }) + ).toBeInTheDocument() + expect( + screen.queryByRole('button', { name: 'Stop recording' }) + ).not.toBeInTheDocument() + }) + + it('shows the stop-recording button while recording is in progress', () => { + renderComponent({ isRecording: true }) + + expect( + screen.getByRole('button', { name: 'Stop recording' }) + ).toBeInTheDocument() + expect( + screen.queryByRole('button', { name: 'Start recording' }) + ).not.toBeInTheDocument() + }) + + it('emits startRecording when the button is clicked from a stopped state', async () => { + const onStartRecording = vi.fn() + const onStopRecording = vi.fn() + const { user } = renderComponent({ + isRecording: false, + onStartRecording, + onStopRecording + }) + + await user.click(screen.getByRole('button', { name: 'Start recording' })) + + expect(onStartRecording).toHaveBeenCalledOnce() + expect(onStopRecording).not.toHaveBeenCalled() + }) + + it('emits stopRecording when the button is clicked from a recording state', async () => { + const onStartRecording = vi.fn() + const onStopRecording = vi.fn() + const { user } = renderComponent({ + isRecording: true, + onStartRecording, + onStopRecording + }) + + await user.click(screen.getByRole('button', { name: 'Stop recording' })) + + expect(onStopRecording).toHaveBeenCalledOnce() + expect(onStartRecording).not.toHaveBeenCalled() + }) + + it('hides the export and clear buttons when there is no recording', () => { + renderComponent({ hasRecording: false, isRecording: false }) + + expect( + screen.queryByRole('button', { name: 'Export recording' }) + ).not.toBeInTheDocument() + expect( + screen.queryByRole('button', { name: 'Clear recording' }) + ).not.toBeInTheDocument() + }) + + it('shows the export and clear buttons once a recording exists', () => { + renderComponent({ hasRecording: true, isRecording: false }) + + expect( + screen.getByRole('button', { name: 'Export recording' }) + ).toBeInTheDocument() + expect( + screen.getByRole('button', { name: 'Clear recording' }) + ).toBeInTheDocument() + }) + + it('hides the export and clear buttons during a new recording even if a previous one exists', () => { + renderComponent({ hasRecording: true, isRecording: true }) + + expect( + screen.queryByRole('button', { name: 'Export recording' }) + ).not.toBeInTheDocument() + expect( + screen.queryByRole('button', { name: 'Clear recording' }) + ).not.toBeInTheDocument() + }) + + it('emits exportRecording and clearRecording from their respective buttons', async () => { + const onExportRecording = vi.fn() + const onClearRecording = vi.fn() + const { user } = renderComponent({ + hasRecording: true, + isRecording: false, + onExportRecording, + onClearRecording + }) + + await user.click(screen.getByRole('button', { name: 'Export recording' })) + await user.click(screen.getByRole('button', { name: 'Clear recording' })) + + expect(onExportRecording).toHaveBeenCalledOnce() + expect(onClearRecording).toHaveBeenCalledOnce() + }) + + it('renders the formatted duration as MM:SS once a recording exists', () => { + renderComponent({ + hasRecording: true, + isRecording: false, + recordingDuration: 75 + }) + + expect(screen.getByTestId('load3d-recording-duration')).toHaveTextContent( + '01:15' + ) + }) + + it('hides the duration display while a recording is in progress', () => { + renderComponent({ + hasRecording: true, + isRecording: true, + recordingDuration: 30 + }) + + expect( + screen.queryByTestId('load3d-recording-duration') + ).not.toBeInTheDocument() + }) + + it('hides the duration display when recordingDuration is zero', () => { + renderComponent({ + hasRecording: true, + isRecording: false, + recordingDuration: 0 + }) + + expect( + screen.queryByTestId('load3d-recording-duration') + ).not.toBeInTheDocument() + }) +}) diff --git a/src/components/load3d/controls/SceneControls.test.ts b/src/components/load3d/controls/SceneControls.test.ts new file mode 100644 index 0000000000..7de6f008f9 --- /dev/null +++ b/src/components/load3d/controls/SceneControls.test.ts @@ -0,0 +1,231 @@ +/* eslint-disable testing-library/no-container, testing-library/no-node-access -- hidden color/file inputs have no role/label, queried by selector */ +import { render, screen } from '@testing-library/vue' +import userEvent from '@testing-library/user-event' +import { describe, expect, it, vi } from 'vitest' +import { ref } from 'vue' +import { createI18n } from 'vue-i18n' + +import SceneControls from '@/components/load3d/controls/SceneControls.vue' + +vi.mock('@/components/load3d/controls/PopupSlider.vue', () => ({ + default: { + name: 'PopupSliderStub', + props: ['tooltipText', 'modelValue'], + template: '<div data-testid="fov-popup-slider">{{ tooltipText }}</div>' + } +})) + +const i18n = createI18n({ + legacy: false, + locale: 'en', + messages: { + en: { + load3d: { + showGrid: 'Show grid', + backgroundColor: 'Background color', + uploadBackgroundImage: 'Upload background image', + panoramaMode: 'Panorama mode', + removeBackgroundImage: 'Remove background image', + fov: 'FOV' + } + } + } +}) + +type RenderOpts = { + showGrid?: boolean + backgroundColor?: string + backgroundImage?: string + backgroundRenderMode?: 'tiled' | 'panorama' + fov?: number + hdriActive?: boolean + onUpdateBackgroundImage?: (file: File | null) => void +} + +function renderComponent(opts: RenderOpts = {}) { + const showGrid = ref<boolean>(opts.showGrid ?? true) + const backgroundColor = ref<string>(opts.backgroundColor ?? '#000000') + const backgroundImage = ref<string>(opts.backgroundImage ?? '') + const backgroundRenderMode = ref<'tiled' | 'panorama'>( + opts.backgroundRenderMode ?? 'tiled' + ) + const fov = ref<number>(opts.fov ?? 75) + + const utils = render(SceneControls, { + props: { + showGrid: showGrid.value, + 'onUpdate:showGrid': (v: boolean | undefined) => { + if (v !== undefined) showGrid.value = v + }, + backgroundColor: backgroundColor.value, + 'onUpdate:backgroundColor': (v: string | undefined) => { + if (v !== undefined) backgroundColor.value = v + }, + backgroundImage: backgroundImage.value, + 'onUpdate:backgroundImage': (v: string | undefined) => { + if (v !== undefined) backgroundImage.value = v + }, + backgroundRenderMode: backgroundRenderMode.value, + 'onUpdate:backgroundRenderMode': ( + v: 'tiled' | 'panorama' | undefined + ) => { + if (v) backgroundRenderMode.value = v + }, + fov: fov.value, + 'onUpdate:fov': (v: number | undefined) => { + if (v !== undefined) fov.value = v + }, + hdriActive: opts.hdriActive ?? false, + onUpdateBackgroundImage: opts.onUpdateBackgroundImage + }, + global: { + plugins: [i18n], + directives: { tooltip: () => {} } + } + }) + + return { + ...utils, + showGrid, + backgroundColor, + backgroundRenderMode, + user: userEvent.setup() + } +} + +describe('SceneControls', () => { + describe('grid', () => { + it('flips showGrid via v-model when the grid button is clicked', async () => { + const { user, showGrid } = renderComponent({ showGrid: false }) + + await user.click(screen.getByRole('button', { name: 'Show grid' })) + + expect(showGrid.value).toBe(true) + }) + }) + + describe('hdriActive=true', () => { + it('hides the background-color and upload buttons when HDRI is active', () => { + renderComponent({ hdriActive: true, backgroundImage: '' }) + + expect( + screen.queryByRole('button', { name: 'Background color' }) + ).not.toBeInTheDocument() + expect( + screen.queryByRole('button', { name: 'Upload background image' }) + ).not.toBeInTheDocument() + }) + }) + + describe('without a background image', () => { + it('renders the background-color and upload buttons', () => { + renderComponent({ backgroundImage: '' }) + + expect( + screen.getByRole('button', { name: 'Background color' }) + ).toBeInTheDocument() + expect( + screen.getByRole('button', { name: 'Upload background image' }) + ).toBeInTheDocument() + }) + + it('does not render the panorama / remove / FOV controls', () => { + renderComponent({ backgroundImage: '' }) + + expect( + screen.queryByRole('button', { name: 'Panorama mode' }) + ).not.toBeInTheDocument() + expect( + screen.queryByRole('button', { name: 'Remove background image' }) + ).not.toBeInTheDocument() + expect(screen.queryByTestId('fov-popup-slider')).not.toBeInTheDocument() + }) + + it('updates backgroundColor v-model from the hidden color picker', async () => { + const { backgroundColor, container } = renderComponent({ + backgroundImage: '', + backgroundColor: '#000000' + }) + + const colorInput = container.querySelector( + 'input[type="color"]' + ) as HTMLInputElement + colorInput.value = '#ff0000' + colorInput.dispatchEvent(new Event('input', { bubbles: true })) + + expect(backgroundColor.value).toBe('#ff0000') + }) + + it('emits updateBackgroundImage with the picked file', async () => { + const onUpdateBackgroundImage = vi.fn() + const { container } = renderComponent({ + backgroundImage: '', + onUpdateBackgroundImage + }) + + const fileInput = container.querySelector( + 'input[type="file"]' + ) as HTMLInputElement + const file = new File(['data'], 'bg.png', { type: 'image/png' }) + Object.defineProperty(fileInput, 'files', { value: [file] }) + fileInput.dispatchEvent(new Event('change')) + + expect(onUpdateBackgroundImage).toHaveBeenCalledWith(file) + }) + }) + + describe('with a background image', () => { + it('renders the panorama and remove buttons', () => { + renderComponent({ backgroundImage: 'bg.png' }) + + expect( + screen.getByRole('button', { name: 'Panorama mode' }) + ).toBeInTheDocument() + expect( + screen.getByRole('button', { name: 'Remove background image' }) + ).toBeInTheDocument() + }) + + it('toggles backgroundRenderMode between tiled and panorama on the panorama button', async () => { + const { user, backgroundRenderMode } = renderComponent({ + backgroundImage: 'bg.png', + backgroundRenderMode: 'tiled' + }) + + await user.click(screen.getByRole('button', { name: 'Panorama mode' })) + expect(backgroundRenderMode.value).toBe('panorama') + }) + + it('hides the FOV PopupSlider in tiled mode', () => { + renderComponent({ + backgroundImage: 'bg.png', + backgroundRenderMode: 'tiled' + }) + + expect(screen.queryByTestId('fov-popup-slider')).not.toBeInTheDocument() + }) + + it('shows the FOV PopupSlider in panorama mode', () => { + renderComponent({ + backgroundImage: 'bg.png', + backgroundRenderMode: 'panorama' + }) + + expect(screen.getByTestId('fov-popup-slider')).toBeInTheDocument() + }) + + it('emits updateBackgroundImage(null) when the remove button is clicked', async () => { + const onUpdateBackgroundImage = vi.fn() + const { user } = renderComponent({ + backgroundImage: 'bg.png', + onUpdateBackgroundImage + }) + + await user.click( + screen.getByRole('button', { name: 'Remove background image' }) + ) + + expect(onUpdateBackgroundImage).toHaveBeenCalledWith(null) + }) + }) +}) diff --git a/src/components/load3d/controls/ViewerControls.test.ts b/src/components/load3d/controls/ViewerControls.test.ts new file mode 100644 index 0000000000..2656a14b45 --- /dev/null +++ b/src/components/load3d/controls/ViewerControls.test.ts @@ -0,0 +1,99 @@ +import { render, screen } from '@testing-library/vue' +import userEvent from '@testing-library/user-event' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createI18n } from 'vue-i18n' + +import ViewerControls from '@/components/load3d/controls/ViewerControls.vue' +import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils' + +const showDialog = vi.fn() +const handleViewerClose = vi.fn() + +vi.mock('@/stores/dialogStore', () => ({ + useDialogStore: () => ({ showDialog }) +})) + +vi.mock('@/services/load3dService', () => ({ + useLoad3dService: () => ({ handleViewerClose }) +})) + +vi.mock('@/components/load3d/Load3dViewerContent.vue', () => ({ + default: { name: 'Load3DViewerContentStub', template: '<div />' } +})) + +const i18n = createI18n({ + legacy: false, + locale: 'en', + messages: { + en: { + load3d: { + openIn3DViewer: 'Open in 3D viewer', + viewer: { title: '3D viewer' } + } + } + } +}) + +const mockNode = createMockLGraphNode({ id: 'node-1' }) + +describe('ViewerControls', () => { + beforeEach(() => { + showDialog.mockClear() + handleViewerClose.mockClear() + }) + + it('renders the open-in-viewer button labeled by the localized aria-label', () => { + render(ViewerControls, { + props: { node: mockNode }, + global: { + plugins: [i18n], + directives: { tooltip: () => {} } + } + }) + + expect( + screen.getByRole('button', { name: 'Open in 3D viewer' }) + ).toBeInTheDocument() + }) + + it('opens the dialog with the provided node and viewer component when clicked', async () => { + const user = userEvent.setup() + render(ViewerControls, { + props: { node: mockNode }, + global: { + plugins: [i18n], + directives: { tooltip: () => {} } + } + }) + + await user.click(screen.getByRole('button', { name: 'Open in 3D viewer' })) + + expect(showDialog).toHaveBeenCalledOnce() + const callArgs = showDialog.mock.calls[0][0] + expect(callArgs.key).toBe('global-load3d-viewer') + expect(callArgs.title).toBe('3D viewer') + expect(callArgs.component).toMatchObject({ + name: 'Load3DViewerContentStub' + }) + expect(callArgs.props).toEqual({ node: mockNode }) + expect(callArgs.dialogComponentProps.maximizable).toBe(true) + }) + + it('routes the dialog onClose handler through useLoad3dService.handleViewerClose with the node', async () => { + const user = userEvent.setup() + render(ViewerControls, { + props: { node: mockNode }, + global: { + plugins: [i18n], + directives: { tooltip: () => {} } + } + }) + + await user.click(screen.getByRole('button', { name: 'Open in 3D viewer' })) + + const onClose = showDialog.mock.calls[0][0].dialogComponentProps.onClose + await onClose() + + expect(handleViewerClose).toHaveBeenCalledWith(mockNode) + }) +}) diff --git a/src/components/load3d/controls/viewer/ViewerCameraControls.test.ts b/src/components/load3d/controls/viewer/ViewerCameraControls.test.ts new file mode 100644 index 0000000000..fe668a91b2 --- /dev/null +++ b/src/components/load3d/controls/viewer/ViewerCameraControls.test.ts @@ -0,0 +1,132 @@ +import { render, screen } from '@testing-library/vue' +import userEvent from '@testing-library/user-event' +import { describe, expect, it, vi } from 'vitest' +import { ref } from 'vue' +import { createI18n } from 'vue-i18n' + +import ViewerCameraControls from '@/components/load3d/controls/viewer/ViewerCameraControls.vue' +import type { CameraType } from '@/extensions/core/load3d/interfaces' + +vi.mock('primevue/select', () => ({ + default: { + name: 'Select', + props: ['modelValue', 'options', 'optionLabel', 'optionValue'], + emits: ['update:modelValue'], + template: ` + <select + :value="modelValue" + @change="$emit('update:modelValue', $event.target.value)" + > + <option v-for="opt in options" :key="opt[optionValue]" :value="opt[optionValue]"> + {{ opt[optionLabel] }} + </option> + </select> + ` + } +})) + +vi.mock('primevue/slider', () => ({ + default: { + name: 'Slider', + props: ['modelValue', 'min', 'max', 'step', 'ariaLabel'], + emits: ['update:modelValue'], + template: ` + <input + type="range" + :value="modelValue" + :min="min" + :max="max" + :step="step" + :aria-label="ariaLabel" + @input="$emit('update:modelValue', Number($event.target.value))" + /> + ` + } +})) + +const i18n = createI18n({ + legacy: false, + locale: 'en', + messages: { + en: { + load3d: { + fov: 'FOV', + viewer: { cameraType: 'Camera type' }, + cameraType: { + perspective: 'Perspective', + orthographic: 'Orthographic' + } + } + } + } +}) + +function renderComponent(initial: { type?: CameraType; fov?: number } = {}) { + const cameraType = ref<CameraType>(initial.type ?? 'perspective') + const fov = ref<number>(initial.fov ?? 75) + + const utils = render(ViewerCameraControls, { + props: { + cameraType: cameraType.value, + 'onUpdate:cameraType': (v: CameraType | undefined) => { + if (v) cameraType.value = v + }, + fov: fov.value, + 'onUpdate:fov': (v: number | undefined) => { + if (v !== undefined) fov.value = v + } + }, + global: { plugins: [i18n] } + }) + + return { ...utils, cameraType, fov, user: userEvent.setup() } +} + +describe('ViewerCameraControls', () => { + it('exposes both camera types in the dropdown', () => { + renderComponent() + const select = screen.getByRole('combobox') as HTMLSelectElement + const options = Array.from(select.options).map((o) => o.value) + + expect(options).toEqual(['perspective', 'orthographic']) + }) + + it('shows the FOV slider when the camera is perspective', () => { + renderComponent({ type: 'perspective' }) + + expect(screen.getByLabelText('FOV')).toBeInTheDocument() + }) + + it('hides the FOV slider when the camera is orthographic', () => { + renderComponent({ type: 'orthographic' }) + + expect(screen.queryByLabelText('FOV')).not.toBeInTheDocument() + }) + + it('reveals the FOV slider when the camera type prop changes back to perspective', async () => { + const { rerender } = renderComponent({ type: 'orthographic' }) + expect(screen.queryByLabelText('FOV')).not.toBeInTheDocument() + + await rerender({ cameraType: 'perspective' }) + + expect(screen.getByLabelText('FOV')).toBeInTheDocument() + }) + + it('updates fov via v-model when the slider changes', () => { + const { fov } = renderComponent({ type: 'perspective', fov: 60 }) + const slider = screen.getByLabelText('FOV') as HTMLInputElement + + slider.value = '90' + slider.dispatchEvent(new Event('input', { bubbles: true })) + + expect(fov.value).toBe(90) + }) + + it('updates cameraType via v-model when the dropdown changes', async () => { + const { user, cameraType } = renderComponent({ type: 'perspective' }) + + await user.selectOptions(screen.getByRole('combobox'), 'orthographic') + + expect(cameraType.value).toBe('orthographic') + }) +}) diff --git a/src/components/load3d/controls/viewer/ViewerExportControls.test.ts b/src/components/load3d/controls/viewer/ViewerExportControls.test.ts new file mode 100644 index 0000000000..800fd3d70b --- /dev/null +++ b/src/components/load3d/controls/viewer/ViewerExportControls.test.ts @@ -0,0 +1,74 @@ +import { render, screen } from '@testing-library/vue' +import userEvent from '@testing-library/user-event' +import { describe, expect, it, vi } from 'vitest' +import { createI18n } from 'vue-i18n' + +import ViewerExportControls from '@/components/load3d/controls/viewer/ViewerExportControls.vue' + +vi.mock('primevue/select', () => ({ + default: { + name: 'Select', + props: ['modelValue', 'options', 'optionLabel', 'optionValue'], + emits: ['update:modelValue'], + template: ` + <select + :value="modelValue" + @change="$emit('update:modelValue', $event.target.value)" + > + <option v-for="opt in options" :key="opt[optionValue]" :value="opt[optionValue]"> + {{ opt[optionLabel] }} + </option> + </select> + ` + } +})) + +const i18n = createI18n({ + legacy: false, + locale: 'en', + messages: { en: { load3d: { export: 'Export' } } } +}) + +function renderComponent(onExportModel?: (format: string) => void) { + const utils = render(ViewerExportControls, { + props: { onExportModel }, + global: { plugins: [i18n] } + }) + return { ...utils, user: userEvent.setup() } +} + +describe('ViewerExportControls', () => { + it('renders all three export format options', () => { + renderComponent() + const select = screen.getByRole('combobox') as HTMLSelectElement + const optionValues = Array.from(select.options).map((o) => o.value) + + expect(optionValues).toEqual(['glb', 'obj', 'stl']) + }) + + it('defaults the export format to obj', () => { + renderComponent() + expect((screen.getByRole('combobox') as HTMLSelectElement).value).toBe( + 'obj' + ) + }) + + it('emits exportModel with the currently selected format when the button is clicked', async () => { + const onExportModel = vi.fn() + const { user } = renderComponent(onExportModel) + + await user.click(screen.getByRole('button', { name: 'Export' })) + + expect(onExportModel).toHaveBeenCalledWith('obj') + }) + + it('emits the newly chosen format after the user changes the dropdown', async () => { + const onExportModel = vi.fn() + const { user } = renderComponent(onExportModel) + + await user.selectOptions(screen.getByRole('combobox'), 'glb') + await user.click(screen.getByRole('button', { name: 'Export' })) + + expect(onExportModel).toHaveBeenCalledWith('glb') + }) +}) diff --git a/src/components/load3d/controls/viewer/ViewerLightControls.test.ts b/src/components/load3d/controls/viewer/ViewerLightControls.test.ts new file mode 100644 index 0000000000..4f48f19c68 --- /dev/null +++ b/src/components/load3d/controls/viewer/ViewerLightControls.test.ts @@ -0,0 +1,87 @@ +import { render, screen } from '@testing-library/vue' +import { describe, expect, it, vi } from 'vitest' +import { ref } from 'vue' +import { createI18n } from 'vue-i18n' + +import ViewerLightControls from '@/components/load3d/controls/viewer/ViewerLightControls.vue' + +const settingValues: Record<string, unknown> = { + 'Comfy.Load3D.LightIntensityMaximum': 10, + 'Comfy.Load3D.LightIntensityMinimum': 1, + 'Comfy.Load3D.LightAdjustmentIncrement': 0.5 +} + +vi.mock('@/platform/settings/settingStore', () => ({ + useSettingStore: () => ({ + get: (key: string) => settingValues[key] + }) +})) + +vi.mock('primevue/slider', () => ({ + default: { + name: 'Slider', + props: ['modelValue', 'min', 'max', 'step'], + emits: ['update:modelValue'], + template: ` + <input + type="range" + :value="modelValue" + :min="min" + :max="max" + :step="step" + @input="$emit('update:modelValue', Number($event.target.value))" + /> + ` + } +})) + +const i18n = createI18n({ + legacy: false, + locale: 'en', + messages: { + en: { load3d: { lightIntensity: 'Light intensity' } } + } +}) + +function renderComponent(initial = 5) { + const intensity = ref<number>(initial) + const utils = render(ViewerLightControls, { + props: { + lightIntensity: intensity.value, + 'onUpdate:lightIntensity': (v: number | undefined) => { + if (v !== undefined) intensity.value = v + } + }, + global: { plugins: [i18n] } + }) + return { ...utils, intensity } +} + +describe('ViewerLightControls', () => { + it('renders the localized label and a slider bound to lightIntensity', () => { + renderComponent(7) + + expect(screen.getByText('Light intensity')).toBeInTheDocument() + const slider = screen.getByRole('slider') as HTMLInputElement + expect(slider.value).toBe('7') + }) + + it('forwards the min / max / step settings from the setting store onto the slider', () => { + renderComponent() + const slider = screen.getByRole('slider') as HTMLInputElement + + expect(slider.min).toBe('1') + expect(slider.max).toBe('10') + expect(slider.step).toBe('0.5') + }) + + it('updates the v-model when the slider value changes', async () => { + const { intensity } = renderComponent(5) + const slider = screen.getByRole('slider') as HTMLInputElement + + slider.value = '8' + slider.dispatchEvent(new Event('input', { bubbles: true })) + + expect(intensity.value).toBe(8) + }) +}) diff --git a/src/components/load3d/controls/viewer/ViewerModelControls.test.ts b/src/components/load3d/controls/viewer/ViewerModelControls.test.ts new file mode 100644 index 0000000000..787255638d --- /dev/null +++ b/src/components/load3d/controls/viewer/ViewerModelControls.test.ts @@ -0,0 +1,194 @@ +import { render, screen } from '@testing-library/vue' +import userEvent from '@testing-library/user-event' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createI18n } from 'vue-i18n' + +import ViewerModelControls from '@/components/load3d/controls/viewer/ViewerModelControls.vue' +import type { + MaterialMode, + UpDirection +} from '@/extensions/core/load3d/interfaces' + +vi.mock('primevue/select', () => ({ + default: { + name: 'Select', + props: ['modelValue', 'options', 'optionLabel', 'optionValue'], + emits: ['update:modelValue'], + template: ` + <select + :value="modelValue" + @change="$emit('update:modelValue', $event.target.value)" + > + <option v-for="opt in options" :key="opt.value" :value="opt.value">{{ opt.label }}</option> + </select> + ` + } +})) + +const i18n = createI18n({ + legacy: false, + locale: 'en', + messages: { + en: { + load3d: { + upDirection: 'Up direction', + materialMode: 'Material mode', + upDirections: { original: 'Original' }, + materialModes: { + original: 'Original', + normal: 'Normal', + wireframe: 'Wireframe', + pointCloud: 'Point Cloud', + depth: 'Depth' + } + } + } + } +}) + +type RenderProps = { + upDirection?: UpDirection + materialMode?: MaterialMode + materialModes?: readonly MaterialMode[] + 'onUpdate:upDirection'?: (value: UpDirection | undefined) => void + 'onUpdate:materialMode'?: (value: MaterialMode | undefined) => void +} + +function renderControls(overrides: RenderProps = {}) { + const result = render(ViewerModelControls, { + props: { + upDirection: 'original', + materialMode: 'original', + materialModes: ['original', 'normal', 'wireframe'], + ...overrides + }, + global: { + plugins: [i18n] + } + }) + return { ...result, user: userEvent.setup() } +} + +function getOptions(select: HTMLElement) { + return Array.from(select.querySelectorAll('option')) +} + +describe('ViewerModelControls', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('rendering', () => { + it('renders both up direction and material mode selects by default', () => { + renderControls() + expect(screen.getAllByRole('combobox')).toHaveLength(2) + expect(screen.getByText('Up direction')).toBeInTheDocument() + expect(screen.getByText('Material mode')).toBeInTheDocument() + }) + + it('hides the material mode select when materialModes is empty', () => { + renderControls({ materialModes: [] }) + expect(screen.getAllByRole('combobox')).toHaveLength(1) + expect(screen.queryByText('Material mode')).not.toBeInTheDocument() + }) + }) + + describe('up direction options', () => { + it('exposes the seven supported directions', () => { + renderControls() + const [upDirectionSelect] = screen.getAllByRole('combobox') + const options = getOptions(upDirectionSelect) + + expect(options.map((o) => o.getAttribute('value'))).toEqual([ + 'original', + '-x', + '+x', + '-y', + '+y', + '-z', + '+z' + ]) + }) + + it('localizes the "original" option label and uses raw axis labels for the rest', () => { + renderControls() + const [upDirectionSelect] = screen.getAllByRole('combobox') + const options = getOptions(upDirectionSelect) + + expect(options.map((o) => o.textContent?.trim())).toEqual([ + 'Original', + '-X', + '+X', + '-Y', + '+Y', + '-Z', + '+Z' + ]) + }) + }) + + describe('material mode options', () => { + it('emits one option per materialModes entry with localized labels', () => { + renderControls({ materialModes: ['original', 'normal', 'wireframe'] }) + const [, materialModeSelect] = screen.getAllByRole('combobox') + const options = getOptions(materialModeSelect) + + expect(options.map((o) => o.getAttribute('value'))).toEqual([ + 'original', + 'normal', + 'wireframe' + ]) + expect(options.map((o) => o.textContent?.trim())).toEqual([ + 'Original', + 'Normal', + 'Wireframe' + ]) + }) + + it('includes pointCloud when the adapter exposes it (PLY)', () => { + renderControls({ + materialModes: ['original', 'pointCloud', 'normal', 'wireframe'] + }) + const [, materialModeSelect] = screen.getAllByRole('combobox') + const options = getOptions(materialModeSelect) + + expect(options).toHaveLength(4) + expect(options[1].textContent?.trim()).toBe('Point Cloud') + expect(options[1].getAttribute('value')).toBe('pointCloud') + }) + }) + + describe('v-model binding', () => { + it('renders the initial upDirection as the selected option', () => { + renderControls({ upDirection: '-z' }) + const [upDirectionSelect] = screen.getAllByRole('combobox') + expect((upDirectionSelect as HTMLSelectElement).value).toBe('-z') + }) + + it('renders the initial materialMode as the selected option', () => { + renderControls({ materialMode: 'normal' }) + const [, materialModeSelect] = screen.getAllByRole('combobox') + expect((materialModeSelect as HTMLSelectElement).value).toBe('normal') + }) + + it('emits update:upDirection when a new direction is chosen', async () => { + const listener = vi.fn() + const { user } = renderControls({ 'onUpdate:upDirection': listener }) + const [upDirectionSelect] = screen.getAllByRole('combobox') + + await user.selectOptions(upDirectionSelect, '+x') + + expect(listener).toHaveBeenCalledWith('+x') + }) + + it('emits update:materialMode when a new mode is chosen', async () => { + const listener = vi.fn() + const { user } = renderControls({ 'onUpdate:materialMode': listener }) + const [, materialModeSelect] = screen.getAllByRole('combobox') + + await user.selectOptions(materialModeSelect, 'wireframe') + + expect(listener).toHaveBeenCalledWith('wireframe') + }) + }) +}) diff --git a/src/components/load3d/controls/viewer/ViewerModelControls.vue b/src/components/load3d/controls/viewer/ViewerModelControls.vue index f8896dfeeb..e56310e8c8 100644 --- a/src/components/load3d/controls/viewer/ViewerModelControls.vue +++ b/src/components/load3d/controls/viewer/ViewerModelControls.vue @@ -10,7 +10,7 @@ /> </div> - <div v-if="!hideMaterialMode" class="flex flex-col gap-2"> + <div v-if="materialModes.length > 0" class="flex flex-col gap-2"> <label>{{ $t('load3d.materialMode') }}</label> <Select v-model="materialMode" @@ -33,9 +33,8 @@ import type { } from '@/extensions/core/load3d/interfaces' const { t } = useI18n() -const { hideMaterialMode = false, isPlyModel = false } = defineProps<{ - hideMaterialMode?: boolean - isPlyModel?: boolean +const { materialModes = ['original', 'normal', 'wireframe'] } = defineProps<{ + materialModes?: readonly MaterialMode[] }>() const upDirection = defineModel<UpDirection>('upDirection') @@ -51,23 +50,10 @@ const upDirectionOptions = [ { label: '+Z', value: '+z' } ] -const materialModeOptions = computed(() => { - const options = [ - { label: t('load3d.materialModes.original'), value: 'original' } - ] - - if (isPlyModel) { - options.push({ - label: t('load3d.materialModes.pointCloud'), - value: 'pointCloud' - }) - } - - options.push( - { label: t('load3d.materialModes.normal'), value: 'normal' }, - { label: t('load3d.materialModes.wireframe'), value: 'wireframe' } - ) - - return options -}) +const materialModeOptions = computed(() => + materialModes.map((mode) => ({ + label: t(`load3d.materialModes.${mode}`), + value: mode + })) +) </script> diff --git a/src/components/load3d/controls/viewer/ViewerSceneControls.test.ts b/src/components/load3d/controls/viewer/ViewerSceneControls.test.ts new file mode 100644 index 0000000000..cb106fe475 --- /dev/null +++ b/src/components/load3d/controls/viewer/ViewerSceneControls.test.ts @@ -0,0 +1,205 @@ +import { render, screen } from '@testing-library/vue' +import userEvent from '@testing-library/user-event' +import { describe, expect, it, vi } from 'vitest' +import { ref } from 'vue' +import { createI18n } from 'vue-i18n' + +import ViewerSceneControls from '@/components/load3d/controls/viewer/ViewerSceneControls.vue' + +vi.mock('primevue/checkbox', () => ({ + default: { + name: 'Checkbox', + props: ['modelValue', 'inputId', 'binary', 'name'], + emits: ['update:modelValue'], + template: ` + <input + type="checkbox" + :id="inputId" + :name="name" + :checked="modelValue" + @change="$emit('update:modelValue', $event.target.checked)" + /> + ` + } +})) + +const i18n = createI18n({ + legacy: false, + locale: 'en', + messages: { + en: { + load3d: { + backgroundColor: 'Background color', + showGrid: 'Show grid', + uploadBackgroundImage: 'Upload background image', + tiledMode: 'Tiled', + panoramaMode: 'Panorama', + removeBackgroundImage: 'Remove background image' + } + } + } +}) + +type RenderProps = { + backgroundColor?: string + showGrid?: boolean + backgroundRenderMode?: 'tiled' | 'panorama' + hasBackgroundImage?: boolean + disableBackgroundUpload?: boolean + onUpdateBackgroundImage?: (file: File | null) => void +} + +function renderComponent(overrides: RenderProps = {}) { + const backgroundColor = ref<string>(overrides.backgroundColor ?? '#282828') + const showGrid = ref<boolean>(overrides.showGrid ?? true) + const backgroundRenderMode = ref<'tiled' | 'panorama'>( + overrides.backgroundRenderMode ?? 'tiled' + ) + + const utils = render(ViewerSceneControls, { + props: { + backgroundColor: backgroundColor.value, + 'onUpdate:backgroundColor': (v: string | undefined) => { + if (v !== undefined) backgroundColor.value = v + }, + showGrid: showGrid.value, + 'onUpdate:showGrid': (v: boolean | undefined) => { + if (v !== undefined) showGrid.value = v + }, + backgroundRenderMode: backgroundRenderMode.value, + 'onUpdate:backgroundRenderMode': ( + v: 'tiled' | 'panorama' | undefined + ) => { + if (v) backgroundRenderMode.value = v + }, + hasBackgroundImage: overrides.hasBackgroundImage ?? false, + disableBackgroundUpload: overrides.disableBackgroundUpload ?? false, + onUpdateBackgroundImage: overrides.onUpdateBackgroundImage + }, + global: { plugins: [i18n] } + }) + + return { + ...utils, + backgroundColor, + showGrid, + backgroundRenderMode, + user: userEvent.setup() + } +} + +describe('ViewerSceneControls', () => { + describe('without a background image', () => { + it('renders the color picker', () => { + renderComponent({ hasBackgroundImage: false }) + + expect(screen.getByText('Background color')).toBeInTheDocument() + }) + + it('renders the upload button when uploads are not disabled', () => { + renderComponent({ + hasBackgroundImage: false, + disableBackgroundUpload: false + }) + + expect( + screen.getByRole('button', { name: /upload background image/i }) + ).toBeInTheDocument() + }) + + it('hides the upload button when uploads are disabled', () => { + renderComponent({ + hasBackgroundImage: false, + disableBackgroundUpload: true + }) + + expect( + screen.queryByRole('button', { name: /upload background image/i }) + ).not.toBeInTheDocument() + }) + + it('does not render the tiled / panorama / remove buttons', () => { + renderComponent({ hasBackgroundImage: false }) + + expect( + screen.queryByRole('button', { name: 'Tiled' }) + ).not.toBeInTheDocument() + expect( + screen.queryByRole('button', { name: 'Panorama' }) + ).not.toBeInTheDocument() + expect( + screen.queryByRole('button', { name: /remove background image/i }) + ).not.toBeInTheDocument() + }) + }) + + describe('with a background image', () => { + it('hides the color picker and upload button', () => { + renderComponent({ hasBackgroundImage: true }) + + expect(screen.queryByText('Background color')).not.toBeInTheDocument() + expect( + screen.queryByRole('button', { name: /upload background image/i }) + ).not.toBeInTheDocument() + }) + + it('renders the tiled / panorama / remove buttons', () => { + renderComponent({ hasBackgroundImage: true }) + + expect(screen.getByRole('button', { name: 'Tiled' })).toBeInTheDocument() + expect( + screen.getByRole('button', { name: 'Panorama' }) + ).toBeInTheDocument() + expect( + screen.getByRole('button', { name: /remove background image/i }) + ).toBeInTheDocument() + }) + + it('updates backgroundRenderMode v-model to tiled when the tiled button is clicked', async () => { + const { user, backgroundRenderMode } = renderComponent({ + hasBackgroundImage: true, + backgroundRenderMode: 'panorama' + }) + + await user.click(screen.getByRole('button', { name: 'Tiled' })) + + expect(backgroundRenderMode.value).toBe('tiled') + }) + + it('updates backgroundRenderMode v-model to panorama when the panorama button is clicked', async () => { + const { user, backgroundRenderMode } = renderComponent({ + hasBackgroundImage: true, + backgroundRenderMode: 'tiled' + }) + + await user.click(screen.getByRole('button', { name: 'Panorama' })) + + expect(backgroundRenderMode.value).toBe('panorama') + }) + + it('emits updateBackgroundImage(null) when the remove button is clicked', async () => { + const onUpdateBackgroundImage = vi.fn() + const { user } = renderComponent({ + hasBackgroundImage: true, + onUpdateBackgroundImage + }) + + await user.click( + screen.getByRole('button', { name: /remove background image/i }) + ) + + expect(onUpdateBackgroundImage).toHaveBeenCalledWith(null) + }) + }) + + describe('show grid', () => { + it('emits the toggled value via v-model', async () => { + const { user, showGrid } = renderComponent({ showGrid: true }) + const checkbox = screen.getByRole('checkbox') + + await user.click(checkbox) + + expect(showGrid.value).toBe(false) + }) + }) +}) diff --git a/src/components/queue/JobHistoryActionsMenu.vue b/src/components/queue/JobHistoryActionsMenu.vue index 3e021fb029..4b2f07764f 100644 --- a/src/components/queue/JobHistoryActionsMenu.vue +++ b/src/components/queue/JobHistoryActionsMenu.vue @@ -98,6 +98,7 @@ import { useQueueFeatureFlags } from '@/composables/queue/useQueueFeatureFlags' import { buildTooltipConfig } from '@/composables/useTooltipConfig' import { isCloud } from '@/platform/distribution/types' import { useSettingStore } from '@/platform/settings/settingStore' +import { useSurveyFeatureTracking } from '@/platform/surveys/useSurveyFeatureTracking' import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore' const emit = defineEmits<{ @@ -107,6 +108,7 @@ const emit = defineEmits<{ const { t } = useI18n() const settingStore = useSettingStore() const sidebarTabStore = useSidebarTabStore() +const { trackFeatureUsed } = useSurveyFeatureTracking('queue-progress-overlay') const moreTooltipConfig = computed(() => buildTooltipConfig(t('g.more'))) const { isQueuePanelV2Enabled, isRunProgressBarEnabled } = @@ -119,6 +121,7 @@ const onClearHistoryFromMenu = (close: () => void) => { } const onToggleDockedJobHistory = async (close: () => void) => { + trackFeatureUsed() close() try { @@ -138,6 +141,7 @@ const onToggleDockedJobHistory = async (close: () => void) => { } const onToggleRunProgressBar = async () => { + trackFeatureUsed() await settingStore.set( 'Comfy.Queue.ShowRunProgressBar', !isRunProgressBarEnabled.value diff --git a/src/components/queue/QueueNotificationBanner.vue b/src/components/queue/QueueNotificationBanner.vue index d199eeeea5..91ad8074e4 100644 --- a/src/components/queue/QueueNotificationBanner.vue +++ b/src/components/queue/QueueNotificationBanner.vue @@ -140,11 +140,11 @@ const iconClass = computed(() => { const iconColorClass = computed(() => { if (notification.type === 'queuedPending') { - return 'animate-spin text-slate-100' + return 'animate-spin text-text-secondary' } if (notification.type === 'failed') { return 'text-danger-200' } - return 'text-slate-100' + return 'text-text-secondary' }) </script> diff --git a/src/components/queue/QueueNotificationBannerHost.vue b/src/components/queue/QueueNotificationBannerHost.vue index 48fb3e2247..34fccdc832 100644 --- a/src/components/queue/QueueNotificationBannerHost.vue +++ b/src/components/queue/QueueNotificationBannerHost.vue @@ -5,6 +5,7 @@ role="status" aria-live="polite" aria-atomic="true" + data-testid="queue-notification-banner" > <QueueNotificationBanner :notification="currentNotification" /> </div> diff --git a/src/components/queue/QueueOverlayExpanded.vue b/src/components/queue/QueueOverlayExpanded.vue index db2cbe846f..e049ffa33b 100644 --- a/src/components/queue/QueueOverlayExpanded.vue +++ b/src/components/queue/QueueOverlayExpanded.vue @@ -13,7 +13,7 @@ :selected-sort-mode="selectedSortMode" :has-failed-jobs="hasFailedJobs" @show-assets="$emit('showAssets')" - @update:selected-job-tab="$emit('update:selectedJobTab', $event)" + @update:selected-job-tab="onUpdateSelectedJobTab" @update:selected-workflow-filter=" $emit('update:selectedWorkflowFilter', $event) " @@ -50,6 +50,7 @@ import type { import type { MenuEntry } from '@/composables/queue/useJobMenu' import { useJobMenu } from '@/composables/queue/useJobMenu' import { useErrorHandling } from '@/composables/useErrorHandling' +import { useSurveyFeatureTracking } from '@/platform/surveys/useSurveyFeatureTracking' import QueueOverlayHeader from './QueueOverlayHeader.vue' import JobContextMenu from './job/JobContextMenu.vue' @@ -81,6 +82,7 @@ const emit = defineEmits<{ const currentMenuItem = ref<JobListItem | null>(null) const jobContextMenuRef = ref<InstanceType<typeof JobContextMenu> | null>(null) const { wrapWithErrorHandlingAsync } = useErrorHandling() +const { trackFeatureUsed } = useSurveyFeatureTracking('queue-progress-overlay') const { jobMenuEntries } = useJobMenu( () => currentMenuItem.value, @@ -95,6 +97,11 @@ const onDeleteItemEvent = (item: JobListItem) => { emit('deleteItem', item) } +const onUpdateSelectedJobTab = (value: JobTab) => { + trackFeatureUsed() + emit('update:selectedJobTab', value) +} + const onMenuItem = (item: JobListItem, event: Event) => { currentMenuItem.value = item jobContextMenuRef.value?.open(event) diff --git a/src/components/queue/QueueProgressOverlay.vue b/src/components/queue/QueueProgressOverlay.vue index fb05c0b8d4..9ba1dea9f9 100644 --- a/src/components/queue/QueueProgressOverlay.vue +++ b/src/components/queue/QueueProgressOverlay.vue @@ -66,6 +66,7 @@ import { useResultGallery } from '@/composables/queue/useResultGallery' import { useErrorHandling } from '@/composables/useErrorHandling' import { useAssetSelectionStore } from '@/platform/assets/composables/useAssetSelectionStore' import { isCloud } from '@/platform/distribution/types' +import { useSurveyFeatureTracking } from '@/platform/surveys/useSurveyFeatureTracking' import { api } from '@/scripts/api' import { useAssetsStore } from '@/stores/assetsStore' import { useCommandStore } from '@/stores/commandStore' @@ -93,6 +94,7 @@ const assetsStore = useAssetsStore() const assetSelectionStore = useAssetSelectionStore() const { showQueueClearHistoryDialog } = useQueueClearHistoryDialog() const { wrapWithErrorHandlingAsync } = useErrorHandling() +const { trackFeatureUsed } = useSurveyFeatureTracking('queue-progress-overlay') const { totalPercentFormatted, @@ -188,6 +190,7 @@ const { const displayedJobGroups = computed(() => groupedJobItems.value) const onCancelItem = wrapWithErrorHandlingAsync(async (item: JobListItem) => { + trackFeatureUsed() const jobId = item.taskRef?.jobId if (!jobId) return @@ -209,6 +212,7 @@ const onCancelItem = wrapWithErrorHandlingAsync(async (item: JobListItem) => { }) const onDeleteItem = wrapWithErrorHandlingAsync(async (item: JobListItem) => { + trackFeatureUsed() if (!item.taskRef) return await queueStore.delete(item.taskRef) }) @@ -224,10 +228,12 @@ const setExpanded = (expanded: boolean) => { } const viewAllJobs = () => { + trackFeatureUsed() setExpanded(true) } const toggleAssetsSidebar = () => { + trackFeatureUsed() sidebarTabStore.toggleSidebarTab('assets') } @@ -257,12 +263,14 @@ const focusAssetInSidebar = async (item: JobListItem) => { const inspectJobAsset = wrapWithErrorHandlingAsync( async (item: JobListItem) => { + trackFeatureUsed() await openResultGallery(item) await focusAssetInSidebar(item) } ) const cancelQueuedWorkflows = wrapWithErrorHandlingAsync(async () => { + trackFeatureUsed() // Capture pending jobIds before clearing const pendingJobIds = queueStore.pendingTasks .map((task) => task.jobId) @@ -275,6 +283,7 @@ const cancelQueuedWorkflows = wrapWithErrorHandlingAsync(async () => { }) const interruptAll = wrapWithErrorHandlingAsync(async () => { + trackFeatureUsed() const tasks = queueStore.runningTasks const jobIds = tasks .map((task) => task.jobId) @@ -298,6 +307,7 @@ const interruptAll = wrapWithErrorHandlingAsync(async () => { }) const onClearHistoryFromMenu = () => { + trackFeatureUsed() showQueueClearHistoryDialog() } </script> diff --git a/src/components/queue/job/JobFilterActions.vue b/src/components/queue/job/JobFilterActions.vue index fce83d2edc..f8c16f23df 100644 --- a/src/components/queue/job/JobFilterActions.vue +++ b/src/components/queue/job/JobFilterActions.vue @@ -122,6 +122,7 @@ import Button from '@/components/ui/button/Button.vue' import { jobSortModes } from '@/composables/queue/useJobList' import type { JobSortMode } from '@/composables/queue/useJobList' import { buildTooltipConfig } from '@/composables/useTooltipConfig' +import { useSurveyFeatureTracking } from '@/platform/surveys/useSurveyFeatureTracking' const { hideShowAssetsAction = false, @@ -147,6 +148,7 @@ const emit = defineEmits<{ }>() const { t } = useI18n() +const { trackFeatureUsed } = useSurveyFeatureTracking('queue-progress-overlay') const filterTooltipConfig = computed(() => buildTooltipConfig(t('sideToolbar.queueProgressOverlay.filterBy')) @@ -170,6 +172,7 @@ const onSelectWorkflowFilter = ( value: 'all' | 'current', close: () => void ) => { + trackFeatureUsed() selectWorkflowFilter(value) close() } @@ -179,6 +182,7 @@ const selectSortMode = (value: JobSortMode) => { } const onSelectSortMode = (value: JobSortMode, close: () => void) => { + trackFeatureUsed() selectSortMode(value) close() } diff --git a/src/components/queue/job/JobGroupsList.test.ts b/src/components/queue/job/JobGroupsList.test.ts deleted file mode 100644 index dc63db2934..0000000000 --- a/src/components/queue/job/JobGroupsList.test.ts +++ /dev/null @@ -1,136 +0,0 @@ -import { fireEvent, render, screen } from '@testing-library/vue' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { defineComponent, nextTick } from 'vue' - -import JobGroupsList from '@/components/queue/job/JobGroupsList.vue' -import type { JobGroup, JobListItem } from '@/composables/queue/useJobList' -import type { TaskItemImpl } from '@/stores/queueStore' - -const QueueJobItemStub = defineComponent({ - name: 'QueueJobItemStub', - props: { - jobId: { type: String, required: true }, - workflowId: { type: String, default: undefined }, - state: { type: String, required: true }, - title: { type: String, required: true }, - rightText: { type: String, default: '' }, - iconName: { type: String, default: undefined }, - iconImageUrl: { type: String, default: undefined }, - showClear: { type: Boolean, default: undefined }, - showMenu: { type: Boolean, default: undefined }, - progressTotalPercent: { type: Number, default: undefined }, - progressCurrentPercent: { type: Number, default: undefined }, - runningNodeName: { type: String, default: undefined }, - activeDetailsId: { type: String, default: null } - }, - template: ` - <div class="queue-job-item-stub" :data-job-id="jobId" :data-active-details-id="activeDetailsId"> - <div :data-testid="'enter-' + jobId" @click="$emit('details-enter', jobId)" /> - <div :data-testid="'leave-' + jobId" @click="$emit('details-leave', jobId)" /> - </div> - ` -}) - -const createJobItem = (overrides: Partial<JobListItem> = {}): JobListItem => { - const { taskRef, ...rest } = overrides - return { - id: 'job-id', - title: 'Example job', - meta: 'Meta text', - state: 'running', - iconName: 'icon', - iconImageUrl: 'https://example.com/icon.png', - showClear: true, - taskRef: (taskRef ?? { - workflow: { id: 'workflow-id' } - }) as TaskItemImpl, - progressTotalPercent: 60, - progressCurrentPercent: 30, - runningNodeName: 'Node A', - ...rest - } -} - -function getActiveDetailsId(container: Element, jobId: string): string | null { - return ( - container - .querySelector(`[data-job-id="${jobId}"]`) - ?.getAttribute('data-active-details-id') ?? null - ) -} - -const renderComponent = (groups: JobGroup[]) => - render(JobGroupsList, { - props: { displayedJobGroups: groups }, - global: { - stubs: { - QueueJobItem: QueueJobItemStub - } - } - }) - -describe('JobGroupsList hover behavior', () => { - afterEach(() => { - vi.useRealTimers() - }) - - it('delays showing and hiding details while hovering over job rows', async () => { - vi.useFakeTimers() - const job = createJobItem({ id: 'job-d' }) - const { container } = renderComponent([ - { key: 'today', label: 'Today', items: [job] } - ]) - - // eslint-disable-next-line testing-library/prefer-user-event - await fireEvent.click(screen.getByTestId('enter-job-d')) - vi.advanceTimersByTime(199) - await nextTick() - expect(getActiveDetailsId(container, 'job-d')).toBeNull() - - vi.advanceTimersByTime(1) - await nextTick() - expect(getActiveDetailsId(container, 'job-d')).toBe(job.id) - - // eslint-disable-next-line testing-library/prefer-user-event - await fireEvent.click(screen.getByTestId('leave-job-d')) - vi.advanceTimersByTime(149) - await nextTick() - expect(getActiveDetailsId(container, 'job-d')).toBe(job.id) - - vi.advanceTimersByTime(1) - await nextTick() - expect(getActiveDetailsId(container, 'job-d')).toBeNull() - }) - - it('clears the previous popover when hovering a new row briefly and leaving', async () => { - vi.useFakeTimers() - const firstJob = createJobItem({ id: 'job-1', title: 'First job' }) - const secondJob = createJobItem({ id: 'job-2', title: 'Second job' }) - const { container } = renderComponent([ - { key: 'today', label: 'Today', items: [firstJob, secondJob] } - ]) - - // eslint-disable-next-line testing-library/prefer-user-event - await fireEvent.click(screen.getByTestId('enter-job-1')) - vi.advanceTimersByTime(200) - await nextTick() - expect(getActiveDetailsId(container, 'job-1')).toBe(firstJob.id) - - // eslint-disable-next-line testing-library/prefer-user-event - await fireEvent.click(screen.getByTestId('leave-job-1')) - // eslint-disable-next-line testing-library/prefer-user-event - await fireEvent.click(screen.getByTestId('enter-job-2')) - vi.advanceTimersByTime(100) - await nextTick() - // eslint-disable-next-line testing-library/prefer-user-event - await fireEvent.click(screen.getByTestId('leave-job-2')) - - vi.advanceTimersByTime(50) - await nextTick() - expect(getActiveDetailsId(container, 'job-1')).toBeNull() - - vi.advanceTimersByTime(50) - await nextTick() - expect(getActiveDetailsId(container, 'job-2')).toBeNull() - }) -}) diff --git a/src/components/queue/job/JobGroupsList.vue b/src/components/queue/job/JobGroupsList.vue deleted file mode 100644 index 8e3ae6b7fd..0000000000 --- a/src/components/queue/job/JobGroupsList.vue +++ /dev/null @@ -1,82 +0,0 @@ -<template> - <div class="flex flex-col gap-4 px-3 pb-4"> - <div - v-for="group in displayedJobGroups" - :key="group.key" - class="flex flex-col gap-2" - > - <div class="text-[12px] leading-none text-text-secondary"> - {{ group.label }} - </div> - <QueueJobItem - v-for="ji in group.items" - :key="ji.id" - :job-id="ji.id" - :workflow-id="ji.taskRef?.workflowId" - :state="ji.state" - :title="ji.title" - :right-text="ji.meta" - :icon-name="ji.iconName" - :icon-image-url="ji.iconImageUrl" - :show-clear="ji.showClear" - :show-menu="true" - :progress-total-percent="ji.progressTotalPercent" - :progress-current-percent="ji.progressCurrentPercent" - :running-node-name="ji.runningNodeName" - :active-details-id="activeDetailsId" - @cancel="emitCancelItem(ji)" - @delete="emitDeleteItem(ji)" - @menu="(ev) => $emit('menu', ji, ev)" - @view="$emit('viewItem', ji)" - @details-enter="onDetailsEnter" - @details-leave="onDetailsLeave" - /> - </div> - </div> -</template> - -<script setup lang="ts"> -import QueueJobItem from '@/components/queue/job/QueueJobItem.vue' -import type { JobGroup, JobListItem } from '@/composables/queue/useJobList' -import { useJobDetailsHover } from '@/composables/queue/useJobDetailsHover' - -const { displayedJobGroups } = defineProps<{ displayedJobGroups: JobGroup[] }>() - -const emit = defineEmits<{ - (e: 'cancelItem', item: JobListItem): void - (e: 'deleteItem', item: JobListItem): void - (e: 'menu', item: JobListItem, ev: MouseEvent): void - (e: 'viewItem', item: JobListItem): void -}>() - -const { - activeDetails: activeDetailsId, - clearHoverTimers, - scheduleDetailsHide, - scheduleDetailsShow -} = useJobDetailsHover<string>({ - getActiveId: (jobId) => jobId, - getDisplayedJobGroups: () => displayedJobGroups -}) - -function emitCancelItem(item: JobListItem) { - emit('cancelItem', item) -} - -function emitDeleteItem(item: JobListItem) { - emit('deleteItem', item) -} - -function onDetailsEnter(jobId: string) { - if (activeDetailsId.value === jobId) { - clearHoverTimers() - return - } - - scheduleDetailsShow(jobId) -} - -function onDetailsLeave(jobId: string) { - scheduleDetailsHide(jobId) -} -</script> diff --git a/src/components/queue/job/QueueAssetPreview.vue b/src/components/queue/job/QueueAssetPreview.vue deleted file mode 100644 index 040a0ebf61..0000000000 --- a/src/components/queue/job/QueueAssetPreview.vue +++ /dev/null @@ -1,65 +0,0 @@ -<template> - <div class="w-[300px] min-w-[260px] rounded-lg shadow-md"> - <div class="p-3"> - <div class="relative aspect-square w-full overflow-hidden rounded-lg"> - <img - ref="imgRef" - :src="imageUrl" - :alt="name" - class="size-full cursor-pointer object-contain" - @click="$emit('image-click')" - @load="onImgLoad" - /> - <div - v-if="timeLabel" - class="absolute bottom-2 left-2 rounded-sm px-2 py-0.5 text-xs text-text-primary" - :style="{ - background: 'rgba(217, 217, 217, 0.40)', - backdropFilter: 'blur(2px)' - }" - > - {{ timeLabel }} - </div> - </div> - <div class="mt-2 text-center"> - <div - class="truncate text-sm/normal font-semibold text-text-primary" - :title="name" - > - {{ name }} - </div> - <div - v-if="width && height" - class="mt-1 text-xs/normal text-text-secondary" - > - {{ width }}x{{ height }} - </div> - </div> - </div> - </div> -</template> - -<script setup lang="ts"> -import { ref } from 'vue' - -defineOptions({ inheritAttrs: false }) - -defineProps<{ - imageUrl: string - name: string - timeLabel?: string -}>() - -defineEmits(['image-click']) - -const imgRef = ref<HTMLImageElement | null>(null) -const width = ref<number | null>(null) -const height = ref<number | null>(null) - -const onImgLoad = () => { - const el = imgRef.value - if (!el) return - width.value = el.naturalWidth || null - height.value = el.naturalHeight || null -} -</script> diff --git a/src/components/queue/job/QueueJobItem.stories.ts b/src/components/queue/job/QueueJobItem.stories.ts deleted file mode 100644 index 6f4246e79e..0000000000 --- a/src/components/queue/job/QueueJobItem.stories.ts +++ /dev/null @@ -1,133 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/vue3-vite' - -import QueueJobItem from './QueueJobItem.vue' - -const meta: Meta<typeof QueueJobItem> = { - title: 'Queue/QueueJobItem', - component: QueueJobItem, - parameters: { - layout: 'padded' - }, - argTypes: { - onCancel: { action: 'cancel' }, - onDelete: { action: 'delete' }, - onMenu: { action: 'menu' }, - onView: { action: 'view' } - } -} - -export default meta -type Story = StoryObj<typeof meta> - -const thumb = (hex: string) => - `data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='256' height='256'><rect width='256' height='256' fill='%23${hex}'/></svg>` - -export const PendingRecentlyAdded: Story = { - args: { - jobId: 'job-pending-added-1', - state: 'pending', - title: 'Job added to queue', - rightText: '12:30 PM', - iconName: 'icon-[lucide--check]' - } -} - -export const Pending: Story = { - args: { - jobId: 'job-pending-1', - state: 'pending', - title: 'Pending job', - rightText: '12:31 PM' - } -} - -export const Initialization: Story = { - args: { - jobId: 'job-init-1', - state: 'initialization', - title: 'Initializing...' - } -} - -export const RunningTotalOnly: Story = { - args: { - jobId: 'job-running-1', - state: 'running', - title: 'Generating image', - progressTotalPercent: 42 - } -} - -export const RunningWithCurrent: Story = { - args: { - jobId: 'job-running-2', - state: 'running', - title: 'Generating image', - progressTotalPercent: 66, - progressCurrentPercent: 10 - } -} - -export const CompletedWithPreview: Story = { - args: { - jobId: 'job-completed-1', - state: 'completed', - title: 'Prompt #1234', - rightText: '12.79s', - iconImageUrl: thumb('4dabf7') - } -} - -export const CompletedNoPreview: Story = { - args: { - jobId: 'job-completed-2', - state: 'completed', - title: 'Prompt #5678', - rightText: '8.12s' - } -} - -export const Failed: Story = { - args: { - jobId: 'job-failed-1', - state: 'failed', - title: 'Failed job', - rightText: 'Failed' - } -} - -export const Gallery: Story = { - render: (args) => ({ - components: { QueueJobItem }, - setup() { - return { args } - }, - template: ` - <div class="flex flex-col gap-2 w-[420px]"> - <QueueJobItem job-id="job-pending-added-1" state="pending" title="Job added to queue" right-text="12:30 PM" icon-name="icon-[lucide--check]" v-bind="args" /> - <QueueJobItem job-id="job-pending-1" state="pending" title="Pending job" right-text="12:31 PM" v-bind="args" /> - <QueueJobItem job-id="job-init-1" state="initialization" title="Initializing..." v-bind="args" /> - <QueueJobItem job-id="job-running-1" state="running" title="Generating image" :progress-total-percent="42" v-bind="args" /> - <QueueJobItem - job-id="job-running-2" - state="running" - title="Generating image" - :progress-total-percent="66" - :progress-current-percent="10" - running-node-name="KSampler" - v-bind="args" - /> - <QueueJobItem - job-id="job-completed-1" - state="completed" - title="Prompt #1234" - right-text="12.79s" - icon-image-url="${thumb('4dabf7')}" - v-bind="args" - /> - <QueueJobItem job-id="job-completed-2" state="completed" title="Prompt #5678" right-text="8.12s" v-bind="args" /> - <QueueJobItem job-id="job-failed-1" state="failed" title="Failed job" right-text="Failed" v-bind="args" /> - </div> - ` - }) -} diff --git a/src/components/queue/job/QueueJobItem.vue b/src/components/queue/job/QueueJobItem.vue deleted file mode 100644 index dd3d782480..0000000000 --- a/src/components/queue/job/QueueJobItem.vue +++ /dev/null @@ -1,362 +0,0 @@ -<template> - <div - ref="rowRef" - class="relative" - @mouseenter="onRowEnter" - @mouseleave="onRowLeave" - @contextmenu.stop.prevent="onContextMenu" - > - <Teleport to="body"> - <div - v-if="!isPreviewVisible && showDetails && popoverPosition" - class="fixed z-50" - :style="{ - top: `${popoverPosition.top}px`, - left: `${popoverPosition.left}px` - }" - @mouseenter="onPopoverEnter" - @mouseleave="onPopoverLeave" - > - <JobDetailsPopover :job-id="jobId" :workflow-id="workflowId" /> - </div> - </Teleport> - <Teleport to="body"> - <div - v-if="isPreviewVisible && canShowPreview && popoverPosition" - class="fixed z-50" - :style="{ - top: `${popoverPosition.top}px`, - left: `${popoverPosition.left}px` - }" - @mouseenter="onPreviewEnter" - @mouseleave="onPreviewLeave" - > - <QueueAssetPreview - :image-url="iconImageUrl!" - :name="title" - :time-label="rightText || undefined" - @image-click="emit('view')" - /> - </div> - </Teleport> - <div - class="relative flex items-center justify-between gap-2 overflow-hidden rounded-lg border border-secondary-background bg-secondary-background p-1 text-[12px] text-text-primary transition-colors duration-150 ease-in-out hover:border-secondary-background-hover hover:bg-secondary-background-hover" - @mouseenter="isHovered = true" - @mouseleave="isHovered = false" - > - <div - v-if=" - state === 'running' && - hasAnyProgressPercent(progressTotalPercent, progressCurrentPercent) - " - :class="progressBarContainerClass" - > - <div - v-if="hasProgressPercent(progressTotalPercent)" - :class="progressBarPrimaryClass" - :style="progressPercentStyle(progressTotalPercent)" - /> - <div - v-if="hasProgressPercent(progressCurrentPercent)" - :class="progressBarSecondaryClass" - :style="progressPercentStyle(progressCurrentPercent)" - /> - </div> - - <div class="relative z-1 flex items-center gap-1"> - <div class="relative inline-flex items-center justify-center"> - <div - class="absolute top-1/2 left-1/2 size-10 -translate-1/2" - @mouseenter.stop="onIconEnter" - @mouseleave.stop="onIconLeave" - /> - <div - class="inline-flex size-6 items-center justify-center overflow-hidden rounded-[6px]" - > - <img - v-if="iconImageUrl" - :src="iconImageUrl" - class="size-full object-cover" - /> - <i - v-else - :class="cn(iconClass, 'size-4', shouldSpin && 'animate-spin')" - /> - </div> - </div> - </div> - - <div class="relative z-1 min-w-0 flex-1"> - <div class="truncate opacity-90" :title="title"> - <slot name="primary">{{ title }}</slot> - </div> - </div> - - <!-- - TODO: Refactor action buttons to use a declarative config system. - - Instead of hardcoding button visibility logic in the template, define an array of - action button configs with properties like: - - icon, label, action, tooltip - - visibleStates: JobState[] (which job states show this button) - - alwaysVisible: boolean (show without hover) - - destructive: boolean (use destructive styling) - - Then render buttons in two groups: - 1. Always-visible buttons (outside Transition) - 2. Hover-only buttons (inside Transition) - - This would eliminate the current duplication where the cancel button exists - both outside (for running) and inside (for pending) the Transition. - --> - <div class="relative z-1 flex items-center gap-2 text-text-secondary"> - <Transition - mode="out-in" - enter-active-class="transition-opacity transition-transform duration-150 ease-out" - leave-active-class="transition-opacity transition-transform duration-150 ease-in" - enter-from-class="opacity-0 translate-y-0.5" - enter-to-class="opacity-100 translate-y-0" - leave-from-class="opacity-100 translate-y-0" - leave-to-class="opacity-0 translate-y-0.5" - > - <div - v-if="isHovered" - key="actions" - class="inline-flex items-center gap-2 pr-1" - > - <Button - v-if="state === 'failed' && computedShowClear" - v-tooltip.top="deleteTooltipConfig" - variant="destructive" - size="icon" - :aria-label="t('g.delete')" - @click.stop="onDeleteClick" - > - <i class="icon-[lucide--trash-2] size-4" /> - </Button> - <Button - v-else-if=" - state !== 'completed' && - state !== 'running' && - computedShowClear - " - v-tooltip.top="cancelTooltipConfig" - variant="destructive" - size="icon" - :aria-label="t('g.cancel')" - @click.stop="onCancelClick" - > - <i class="icon-[lucide--x] size-4" /> - </Button> - <Button - v-else-if="state === 'completed'" - variant="textonly" - size="sm" - @click.stop="emit('view')" - >{{ t('menuLabels.View') }}</Button - > - <Button - v-if="showMenu !== undefined ? showMenu : true" - v-tooltip.top="moreTooltipConfig" - variant="textonly" - size="icon-sm" - :aria-label="t('g.more')" - @click.stop="emit('menu', $event)" - > - <i class="icon-[lucide--more-horizontal] size-4" /> - </Button> - </div> - <div v-else-if="state !== 'running'" key="secondary" class="pr-2"> - <slot name="secondary">{{ rightText }}</slot> - </div> - </Transition> - <!-- Running job cancel button - always visible --> - <Button - v-if="state === 'running' && computedShowClear" - v-tooltip.top="cancelTooltipConfig" - variant="destructive" - size="icon" - :aria-label="t('g.cancel')" - @click.stop="onCancelClick" - > - <i class="icon-[lucide--x] size-4" /> - </Button> - </div> - </div> - </div> -</template> - -<script setup lang="ts"> -import { computed, nextTick, ref, watch } from 'vue' -import { useI18n } from 'vue-i18n' - -import JobDetailsPopover from '@/components/queue/job/JobDetailsPopover.vue' -import { getHoverPopoverPosition } from '@/components/queue/job/getHoverPopoverPosition' -import QueueAssetPreview from '@/components/queue/job/QueueAssetPreview.vue' -import Button from '@/components/ui/button/Button.vue' -import { useProgressBarBackground } from '@/composables/useProgressBarBackground' -import { buildTooltipConfig } from '@/composables/useTooltipConfig' -import type { JobState } from '@/types/queue' -import { iconForJobState } from '@/utils/queueDisplay' -import { cn } from '@comfyorg/tailwind-utils' - -const { - jobId, - workflowId, - state, - title, - rightText = '', - iconName, - iconImageUrl, - showClear, - showMenu, - progressTotalPercent, - progressCurrentPercent, - activeDetailsId = null -} = defineProps<{ - jobId: string - workflowId?: string - state: JobState - title: string - rightText?: string - iconName?: string - iconImageUrl?: string - showClear?: boolean - showMenu?: boolean - progressTotalPercent?: number - progressCurrentPercent?: number - activeDetailsId?: string | null -}>() - -const emit = defineEmits<{ - (e: 'cancel'): void - (e: 'delete'): void - (e: 'menu', event: MouseEvent): void - (e: 'view'): void - (e: 'details-enter', jobId: string): void - (e: 'details-leave', jobId: string): void -}>() - -const { t } = useI18n() -const { - progressBarContainerClass, - progressBarPrimaryClass, - progressBarSecondaryClass, - hasProgressPercent, - hasAnyProgressPercent, - progressPercentStyle -} = useProgressBarBackground() - -const cancelTooltipConfig = computed(() => buildTooltipConfig(t('g.cancel'))) -const deleteTooltipConfig = computed(() => buildTooltipConfig(t('g.delete'))) -const moreTooltipConfig = computed(() => buildTooltipConfig(t('g.more'))) - -const rowRef = ref<HTMLDivElement | null>(null) -const showDetails = computed(() => activeDetailsId === jobId) - -const onRowEnter = () => { - if (!isPreviewVisible.value) emit('details-enter', jobId) -} -const onRowLeave = () => emit('details-leave', jobId) -const onPopoverEnter = () => emit('details-enter', jobId) -const onPopoverLeave = () => emit('details-leave', jobId) - -const isPreviewVisible = ref(false) -const previewHideTimer = ref<number | null>(null) -const previewShowTimer = ref<number | null>(null) -const clearPreviewHideTimer = () => { - if (previewHideTimer.value !== null) { - clearTimeout(previewHideTimer.value) - previewHideTimer.value = null - } -} -const clearPreviewShowTimer = () => { - if (previewShowTimer.value !== null) { - clearTimeout(previewShowTimer.value) - previewShowTimer.value = null - } -} -const canShowPreview = computed(() => state === 'completed' && !!iconImageUrl) -const scheduleShowPreview = () => { - if (!canShowPreview.value) return - clearPreviewHideTimer() - clearPreviewShowTimer() - previewShowTimer.value = window.setTimeout(() => { - isPreviewVisible.value = true - previewShowTimer.value = null - }, 200) -} -const scheduleHidePreview = () => { - clearPreviewHideTimer() - clearPreviewShowTimer() - previewHideTimer.value = window.setTimeout(() => { - isPreviewVisible.value = false - previewHideTimer.value = null - }, 150) -} -const onIconEnter = () => scheduleShowPreview() -const onIconLeave = () => scheduleHidePreview() -const onPreviewEnter = () => scheduleShowPreview() -const onPreviewLeave = () => scheduleHidePreview() - -const popoverPosition = ref<{ top: number; left: number } | null>(null) - -const updatePopoverPosition = () => { - const el = rowRef.value - if (!el) return - const rect = el.getBoundingClientRect() - popoverPosition.value = getHoverPopoverPosition(rect, window.innerWidth) -} - -const isAnyPopoverVisible = computed( - () => showDetails.value || (isPreviewVisible.value && canShowPreview.value) -) - -watch( - isAnyPopoverVisible, - (visible) => { - if (visible) { - nextTick(updatePopoverPosition) - } else { - popoverPosition.value = null - } - }, - { immediate: false } -) - -const isHovered = ref(false) - -const iconClass = computed(() => { - if (iconName) return iconName - return iconForJobState(state) -}) - -const shouldSpin = computed( - () => - state === 'pending' && - iconClass.value === iconForJobState('pending') && - !iconImageUrl -) - -const computedShowClear = computed(() => { - if (showClear !== undefined) return showClear - return state !== 'completed' -}) - -const emitDetailsLeave = () => emit('details-leave', jobId) - -const onCancelClick = () => { - emitDetailsLeave() - emit('cancel') -} - -const onDeleteClick = () => { - emitDetailsLeave() - emit('delete') -} - -const onContextMenu = (event: MouseEvent) => { - const shouldShowMenu = showMenu !== undefined ? showMenu : true - if (shouldShowMenu) emit('menu', event) -} -</script> diff --git a/src/components/searchbox/NodeSearchBoxPopover.test.ts b/src/components/searchbox/NodeSearchBoxPopover.test.ts index 38077d3ba9..ec0f3fda0a 100644 --- a/src/components/searchbox/NodeSearchBoxPopover.test.ts +++ b/src/components/searchbox/NodeSearchBoxPopover.test.ts @@ -1,59 +1,34 @@ import { createTestingPinia } from '@pinia/testing' import { render, screen } from '@testing-library/vue' import PrimeVue from 'primevue/config' -import { describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import { computed, defineComponent, nextTick } from 'vue' import { createI18n } from 'vue-i18n' +import { CORE_SETTINGS } from '@/platform/settings/constants/coreSettings' +import type { Settings } from '@/schemas/apiSchema' import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore' import type { FuseFilter, FuseFilterWithValue } from '@/utils/fuseUtil' import NodeSearchBoxPopover from './NodeSearchBoxPopover.vue' -vi.mock('@/platform/settings/settingStore', () => ({ - useSettingStore: () => ({ - get: vi.fn() - }) +const coreSettingsById = Object.fromEntries(CORE_SETTINGS.map((s) => [s.id, s])) + +const { addNodeOnGraph } = vi.hoisted(() => ({ + addNodeOnGraph: vi.fn() })) vi.mock('@/services/litegraphService', () => ({ useLitegraphService: () => ({ getCanvasCenter: vi.fn(() => [0, 0]), - addNodeOnGraph: vi.fn() - }) -})) - -vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({ - useWorkflowStore: () => ({ - activeWorkflow: null - }) -})) - -vi.mock('@/renderer/core/canvas/canvasStore', () => ({ - useCanvasStore: () => ({ - canvas: null, - getCanvas: vi.fn(() => ({ - linkConnector: { - events: new EventTarget(), - renderLinks: [] - } - })) - }) -})) - -vi.mock('@/stores/nodeDefStore', () => ({ - useNodeDefStore: () => ({ - nodeSearchService: { - nodeFilters: [], - inputTypeFilter: {}, - outputTypeFilter: {} - } + addNodeOnGraph }) })) type EmitAddFilter = ( filter: FuseFilterWithValue<ComfyNodeDefImpl, string> ) => void +type EmitAddNode = (nodeDef: ComfyNodeDefImpl, dragEvent?: MouseEvent) => void function createFilter( id: string, @@ -72,26 +47,48 @@ describe('NodeSearchBoxPopover', () => { messages: { en: {} } }) - function renderComponent() { + function renderComponent(settings: Partial<Settings> = {}) { let emitAddFilter: EmitAddFilter | null = null + let emitAddNodeV1: EmitAddNode | null = null + let emitAddNodeV2: EmitAddNode | null = null const NodeSearchBoxStub = defineComponent({ name: 'NodeSearchBox', props: { filters: { type: Array, default: () => [] } }, - emits: ['addFilter'], + emits: ['addFilter', 'addNode'], setup(props, { emit }) { emitAddFilter = (filter) => emit('addFilter', filter) + emitAddNodeV1 = (nodeDef, dragEvent) => + emit('addNode', nodeDef, dragEvent) const filterCount = computed(() => props.filters.length) return { filterCount } }, template: '<output aria-label="filter count">{{ filterCount }}</output>' }) + const NodeSearchContentStub = defineComponent({ + name: 'NodeSearchContent', + props: { + filters: { type: Array, default: () => [] } + }, + emits: ['addFilter', 'removeFilter', 'addNode', 'hoverNode'], + setup(_, { emit }) { + emitAddNodeV2 = (nodeDef, dragEvent) => + emit('addNode', nodeDef, dragEvent) + return {} + }, + template: '<div data-testid="search-content-v2"></div>' + }) + const pinia = createTestingPinia({ stubActions: false, initialState: { + setting: { + settingValues: settings, + settingsById: coreSettingsById + }, searchBox: { visible: false } } }) @@ -101,6 +98,8 @@ describe('NodeSearchBoxPopover', () => { plugins: [i18n, PrimeVue, pinia], stubs: { NodeSearchBox: NodeSearchBoxStub, + NodeSearchContent: NodeSearchContentStub, + NodePreviewCard: true, Dialog: { template: '<div><slot name="container" /></div>', props: ['visible', 'modal', 'dismissableMask', 'pt'] @@ -109,14 +108,34 @@ describe('NodeSearchBoxPopover', () => { } }) - if (!emitAddFilter) throw new Error('NodeSearchBox stub did not mount') - - return { ...result, emitAddFilter: emitAddFilter as EmitAddFilter } + return { + ...result, + get emitAddFilter() { + if (!emitAddFilter) throw new Error('NodeSearchBox stub did not mount') + return emitAddFilter + }, + get emitAddNodeV1() { + if (!emitAddNodeV1) throw new Error('NodeSearchBox stub did not mount') + return emitAddNodeV1 + }, + get emitAddNodeV2() { + if (!emitAddNodeV2) + throw new Error('NodeSearchContent stub did not mount') + return emitAddNodeV2 + } + } } + beforeEach(() => { + addNodeOnGraph.mockReset() + addNodeOnGraph.mockReturnValue(null) + }) + describe('addFilter duplicate prevention', () => { it('should add a filter when no duplicates exist', async () => { - const { emitAddFilter } = renderComponent() + const { emitAddFilter } = renderComponent({ + 'Comfy.NodeSearchBoxImpl': 'v1 (legacy)' + }) emitAddFilter(createFilter('outputType', 'IMAGE')) await nextTick() @@ -125,7 +144,9 @@ describe('NodeSearchBoxPopover', () => { }) it('should not add a duplicate filter with same id and value', async () => { - const { emitAddFilter } = renderComponent() + const { emitAddFilter } = renderComponent({ + 'Comfy.NodeSearchBoxImpl': 'v1 (legacy)' + }) emitAddFilter(createFilter('outputType', 'IMAGE')) await nextTick() @@ -136,7 +157,9 @@ describe('NodeSearchBoxPopover', () => { }) it('should allow filters with same id but different values', async () => { - const { emitAddFilter } = renderComponent() + const { emitAddFilter } = renderComponent({ + 'Comfy.NodeSearchBoxImpl': 'v1 (legacy)' + }) emitAddFilter(createFilter('outputType', 'IMAGE')) await nextTick() @@ -147,7 +170,9 @@ describe('NodeSearchBoxPopover', () => { }) it('should allow filters with different ids but same value', async () => { - const { emitAddFilter } = renderComponent() + const { emitAddFilter } = renderComponent({ + 'Comfy.NodeSearchBoxImpl': 'v1 (legacy)' + }) emitAddFilter(createFilter('outputType', 'IMAGE')) await nextTick() @@ -157,4 +182,98 @@ describe('NodeSearchBoxPopover', () => { expect(screen.getByLabelText('filter count')).toHaveTextContent('2') }) }) + + describe('addNode ghost flag (FollowCursor setting)', () => { + const nodeDef = { name: 'KSampler' } as ComfyNodeDefImpl + + it('should default ghost to true when v2 search is active and FollowCursor is unset', async () => { + const { emitAddNodeV2 } = renderComponent({ + 'Comfy.NodeSearchBoxImpl': 'default' + }) + emitAddNodeV2(nodeDef) + await nextTick() + + expect(addNodeOnGraph).toHaveBeenCalledWith( + nodeDef, + expect.objectContaining({ pos: expect.any(Array) }), + expect.objectContaining({ ghost: true }) + ) + }) + + it('should pass ghost: true when v2 search is active and FollowCursor is enabled', async () => { + const { emitAddNodeV2 } = renderComponent({ + 'Comfy.NodeSearchBoxImpl': 'default', + 'Comfy.NodeSearchBoxImpl.FollowCursor': true + }) + emitAddNodeV2(nodeDef) + await nextTick() + + expect(addNodeOnGraph).toHaveBeenCalledWith( + nodeDef, + expect.objectContaining({ pos: expect.any(Array) }), + expect.objectContaining({ ghost: true }) + ) + }) + + it('should pass ghost: false when v2 search is active but FollowCursor is disabled', async () => { + const { emitAddNodeV2 } = renderComponent({ + 'Comfy.NodeSearchBoxImpl': 'default', + 'Comfy.NodeSearchBoxImpl.FollowCursor': false + }) + emitAddNodeV2(nodeDef) + await nextTick() + + expect(addNodeOnGraph).toHaveBeenCalledWith( + nodeDef, + expect.objectContaining({ pos: expect.any(Array) }), + expect.objectContaining({ ghost: false }) + ) + }) + + it('should pass ghost: false when v1 legacy search box is used', async () => { + const { emitAddNodeV1 } = renderComponent({ + 'Comfy.NodeSearchBoxImpl': 'v1 (legacy)', + 'Comfy.NodeSearchBoxImpl.FollowCursor': true + }) + emitAddNodeV1(nodeDef) + await nextTick() + + expect(addNodeOnGraph).toHaveBeenCalledWith( + nodeDef, + expect.objectContaining({ pos: expect.any(Array) }), + expect.objectContaining({ ghost: false }) + ) + }) + + it('should pass ghost: false when litegraph legacy search box is used', async () => { + const { emitAddNodeV1 } = renderComponent({ + 'Comfy.NodeSearchBoxImpl': 'litegraph (legacy)', + 'Comfy.NodeSearchBoxImpl.FollowCursor': true + }) + emitAddNodeV1(nodeDef) + await nextTick() + + expect(addNodeOnGraph).toHaveBeenCalledWith( + nodeDef, + expect.objectContaining({ pos: expect.any(Array) }), + expect.objectContaining({ ghost: false }) + ) + }) + + it('should forward the dragEvent through to addNodeOnGraph', async () => { + const dragEvent = new MouseEvent('mousedown') + const { emitAddNodeV2 } = renderComponent({ + 'Comfy.NodeSearchBoxImpl': 'default', + 'Comfy.NodeSearchBoxImpl.FollowCursor': true + }) + emitAddNodeV2(nodeDef, dragEvent) + await nextTick() + + expect(addNodeOnGraph).toHaveBeenCalledWith( + nodeDef, + expect.objectContaining({ pos: expect.any(Array) }), + expect.objectContaining({ ghost: true, dragEvent }) + ) + }) + }) }) diff --git a/src/components/searchbox/NodeSearchBoxPopover.vue b/src/components/searchbox/NodeSearchBoxPopover.vue index f0e8c08326..c0f5101376 100644 --- a/src/components/searchbox/NodeSearchBoxPopover.vue +++ b/src/components/searchbox/NodeSearchBoxPopover.vue @@ -129,10 +129,11 @@ function closeDialog() { const canvasStore = useCanvasStore() function addNode(nodeDef: ComfyNodeDefImpl, dragEvent?: MouseEvent) { + const followCursor = settingStore.get('Comfy.NodeSearchBoxImpl.FollowCursor') const node = litegraphService.addNodeOnGraph( nodeDef, { pos: getNewNodeLocation() }, - { ghost: useSearchBoxV2.value, dragEvent } + { ghost: useSearchBoxV2.value && followCursor, dragEvent } ) if (!node) return diff --git a/src/components/searchbox/v2/NodeSearchContent.test.ts b/src/components/searchbox/v2/NodeSearchContent.test.ts index 8607d66537..9005aed6e2 100644 --- a/src/components/searchbox/v2/NodeSearchContent.test.ts +++ b/src/components/searchbox/v2/NodeSearchContent.test.ts @@ -5,9 +5,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import NodeSearchContent from '@/components/searchbox/v2/NodeSearchContent.vue' import { createMockNodeDef, + setViewport, setupTestPinia, testI18n } from '@/components/searchbox/v2/__test__/testUtils' + import { useSettingStore } from '@/platform/settings/settingStore' import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore' import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore' @@ -15,10 +17,14 @@ import { useNodeDefStore, useNodeFrequencyStore } from '@/stores/nodeDefStore' import { NodeSourceType } from '@/types/nodeSource' import type { FuseFilterWithValue } from '@/utils/fuseUtil' +const DESKTOP_VIEWPORT = { width: 1280, height: 800 } +const MOBILE_VIEWPORT = { width: 360, height: 800 } + describe('NodeSearchContent', () => { beforeEach(() => { setupTestPinia() vi.restoreAllMocks() + setViewport(DESKTOP_VIEWPORT) const settings = useSettingStore() settings.settingValues['Comfy.NodeLibrary.Bookmarks.V2'] = [] settings.settingValues['Comfy.NodeLibrary.BookmarksCustomization'] = {} @@ -547,7 +553,7 @@ describe('NodeSearchContent', () => { }) describe('filter integration', () => { - it('should display active filters in the input area', () => { + it('renders one chip per active filter with the filter value', () => { useNodeDefStore().updateNodeDefs([ createMockNodeDef({ name: 'ImageNode', @@ -556,16 +562,20 @@ describe('NodeSearchContent', () => { }) ]) + const inputFilter = useNodeDefStore().nodeSearchService.inputTypeFilter renderComponent({ filters: [ - { - filterDef: useNodeDefStore().nodeSearchService.inputTypeFilter, - value: 'IMAGE' - } + { filterDef: inputFilter, value: 'IMAGE' }, + { filterDef: inputFilter, value: 'LATENT' } ] }) - expect(screen.getAllByTestId('filter-chip').length).toBeGreaterThan(0) + const chipTexts = screen + .getAllByTestId('filter-chip') + .map((c) => c.textContent ?? '') + expect(chipTexts).toHaveLength(2) + expect(chipTexts.some((t) => t.includes('IMAGE'))).toBe(true) + expect(chipTexts.some((t) => t.includes('LATENT'))).toBe(true) }) }) @@ -659,6 +669,95 @@ describe('NodeSearchContent', () => { }) }) + describe('sidebar toggle', () => { + it('should hide and show the category sidebar when the toggle is clicked', async () => { + useNodeDefStore().updateNodeDefs([ + createMockNodeDef({ + name: 'KSampler', + display_name: 'KSampler', + category: 'sampling' + }) + ]) + + const { user } = renderComponent() + + const sidebar = await screen.findByTestId('category-sampling') + expect(sidebar).toBeVisible() + + const toggle = screen.getByTestId('toggle-category-sidebar') + expect(toggle).toHaveAttribute('aria-expanded', 'true') + + await user.click(toggle) + await waitFor(() => { + expect(toggle).toHaveAttribute('aria-expanded', 'false') + expect(screen.getByTestId('category-sampling')).not.toBeVisible() + }) + + await user.click(toggle) + await waitFor(() => { + expect(toggle).toHaveAttribute('aria-expanded', 'true') + expect(screen.getByTestId('category-sampling')).toBeVisible() + }) + }) + + it('should close the sidebar when the search input gains focus on mobile', async () => { + setViewport(MOBILE_VIEWPORT) + useNodeDefStore().updateNodeDefs([ + createMockNodeDef({ + name: 'KSampler', + display_name: 'KSampler', + category: 'sampling' + }) + ]) + + const { user } = renderComponent() + + const toggle = screen.getByTestId('toggle-category-sidebar') + expect(toggle).toHaveAttribute('aria-expanded', 'false') + + await user.click(toggle) + expect(toggle).toHaveAttribute('aria-expanded', 'true') + + await user.click(screen.getByRole('combobox')) + + await waitFor(() => { + expect(toggle).toHaveAttribute('aria-expanded', 'false') + }) + }) + + it('should preserve user state across mobile/desktop resizes', async () => { + useNodeDefStore().updateNodeDefs([ + createMockNodeDef({ + name: 'KSampler', + display_name: 'KSampler', + category: 'sampling' + }) + ]) + + const { user } = renderComponent() + + const toggle = screen.getByTestId('toggle-category-sidebar') + const expectExpanded = (value: 'true' | 'false') => + waitFor(() => expect(toggle).toHaveAttribute('aria-expanded', value)) + + await expectExpanded('true') + + setViewport(MOBILE_VIEWPORT) + await expectExpanded('false') + + await user.click(toggle) + setViewport(DESKTOP_VIEWPORT) + await expectExpanded('true') + + await user.click(toggle) + setViewport(MOBILE_VIEWPORT) + await expectExpanded('false') + + setViewport(DESKTOP_VIEWPORT) + await expectExpanded('false') + }) + }) + describe('rootFilter + category + search combination', () => { it('should intersect rootFilter, selected category, and search query', async () => { useNodeDefStore().updateNodeDefs([ diff --git a/src/components/searchbox/v2/NodeSearchContent.vue b/src/components/searchbox/v2/NodeSearchContent.vue index c99fcd5d63..469369767f 100644 --- a/src/components/searchbox/v2/NodeSearchContent.vue +++ b/src/components/searchbox/v2/NodeSearchContent.vue @@ -13,11 +13,13 @@ @navigate-down="navigateResults(1)" @navigate-up="navigateResults(-1)" @select-current="selectCurrentResult" + @focusin="onSearchFocus" /> <!-- Filter header row --> <div class="flex items-center"> <NodeSearchFilterBar + v-model:is-sidebar-open="isSidebarOpen" class="flex-1" :filters="filters" :active-category="rootFilter" @@ -34,11 +36,13 @@ </div> <!-- Content area --> - <div class="flex min-h-0 flex-1 overflow-hidden"> - <!-- Category sidebar --> + <div class="relative flex min-h-0 flex-1 overflow-hidden"> <NodeSearchCategorySidebar + v-show="isSidebarOpen" + id="node-search-category-sidebar" v-model:selected-category="sidebarCategory" - class="w-52 shrink-0" + :aria-label="isMobile ? t('g.categories') : undefined" + class="w-52 shrink-0 max-md:absolute max-md:inset-y-0 max-md:left-0 max-md:z-20 max-md:bg-base-background max-md:shadow-interface" :hide-chevrons="!anyTreeCategoryHasChildren" :hide-presets="rootFilter !== null" :node-defs="rootFilteredNodeDefs" @@ -47,6 +51,14 @@ @auto-expand="selectedCategory = $event" /> + <!-- Mobile overlay backdrop to close sidebar on outside click --> + <div + v-if="isMobile && isSidebarOpen" + data-testid="sidebar-backdrop" + class="absolute inset-0 z-10 md:hidden" + @click="isSidebarOpen = false" + /> + <!-- Results list --> <div id="results-list" @@ -78,12 +90,13 @@ :node-def="node" :current-query="searchQuery" show-description - :show-source-badge="rootFilter !== 'essentials'" - :hide-bookmark-icon="selectedCategory === 'favorites'" + :show-source-badge="rootFilter !== RootCategory.Essentials" + :hide-bookmark-icon="selectedCategory === RootCategory.Favorites" /> </div> <div v-if="displayedResults.length === 0" + data-testid="no-results" class="px-4 py-8 text-center text-muted-foreground" > {{ $t('g.noResults') }} @@ -95,6 +108,7 @@ </template> <script setup lang="ts"> +import { breakpointsTailwind, useBreakpoints } from '@vueuse/core' import { FocusScope } from 'reka-ui' import { computed, nextTick, ref, watch } from 'vue' import { useI18n } from 'vue-i18n' @@ -105,6 +119,8 @@ import NodeSearchCategorySidebar, { } from '@/components/searchbox/v2/NodeSearchCategorySidebar.vue' import NodeSearchInput from '@/components/searchbox/v2/NodeSearchInput.vue' import NodeSearchListItem from '@/components/searchbox/v2/NodeSearchListItem.vue' +import { RootCategory } from '@/components/searchbox/v2/rootCategories' +import type { RootCategoryId } from '@/components/searchbox/v2/rootCategories' import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore' import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore' import { useNodeDefStore, useNodeFrequencyStore } from '@/stores/nodeDefStore' @@ -120,9 +136,9 @@ import { cn } from '@comfyorg/tailwind-utils' const sourceCategoryFilters: Record<string, (n: ComfyNodeDefImpl) => boolean> = { - essentials: isEssentialNode, - comfy: (n) => n.nodeSource.type === NodeSourceType.Core, - custom: isCustomNode + [RootCategory.Essentials]: isEssentialNode, + [RootCategory.Comfy]: (n) => n.nodeSource.type === NodeSourceType.Core, + [RootCategory.Custom]: isCustomNode } const { filters } = defineProps<{ @@ -166,22 +182,33 @@ const searchQuery = ref('') const selectedCategory = ref(DEFAULT_CATEGORY) const selectedIndex = ref(0) +const isMobile = useBreakpoints(breakpointsTailwind).smaller('md') +const isSidebarOpen = ref(!isMobile.value) +watch(isMobile, (mobile) => { + // On transitioning to mobile state, close the sidebar + if (mobile) isSidebarOpen.value = false +}) + +function onSearchFocus() { + if (isMobile.value) isSidebarOpen.value = false +} + // Root filter from filter bar category buttons (radio toggle) -const rootFilter = ref<string | null>(null) +const rootFilter = ref<RootCategoryId | null>(null) const rootFilterLabel = computed(() => { switch (rootFilter.value) { - case 'favorites': + case RootCategory.Favorites: return t('g.bookmarked') - case BLUEPRINT_CATEGORY: + case RootCategory.Blueprint: return t('g.blueprints') - case 'partner-nodes': + case RootCategory.PartnerNodes: return t('g.partner') - case 'essentials': + case RootCategory.Essentials: return t('g.essentials') - case 'comfy': + case RootCategory.Comfy: return t('g.comfy') - case 'custom': + case RootCategory.Custom: return t('g.extensions') default: return undefined @@ -194,11 +221,11 @@ const rootFilteredNodeDefs = computed(() => { const sourceFilter = sourceCategoryFilters[rootFilter.value] if (sourceFilter) return allNodes.filter(sourceFilter) switch (rootFilter.value) { - case 'favorites': + case RootCategory.Favorites: return allNodes.filter((n) => nodeBookmarkStore.isBookmarked(n)) - case BLUEPRINT_CATEGORY: - return allNodes.filter((n) => n.category.startsWith(rootFilter.value!)) - case 'partner-nodes': + case RootCategory.Blueprint: + return allNodes.filter((n) => n.category.startsWith(BLUEPRINT_CATEGORY)) + case RootCategory.PartnerNodes: return allNodes.filter((n) => n.api_node) default: return allNodes @@ -225,7 +252,7 @@ function onClearFilterGroup(filterId: string) { } } -function onSelectCategory(category: string) { +function onSelectCategory(category: RootCategoryId) { if (rootFilter.value === category) { rootFilter.value = null } else { diff --git a/src/components/searchbox/v2/NodeSearchFilterBar.test.ts b/src/components/searchbox/v2/NodeSearchFilterBar.test.ts index c13f4afd59..70f0b282a3 100644 --- a/src/components/searchbox/v2/NodeSearchFilterBar.test.ts +++ b/src/components/searchbox/v2/NodeSearchFilterBar.test.ts @@ -1,4 +1,4 @@ -import { render, screen } from '@testing-library/vue' +import { cleanup, render, screen } from '@testing-library/vue' import userEvent from '@testing-library/user-event' import { beforeEach, describe, expect, it, vi } from 'vitest' import { nextTick } from 'vue' @@ -9,23 +9,16 @@ import { setupTestPinia, testI18n } from '@/components/searchbox/v2/__test__/testUtils' +import { useSettingStore } from '@/platform/settings/settingStore' import { useNodeDefStore } from '@/stores/nodeDefStore' -vi.mock('@/platform/settings/settingStore', () => ({ - useSettingStore: vi.fn(() => ({ - get: vi.fn((key: string) => { - if (key === 'Comfy.NodeLibrary.Bookmarks.V2') return [] - if (key === 'Comfy.NodeLibrary.BookmarksCustomization') return {} - return undefined - }), - set: vi.fn() - })) -})) - describe(NodeSearchFilterBar, () => { beforeEach(() => { vi.restoreAllMocks() setupTestPinia() + const settings = useSettingStore() + settings.settingValues['Comfy.NodeLibrary.Bookmarks.V2'] = [] + settings.settingValues['Comfy.NodeLibrary.BookmarksCustomization'] = {} useNodeDefStore().updateNodeDefs([ createMockNodeDef({ name: 'ImageNode', @@ -38,8 +31,13 @@ describe(NodeSearchFilterBar, () => { async function createRender(props = {}) { const user = userEvent.setup() const onSelectCategory = vi.fn() + const onUpdateIsSidebarOpen = vi.fn() render(NodeSearchFilterBar, { - props: { onSelectCategory, ...props }, + props: { + onSelectCategory, + 'onUpdate:isSidebarOpen': onUpdateIsSidebarOpen, + ...props + }, global: { plugins: [testI18n], stubs: { @@ -51,51 +49,38 @@ describe(NodeSearchFilterBar, () => { } }) await nextTick() - return { user, onSelectCategory } + return { user, onSelectCategory, onUpdateIsSidebarOpen } } - it('should render Extensions button and Input/Output popover triggers', async () => { - await createRender({ hasCustomNodes: true }) + const buttonTexts = () => + screen.getAllByRole('button').map((b) => b.textContent?.trim()) - const buttons = screen.getAllByRole('button') - const texts = buttons.map((b) => b.textContent?.trim()) - expect(texts).toContain('Extensions') + it.each([ + { prop: 'hasFavorites', label: 'Bookmarked' }, + { prop: 'hasBlueprintNodes', label: 'Blueprints' }, + { prop: 'hasEssentialNodes', label: 'Essentials' }, + { prop: 'hasPartnerNodes', label: 'Partner' }, + { prop: 'hasCustomNodes', label: 'Extensions' } + ] as const)( + 'shows the $label button only when $prop is true', + async ({ prop, label }) => { + await createRender() + expect(buttonTexts()).not.toContain(label) + + cleanup() + await createRender({ [prop]: true }) + expect(buttonTexts()).toContain(label) + } + ) + + it('always renders the Comfy button and Input/Output type filter triggers', async () => { + await createRender() + const texts = buttonTexts() + expect(texts).toContain('Comfy') expect(texts).toContain('Input') expect(texts).toContain('Output') }) - it('should always render Comfy button', async () => { - await createRender() - const texts = screen - .getAllByRole('button') - .map((b) => b.textContent?.trim()) - expect(texts).toContain('Comfy') - }) - - it('should render conditional category buttons when matching nodes exist', async () => { - await createRender({ - hasFavorites: true, - hasEssentialNodes: true, - hasBlueprintNodes: true, - hasPartnerNodes: true - }) - const texts = screen - .getAllByRole('button') - .map((b) => b.textContent?.trim()) - expect(texts).toContain('Bookmarked') - expect(texts).toContain('Blueprints') - expect(texts).toContain('Partner') - expect(texts).toContain('Essentials') - }) - - it('should not render Extensions button when no custom nodes exist', async () => { - await createRender() - const texts = screen - .getAllByRole('button') - .map((b) => b.textContent?.trim()) - expect(texts).not.toContain('Extensions') - }) - it('should emit selectCategory when category button is clicked', async () => { const { user, onSelectCategory } = await createRender({ hasCustomNodes: true @@ -114,4 +99,24 @@ describe(NodeSearchFilterBar, () => { 'true' ) }) + + it('should expose aria-expanded=false and emit update:isSidebarOpen=true when toggled from collapsed', async () => { + const { user, onUpdateIsSidebarOpen } = await createRender({ + isSidebarOpen: false + }) + const toggle = screen.getByTestId('toggle-category-sidebar') + + expect(toggle).toHaveAttribute('aria-expanded', 'false') + + await user.click(toggle) + expect(onUpdateIsSidebarOpen).toHaveBeenCalledExactlyOnceWith(true) + }) + + it('should expose aria-expanded=true when isSidebarOpen prop is true', async () => { + await createRender({ isSidebarOpen: true }) + expect(screen.getByTestId('toggle-category-sidebar')).toHaveAttribute( + 'aria-expanded', + 'true' + ) + }) }) diff --git a/src/components/searchbox/v2/NodeSearchFilterBar.vue b/src/components/searchbox/v2/NodeSearchFilterBar.vue index 5893fd1cad..eb0c11b795 100644 --- a/src/components/searchbox/v2/NodeSearchFilterBar.vue +++ b/src/components/searchbox/v2/NodeSearchFilterBar.vue @@ -1,43 +1,67 @@ <template> - <div class="flex items-center gap-2.5 px-3"> - <!-- Category filter buttons --> + <div class="flex min-w-0 items-center gap-2.5 pl-3"> <button - v-for="btn in categoryButtons" - :key="btn.id" type="button" - :aria-pressed="activeCategory === btn.id" - :class="chipClass(activeCategory === btn.id)" - @click="emit('selectCategory', btn.id)" + data-testid="toggle-category-sidebar" + aria-controls="node-search-category-sidebar" + :aria-expanded="isSidebarOpen" + :aria-label="isSidebarOpen ? t('g.hideLeftPanel') : t('g.showLeftPanel')" + :class="chipClass(isSidebarOpen)" + @click="isSidebarOpen = !isSidebarOpen" > - {{ btn.label }} + <i class="icon-[lucide--panel-left] size-4" /> </button> <div class="h-5 w-px shrink-0 bg-border-subtle" /> - <!-- Type filter popovers (Input / Output) --> - <NodeSearchTypeFilterPopover - v-for="tf in typeFilters" - :key="tf.chip.key" - :chip="tf.chip" - :selected-values="tf.values" - @toggle="(v) => emit('toggleFilter', tf.chip.filter, v)" - @clear="emit('clearFilterGroup', tf.chip.filter.id)" - @escape-close="emit('focusSearch')" + <div + data-testid="filter-chips-scroll" + class="flex min-w-0 flex-1 items-center gap-2.5 overflow-x-auto pr-3" > - <button type="button" :class="chipClass(false, tf.values.length > 0)"> - <span v-if="tf.values.length > 0" class="flex items-center"> - <span - v-for="val in tf.values.slice(0, MAX_VISIBLE_DOTS)" - :key="val" - class="-mx-[2px] text-lg leading-none" - :style="{ color: getLinkTypeColor(val) }" - >•</span - > - </span> - {{ tf.chip.label }} - <i class="icon-[lucide--chevron-down] size-3.5" /> + <!-- Category filter buttons --> + <button + v-for="btn in categoryButtons" + :key="btn.id" + type="button" + :data-testid="`search-category-${btn.id}`" + :aria-pressed="activeCategory === btn.id" + :class="chipClass(activeCategory === btn.id)" + @click="emit('selectCategory', btn.id)" + > + {{ btn.label }} </button> - </NodeSearchTypeFilterPopover> + + <div class="h-5 w-px shrink-0 bg-border-subtle" /> + + <!-- Type filter popovers (Input / Output) --> + <NodeSearchTypeFilterPopover + v-for="tf in typeFilters" + :key="tf.chip.key" + :chip="tf.chip" + :selected-values="tf.values" + @toggle="(v) => emit('toggleFilter', tf.chip.filter, v)" + @clear="emit('clearFilterGroup', tf.chip.filter.id)" + @escape-close="emit('focusSearch')" + > + <button + type="button" + :data-testid="`search-filter-${tf.chip.key}`" + :class="chipClass(false, tf.values.length > 0)" + > + <span v-if="tf.values.length > 0" class="flex items-center"> + <span + v-for="val in tf.values.slice(0, MAX_VISIBLE_DOTS)" + :key="val" + class="-mx-[2px] text-lg leading-none" + :style="{ color: getLinkTypeColor(val) }" + >•</span + > + </span> + {{ tf.chip.label }} + <i class="icon-[lucide--chevron-down] size-3.5" /> + </button> + </NodeSearchTypeFilterPopover> + </div> </div> </template> @@ -57,8 +81,9 @@ import { computed } from 'vue' import { useI18n } from 'vue-i18n' import NodeSearchTypeFilterPopover from '@/components/searchbox/v2/NodeSearchTypeFilterPopover.vue' +import { RootCategory } from '@/components/searchbox/v2/rootCategories' +import type { RootCategoryId } from '@/components/searchbox/v2/rootCategories' import { useNodeDefStore } from '@/stores/nodeDefStore' -import { BLUEPRINT_CATEGORY } from '@/types/nodeSource' import type { FuseFilterWithValue } from '@/utils/fuseUtil' import { getLinkTypeColor } from '@/utils/litegraphUtil' import { cn } from '@comfyorg/tailwind-utils' @@ -81,11 +106,13 @@ const { hasCustomNodes?: boolean }>() +const isSidebarOpen = defineModel<boolean>('isSidebarOpen', { default: true }) + const emit = defineEmits<{ toggleFilter: [filterDef: FuseFilter<ComfyNodeDefImpl, string>, value: string] clearFilterGroup: [filterId: string] focusSearch: [] - selectCategory: [category: string] + selectCategory: [category: RootCategoryId] }>() const { t } = useI18n() @@ -94,22 +121,22 @@ const nodeDefStore = useNodeDefStore() const MAX_VISIBLE_DOTS = 4 const categoryButtons = computed(() => { - const buttons: { id: string; label: string }[] = [] + const buttons: { id: RootCategoryId; label: string }[] = [] if (hasFavorites) { - buttons.push({ id: 'favorites', label: t('g.bookmarked') }) + buttons.push({ id: RootCategory.Favorites, label: t('g.bookmarked') }) } if (hasBlueprintNodes) { - buttons.push({ id: BLUEPRINT_CATEGORY, label: t('g.blueprints') }) + buttons.push({ id: RootCategory.Blueprint, label: t('g.blueprints') }) + } + buttons.push({ id: RootCategory.Comfy, label: t('g.comfy') }) + if (hasEssentialNodes) { + buttons.push({ id: RootCategory.Essentials, label: t('g.essentials') }) } if (hasPartnerNodes) { - buttons.push({ id: 'partner-nodes', label: t('g.partner') }) + buttons.push({ id: RootCategory.PartnerNodes, label: t('g.partner') }) } - if (hasEssentialNodes) { - buttons.push({ id: 'essentials', label: t('g.essentials') }) - } - buttons.push({ id: 'comfy', label: t('g.comfy') }) if (hasCustomNodes) { - buttons.push({ id: 'custom', label: t('g.extensions') }) + buttons.push({ id: RootCategory.Custom, label: t('g.extensions') }) } return buttons }) @@ -141,7 +168,7 @@ const typeFilters = computed(() => [ function chipClass(isActive: boolean, hasSelections = false) { return cn( - 'flex cursor-pointer items-center justify-center gap-1 rounded-md border border-secondary-background px-3 py-1 font-inter text-sm transition-colors', + 'flex shrink-0 cursor-pointer items-center justify-center gap-1 rounded-md border border-secondary-background px-3 py-1 font-inter text-sm transition-colors', isActive ? 'border-base-foreground bg-base-foreground text-base-background' : hasSelections diff --git a/src/components/searchbox/v2/NodeSearchListItem.test.ts b/src/components/searchbox/v2/NodeSearchListItem.test.ts index 552912984c..bde077e75c 100644 --- a/src/components/searchbox/v2/NodeSearchListItem.test.ts +++ b/src/components/searchbox/v2/NodeSearchListItem.test.ts @@ -57,6 +57,19 @@ describe('NodeSearchListItem', () => { }) expect(screen.queryByText('KSamplerNode')).not.toBeInTheDocument() }) + + it('hides id name for subgraph blueprints even when ShowIdName is enabled', () => { + useSettingStore().settingValues['Comfy.NodeSearchBoxImpl.ShowIdName'] = + true + renderItem({ + nodeDef: createMockNodeDef({ + name: 'SubgraphBlueprint.e21be61fc452df75e1324e3cc97c41fb0c01a08a5dad4dcd3a2ac118d8907025', + display_name: 'My Blueprint', + python_module: 'blueprint' + }) + }) + expect(screen.queryByTestId('node-id-badge')).not.toBeInTheDocument() + }) }) describe('showDescription mode', () => { diff --git a/src/components/searchbox/v2/NodeSearchListItem.vue b/src/components/searchbox/v2/NodeSearchListItem.vue index 7177b7eda7..ba99ecda04 100644 --- a/src/components/searchbox/v2/NodeSearchListItem.vue +++ b/src/components/searchbox/v2/NodeSearchListItem.vue @@ -18,6 +18,7 @@ /> <span v-if="showIdName" + data-testid="node-id-badge" class="shrink-0 rounded-sm bg-secondary-background px-1.5 py-0.5 text-xs text-muted-foreground" v-html="highlightQuery(nodeDef.name, currentQuery)" /> @@ -154,8 +155,10 @@ const settingStore = useSettingStore() const showCategory = computed(() => settingStore.get('Comfy.NodeSearchBoxImpl.ShowCategory') ) -const showIdName = computed(() => - settingStore.get('Comfy.NodeSearchBoxImpl.ShowIdName') +const showIdName = computed( + () => + settingStore.get('Comfy.NodeSearchBoxImpl.ShowIdName') && + nodeDef.nodeSource.type !== NodeSourceType.Blueprint ) const showNodeFrequency = computed(() => settingStore.get('Comfy.NodeSearchBoxImpl.ShowNodeFrequency') diff --git a/src/components/searchbox/v2/__test__/testUtils.ts b/src/components/searchbox/v2/__test__/testUtils.ts index a55a71c02f..1a4cdab073 100644 --- a/src/components/searchbox/v2/__test__/testUtils.ts +++ b/src/components/searchbox/v2/__test__/testUtils.ts @@ -1,4 +1,5 @@ import { createTestingPinia } from '@pinia/testing' +import type { DetachedWindowAPI } from 'happy-dom' import { setActivePinia } from 'pinia' import { createI18n } from 'vue-i18n' @@ -35,3 +36,12 @@ export const testI18n = createI18n({ locale: 'en', messages: { en: enMessages } }) + +export function setViewport(viewport: { width: number; height: number }) { + const happyDOM = (window as unknown as { happyDOM?: DetachedWindowAPI }) + .happyDOM + if (!happyDOM) { + throw new Error('window.happyDOM is unavailable to set viewport') + } + happyDOM.setViewport(viewport) +} diff --git a/src/components/searchbox/v2/rootCategories.ts b/src/components/searchbox/v2/rootCategories.ts new file mode 100644 index 0000000000..13a828eb97 --- /dev/null +++ b/src/components/searchbox/v2/rootCategories.ts @@ -0,0 +1,12 @@ +import { BLUEPRINT_CATEGORY } from '@/types/nodeSource' + +export const RootCategory = { + Favorites: 'favorites', + Comfy: 'comfy', + Custom: 'custom', + Essentials: 'essentials', + PartnerNodes: 'partner-nodes', + Blueprint: BLUEPRINT_CATEGORY +} as const + +export type RootCategoryId = (typeof RootCategory)[keyof typeof RootCategory] diff --git a/src/components/sidebar/tabs/AssetsSidebarTab.vue b/src/components/sidebar/tabs/AssetsSidebarTab.vue index 32adfa2d89..ce661eef00 100644 --- a/src/components/sidebar/tabs/AssetsSidebarTab.vue +++ b/src/components/sidebar/tabs/AssetsSidebarTab.vue @@ -327,7 +327,7 @@ const { } = useAssetSelection() const { - downloadMultipleAssets, + downloadAssets, deleteAssets, addMultipleToWorkflow, openMultipleWorkflows, @@ -533,7 +533,7 @@ function handleContextMenuHide() { } const handleBulkDownload = (assets: AssetItem[]) => { - downloadMultipleAssets(assets) + downloadAssets(assets) clearSelection() } @@ -559,7 +559,7 @@ const handleBulkExportWorkflow = async (assets: AssetItem[]) => { } const handleDownloadSelected = () => { - downloadMultipleAssets(selectedAssets.value) + downloadAssets(selectedAssets.value) clearSelection() } diff --git a/src/components/sidebar/tabs/JobHistorySidebarTab.vue b/src/components/sidebar/tabs/JobHistorySidebarTab.vue index ae323e9dfd..b36b9ed428 100644 --- a/src/components/sidebar/tabs/JobHistorySidebarTab.vue +++ b/src/components/sidebar/tabs/JobHistorySidebarTab.vue @@ -2,15 +2,16 @@ <SidebarTabTemplate :title="$t('queue.jobHistory')"> <template #alt-title> <div class="ml-auto flex shrink-0 items-center"> - <JobHistoryActionsMenu @clear-history="showQueueClearHistoryDialog" /> + <JobHistoryActionsMenu @clear-history="onClearHistory" /> </div> </template> <template #header> <div class="flex flex-col gap-2 pb-1"> <div class="px-3 py-2"> <JobFilterTabs - v-model:selected-job-tab="selectedJobTab" + :selected-job-tab="selectedJobTab" :has-failed-jobs="hasFailedJobs" + @update:selected-job-tab="onUpdateSelectedJobTab" /> </div> <JobFilterActions @@ -81,13 +82,14 @@ import JobHistoryActionsMenu from '@/components/queue/JobHistoryActionsMenu.vue' import type { MenuEntry } from '@/composables/queue/useJobMenu' import { useJobMenu } from '@/composables/queue/useJobMenu' import { useJobList } from '@/composables/queue/useJobList' -import type { JobListItem } from '@/composables/queue/useJobList' +import type { JobListItem, JobTab } from '@/composables/queue/useJobList' import { useQueueClearHistoryDialog } from '@/composables/queue/useQueueClearHistoryDialog' import { useResultGallery } from '@/composables/queue/useResultGallery' import { useErrorHandling } from '@/composables/useErrorHandling' import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue' import MediaLightbox from '@/components/sidebar/tabs/queue/MediaLightbox.vue' import Button from '@/components/ui/button/Button.vue' +import { useSurveyFeatureTracking } from '@/platform/surveys/useSurveyFeatureTracking' import { useCommandStore } from '@/stores/commandStore' import { useDialogStore } from '@/stores/dialogStore' import { useExecutionStore } from '@/stores/executionStore' @@ -104,6 +106,17 @@ const executionStore = useExecutionStore() const queueStore = useQueueStore() const { showQueueClearHistoryDialog } = useQueueClearHistoryDialog() const { wrapWithErrorHandlingAsync } = useErrorHandling() +const { trackFeatureUsed } = useSurveyFeatureTracking('queue-progress-overlay') + +const onClearHistory = () => { + trackFeatureUsed() + showQueueClearHistoryDialog() +} + +const onUpdateSelectedJobTab = (value: JobTab) => { + trackFeatureUsed() + selectedJobTab.value = value +} const { selectedJobTab, selectedWorkflowFilter, @@ -145,6 +158,7 @@ const activeQueueSummary = computed(() => { }) const clearQueuedWorkflows = wrapWithErrorHandlingAsync(async () => { + trackFeatureUsed() const pendingJobIds = queueStore.pendingTasks .map((task) => task.jobId) .filter((id): id is string => typeof id === 'string' && id.length > 0) @@ -160,6 +174,7 @@ const { } = useResultGallery(() => filteredTasks.value) const onViewItem = wrapWithErrorHandlingAsync(async (item: JobListItem) => { + trackFeatureUsed() const previewOutput = item.taskRef?.previewOutput if (previewOutput?.is3D) { @@ -194,10 +209,12 @@ const { jobMenuEntries, cancelJob } = useJobMenu( ) const onCancelItem = wrapWithErrorHandlingAsync(async (item: JobListItem) => { + trackFeatureUsed() await cancelJob(item) }) const onDeleteItem = wrapWithErrorHandlingAsync(async (item: JobListItem) => { + trackFeatureUsed() if (!item.taskRef) return await queueStore.delete(item.taskRef) }) diff --git a/src/components/toast/ProgressToastItem.test.ts b/src/components/toast/ProgressToastItem.test.ts new file mode 100644 index 0000000000..14e5b5d867 --- /dev/null +++ b/src/components/toast/ProgressToastItem.test.ts @@ -0,0 +1,51 @@ +import { render, screen } from '@testing-library/vue' +import { describe, expect, it } from 'vitest' +import { createI18n } from 'vue-i18n' + +import type { AssetDownload } from '@/stores/assetDownloadStore' + +import ProgressToastItem from './ProgressToastItem.vue' + +const i18n = createI18n({ + legacy: false, + locale: 'en', + messages: { + en: { + progressToast: { + finished: 'Finished', + failed: 'Failed', + pending: 'Pending' + } + } + } +}) + +function completedJob(): AssetDownload { + return { + taskId: 'task-1', + assetId: 'asset-1', + assetName: 'controlnet-canny.safetensors', + bytesTotal: 100, + bytesDownloaded: 100, + progress: 1, + status: 'completed', + lastUpdate: Date.now() + } +} + +describe('ProgressToastItem — completed state', () => { + it('keeps the finished badge outside the dimmed (opacity-50) subtree', () => { + render(ProgressToastItem, { + props: { job: completedJob() }, + global: { plugins: [i18n] } + }) + + const badge = screen.getByText('Finished') + // eslint-disable-next-line testing-library/no-node-access -- verifying structural placement of opacity-50 boundary, which is the subject of this fix + expect(badge.closest('.opacity-50')).toBeNull() + + const assetName = screen.getByText('controlnet-canny.safetensors') + // eslint-disable-next-line testing-library/no-node-access -- verifying structural placement of opacity-50 boundary, which is the subject of this fix + expect(assetName.closest('.opacity-50')).not.toBeNull() + }) +}) diff --git a/src/components/toast/ProgressToastItem.vue b/src/components/toast/ProgressToastItem.vue index f1434ef7c8..92a5cef08b 100644 --- a/src/components/toast/ProgressToastItem.vue +++ b/src/components/toast/ProgressToastItem.vue @@ -22,14 +22,9 @@ const isPending = computed(() => job.status === 'created') <template> <div - :class=" - cn( - 'flex items-center justify-between rounded-lg bg-modal-card-background px-4 py-3', - isCompleted && 'opacity-50' - ) - " + class="flex items-center justify-between rounded-lg bg-modal-card-background px-4 py-3" > - <div class="min-w-0 flex-1"> + <div :class="cn('min-w-0 flex-1', isCompleted && 'opacity-50')"> <span class="block truncate text-sm text-base-foreground">{{ job.assetName }}</span> diff --git a/src/components/topbar/ActionBarButtons.vue b/src/components/topbar/ActionBarButtons.vue index e87b1d62f5..15934d5182 100644 --- a/src/components/topbar/ActionBarButtons.vue +++ b/src/components/topbar/ActionBarButtons.vue @@ -1,5 +1,8 @@ <template> - <div class="flex h-full shrink-0 items-center gap-1 empty:hidden"> + <div + data-testid="action-bar-buttons" + class="flex h-full shrink-0 items-center gap-1 empty:hidden" + > <Button v-for="(button, index) in actionBarButtonStore.buttons" :key="index" diff --git a/src/components/topbar/LoginButton.vue b/src/components/topbar/LoginButton.vue index cadf9a9d87..a2afc46fe0 100644 --- a/src/components/topbar/LoginButton.vue +++ b/src/components/topbar/LoginButton.vue @@ -1,6 +1,7 @@ <template> <Button v-if="!isLoggedIn" + data-testid="login-button" variant="textonly" size="icon" :class="cn('group rounded-full p-0 text-base-foreground', className)" @@ -21,9 +22,10 @@ @mouseout="hidePopover" @mouseover="cancelHidePopover" > - <div> + <div data-testid="login-button-popover"> <div class="mb-1">{{ t('auth.loginButton.tooltipHelp') }}</div> <a + data-testid="login-button-popover-learn-more" :href="apiNodesOverviewUrl" target="_blank" class="text-neutral-500 hover:text-primary" diff --git a/src/components/topbar/TopbarBadge.vue b/src/components/topbar/TopbarBadge.vue index a638c16a51..74b334f43a 100644 --- a/src/components/topbar/TopbarBadge.vue +++ b/src/components/topbar/TopbarBadge.vue @@ -209,7 +209,7 @@ const dotClasses = computed(() => { return 'bg-gold-600' case 'info': default: - return 'bg-slate-100' + return 'bg-text-secondary' } }) diff --git a/src/components/ui/input/Input.vue b/src/components/ui/input/Input.vue index 792532cb5a..f2e2daf49f 100644 --- a/src/components/ui/input/Input.vue +++ b/src/components/ui/input/Input.vue @@ -14,7 +14,12 @@ const inputRef = useTemplateRef<HTMLInputElement>('inputEl') defineExpose({ focus: () => inputRef.value?.focus(), - select: () => inputRef.value?.select() + select: () => inputRef.value?.select(), + blur: () => inputRef.value?.blur(), + setSelectionRange: (start: number, end: number) => + inputRef.value?.setSelectionRange(start, end), + selectAll: () => + inputRef.value?.setSelectionRange(0, inputRef.value.value.length) }) </script> diff --git a/src/components/ui/search-input/SearchAutocomplete.test.ts b/src/components/ui/search-input/SearchAutocomplete.test.ts new file mode 100644 index 0000000000..2005d3c58e --- /dev/null +++ b/src/components/ui/search-input/SearchAutocomplete.test.ts @@ -0,0 +1,89 @@ +import { render, screen } from '@testing-library/vue' +import userEvent from '@testing-library/user-event' +import { describe, expect, it, vi } from 'vitest' +import { createI18n } from 'vue-i18n' + +import SearchAutocomplete from './SearchAutocomplete.vue' + +const i18n = createI18n({ + legacy: false, + locale: 'en', + messages: { en: { g: { searchPlaceholder: 'Search...' } } } +}) + +describe('SearchAutocomplete', () => { + function renderComponent(props: Record<string, unknown> = {}) { + return render(SearchAutocomplete, { + global: { + plugins: [i18n], + stubs: { + ComboboxRoot: { template: '<div><slot /></div>' }, + ComboboxAnchor: { template: '<div><slot /></div>' }, + ComboboxInput: { + template: + '<input :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)" />', + props: ['modelValue'], + emits: ['update:modelValue'] + }, + ComboboxPortal: { template: '<div><slot /></div>' }, + ComboboxContent: { template: '<div><slot /></div>' }, + ComboboxItem: { + template: + '<button type="button" @click="$emit(\'select\', { preventDefault: () => {} })"><slot /></button>', + emits: ['select'] + } + } + }, + props: { modelValue: '', ...props } + }) + } + + describe('suggestions dropdown', () => { + it('does not render items when suggestions list is empty', () => { + renderComponent({ suggestions: [] }) + expect(screen.queryByRole('button')).not.toBeInTheDocument() + }) + + it('renders a button for each suggestion', () => { + renderComponent({ suggestions: ['foo', 'bar'] }) + expect(screen.getByText('foo')).toBeInTheDocument() + expect(screen.getByText('bar')).toBeInTheDocument() + }) + + it('emits select with the suggestion when an item is clicked', async () => { + const onSelect = vi.fn() + const user = userEvent.setup() + renderComponent({ suggestions: ['foo', 'bar'], onSelect }) + await user.click(screen.getByText('foo')) + expect(onSelect).toHaveBeenCalledWith('foo') + }) + + it('updates modelValue to the suggestion label on selection', async () => { + const onUpdateModelValue = vi.fn() + const user = userEvent.setup() + renderComponent({ + suggestions: ['foo', 'bar'], + 'onUpdate:modelValue': onUpdateModelValue + }) + await user.click(screen.getByText('foo')) + expect(onUpdateModelValue).toHaveBeenCalledWith('foo') + }) + }) + + describe('with optionLabel', () => { + it('displays the optionLabel property as the suggestion text', () => { + const suggestions = [{ id: 1, query: 'my-extension' }] + renderComponent({ suggestions, optionLabel: 'query' }) + expect(screen.getByText('my-extension')).toBeInTheDocument() + }) + + it('emits the full item object on selection when optionLabel is set', async () => { + const onSelect = vi.fn() + const user = userEvent.setup() + const suggestions = [{ id: 1, query: 'my-extension' }] + renderComponent({ suggestions, optionLabel: 'query', onSelect }) + await user.click(screen.getByText('my-extension')) + expect(onSelect).toHaveBeenCalledWith({ id: 1, query: 'my-extension' }) + }) + }) +}) diff --git a/src/components/ui/search-input/SearchAutocomplete.vue b/src/components/ui/search-input/SearchAutocomplete.vue index 95ef95cef9..56832e3305 100644 --- a/src/components/ui/search-input/SearchAutocomplete.vue +++ b/src/components/ui/search-input/SearchAutocomplete.vue @@ -65,34 +65,36 @@ /> </ComboboxAnchor> - <ComboboxContent - v-if="suggestions.length > 0" - position="popper" - :side-offset="4" - :class=" - cn( - 'z-50 max-h-60 w-(--reka-combobox-trigger-width) overflow-y-auto', - 'rounded-lg border border-border-default bg-base-background p-1 shadow-lg' - ) - " - > - <ComboboxItem - v-for="(suggestion, index) in suggestions" - :key="suggestionKey(suggestion, index)" - :value="suggestionValue(suggestion)" + <ComboboxPortal> + <ComboboxContent + v-if="suggestions.length > 0" + position="popper" + :side-offset="4" :class=" cn( - 'cursor-pointer rounded-sm px-3 py-2 text-sm outline-none', - 'data-highlighted:bg-secondary-background-hover' + 'z-3000 max-h-60 w-(--reka-combobox-trigger-width) overflow-y-auto', + 'rounded-lg border border-border-default bg-base-background p-1 shadow-lg' ) " - @select.prevent="onSelectSuggestion(suggestion)" > - <slot name="suggestion" :suggestion> - {{ suggestionLabel(suggestion) }} - </slot> - </ComboboxItem> - </ComboboxContent> + <ComboboxItem + v-for="(suggestion, index) in suggestions" + :key="suggestionKey(suggestion, index)" + :value="suggestionValue(suggestion)" + :class=" + cn( + 'cursor-pointer rounded-sm px-3 py-2 text-sm outline-none', + 'data-highlighted:bg-secondary-background-hover' + ) + " + @select.prevent="onSelectSuggestion(suggestion)" + > + <slot name="suggestion" :suggestion> + {{ suggestionLabel(suggestion) }} + </slot> + </ComboboxItem> + </ComboboxContent> + </ComboboxPortal> </ComboboxRoot> </template> @@ -105,6 +107,7 @@ import { ComboboxContent, ComboboxInput, ComboboxItem, + ComboboxPortal, ComboboxRoot } from 'reka-ui' import { computed, ref, watch } from 'vue' diff --git a/src/components/ui/textarea/Textarea.vue b/src/components/ui/textarea/Textarea.vue index 210e5569c9..3663f775b0 100644 --- a/src/components/ui/textarea/Textarea.vue +++ b/src/components/ui/textarea/Textarea.vue @@ -1,18 +1,25 @@ <script setup lang="ts"> import type { HTMLAttributes } from 'vue' +import { useTemplateRef } from 'vue' import { cn } from '@comfyorg/tailwind-utils' -const { class: className, ...restAttrs } = defineProps<{ +const { class: className } = defineProps<{ class?: HTMLAttributes['class'] }>() const modelValue = defineModel<string | number>() + +const textareaEl = useTemplateRef<HTMLTextAreaElement>('textareaEl') + +defineExpose({ + focus: () => textareaEl.value?.focus() +}) </script> <template> <textarea - v-bind="restAttrs" + ref="textareaEl" v-model="modelValue" :class=" cn( diff --git a/src/components/widget/layout/BaseModalLayout.vue b/src/components/widget/layout/BaseModalLayout.vue index fbd7291393..0b11ba1d82 100644 --- a/src/components/widget/layout/BaseModalLayout.vue +++ b/src/components/widget/layout/BaseModalLayout.vue @@ -35,7 +35,7 @@ v-if="$slots.header" class="flex h-18 w-full items-center justify-between gap-2 px-6" > - <div class="flex flex-1 shrink-0 gap-2"> + <div class="flex min-w-0 flex-1 gap-2"> <Button v-if="!notMobile && !showLeftPanel" size="lg" diff --git a/src/composables/maskeditor/useBrushAdjustment.test.ts b/src/composables/maskeditor/useBrushAdjustment.test.ts new file mode 100644 index 0000000000..a0e0838f8c --- /dev/null +++ b/src/composables/maskeditor/useBrushAdjustment.test.ts @@ -0,0 +1,142 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createTestingPinia } from '@pinia/testing' +import { setActivePinia } from 'pinia' + +vi.mock('@/composables/maskeditor/useCoordinateTransform', () => ({ + useCoordinateTransform: () => ({ + screenToCanvas: vi.fn(({ x, y }: { x: number; y: number }) => ({ x, y })) + }) +})) + +vi.mock('@/scripts/app', () => ({ + app: { registerExtension: vi.fn() } +})) + +import { useMaskEditorStore } from '@/stores/maskEditorStore' +import { useBrushAdjustment } from './useBrushAdjustment' + +function makePointerEvent(offsetX: number, offsetY: number): PointerEvent { + return { + offsetX, + offsetY, + preventDefault: vi.fn() + } as unknown as PointerEvent +} + +beforeEach(() => { + setActivePinia(createTestingPinia({ stubActions: false })) + vi.resetAllMocks() +}) + +describe('startBrushAdjustment', () => { + it('sets brushPreviewGradientVisible to true', async () => { + const store = useMaskEditorStore() + store.brushPreviewGradientVisible = false + const { startBrushAdjustment } = useBrushAdjustment() + await startBrushAdjustment(makePointerEvent(100, 100)) + expect(store.brushPreviewGradientVisible).toBe(true) + }) +}) + +describe('handleBrushAdjustment', () => { + it('does nothing when startBrushAdjustment has not been called', async () => { + const store = useMaskEditorStore() + const sizeBefore = store.brushSettings.size + const hardnessBefore = store.brushSettings.hardness + const { handleBrushAdjustment } = useBrushAdjustment() + await handleBrushAdjustment(makePointerEvent(200, 100)) + expect(store.brushSettings.size).toBe(sizeBefore) + expect(store.brushSettings.hardness).toBe(hardnessBefore) + }) + + it('does not change size when deltaX is within the dead zone', async () => { + const store = useMaskEditorStore() + const { startBrushAdjustment, handleBrushAdjustment } = useBrushAdjustment() + await startBrushAdjustment(makePointerEvent(100, 100)) + const sizeBefore = store.brushSettings.size + await handleBrushAdjustment(makePointerEvent(103, 100)) + expect(store.brushSettings.size).toBe(sizeBefore) + }) + + it('increases size when dragging right past the dead zone', async () => { + const store = useMaskEditorStore() + const { startBrushAdjustment, handleBrushAdjustment } = useBrushAdjustment() + await startBrushAdjustment(makePointerEvent(100, 100)) + const sizeBefore = store.brushSettings.size + await handleBrushAdjustment(makePointerEvent(150, 100)) + expect(store.brushSettings.size).toBeGreaterThan(sizeBefore) + }) + + it('does not compound size when pointer stays at the same position', async () => { + const store = useMaskEditorStore() + const { startBrushAdjustment, handleBrushAdjustment } = useBrushAdjustment() + await startBrushAdjustment(makePointerEvent(100, 100)) + + await handleBrushAdjustment(makePointerEvent(150, 100)) + const sizeAfterFirstMove = store.brushSettings.size + + await handleBrushAdjustment(makePointerEvent(150, 100)) + expect(store.brushSettings.size).toBe(sizeAfterFirstMove) + }) + + it('continues increasing size beyond 100px drag (no delta saturation)', async () => { + const store = useMaskEditorStore() + const { startBrushAdjustment, handleBrushAdjustment } = useBrushAdjustment() + await startBrushAdjustment(makePointerEvent(0, 0)) + + await handleBrushAdjustment(makePointerEvent(100, 0)) + const sizeAt100px = store.brushSettings.size + + await handleBrushAdjustment(makePointerEvent(300, 0)) + const sizeAt300px = store.brushSettings.size + + expect(sizeAt300px).toBeGreaterThan(sizeAt100px) + }) + + it('clamps size to minimum 1 when dragging far left', async () => { + const store = useMaskEditorStore() + store.brushSettings.size = 2 + const { startBrushAdjustment, handleBrushAdjustment } = useBrushAdjustment() + await startBrushAdjustment(makePointerEvent(500, 100)) + await handleBrushAdjustment(makePointerEvent(0, 100)) + expect(store.brushSettings.size).toBe(1) + }) + + it('clamps hardness to maximum 1 when dragging far up', async () => { + const store = useMaskEditorStore() + store.brushSettings.hardness = 0.9 + const { startBrushAdjustment, handleBrushAdjustment } = useBrushAdjustment({ + brushAdjustmentSpeed: 10 + }) + await startBrushAdjustment(makePointerEvent(100, 500)) + await handleBrushAdjustment(makePointerEvent(100, 0)) + expect(store.brushSettings.hardness).toBe(1) + }) + + it('suppresses hardness change when X delta dominates (useDominantAxis=true)', async () => { + const store = useMaskEditorStore() + store.brushSettings.hardness = 0.5 + const { startBrushAdjustment, handleBrushAdjustment } = useBrushAdjustment({ + useDominantAxis: true + }) + await startBrushAdjustment(makePointerEvent(0, 0)) + const sizeBefore = store.brushSettings.size + await handleBrushAdjustment(makePointerEvent(100, 10)) + expect(store.brushSettings.size).toBeGreaterThan(sizeBefore) + expect(store.brushSettings.hardness).toBe(0.5) + }) + + it('suppresses size change when Y delta dominates (useDominantAxis=true)', async () => { + const store = useMaskEditorStore() + store.brushSettings.hardness = 0.5 + const { startBrushAdjustment, handleBrushAdjustment } = useBrushAdjustment({ + useDominantAxis: true + }) + await startBrushAdjustment(makePointerEvent(0, 0)) + const sizeBefore = store.brushSettings.size + const hardnessBefore = store.brushSettings.hardness + await handleBrushAdjustment(makePointerEvent(10, 100)) + expect(store.brushSettings.size).toBe(sizeBefore) + expect(store.brushSettings.hardness).toBeLessThan(hardnessBefore) + }) +}) diff --git a/src/composables/maskeditor/useBrushAdjustment.ts b/src/composables/maskeditor/useBrushAdjustment.ts new file mode 100644 index 0000000000..4427f83760 --- /dev/null +++ b/src/composables/maskeditor/useBrushAdjustment.ts @@ -0,0 +1,83 @@ +import { ref } from 'vue' + +import type { Point } from '@/extensions/core/maskeditor/types' +import { useMaskEditorStore } from '@/stores/maskEditorStore' +import { useCoordinateTransform } from './useCoordinateTransform' + +export function useBrushAdjustment(initialSettings?: { + useDominantAxis?: boolean + brushAdjustmentSpeed?: number +}) { + const store = useMaskEditorStore() + const coordinateTransform = useCoordinateTransform() + + const initialPoint = ref<Point | null>(null) + const initialBrushSize = ref(0) + const initialBrushHardness = ref(0) + const useDominantAxis = ref(initialSettings?.useDominantAxis ?? false) + const brushAdjustmentSpeed = ref(initialSettings?.brushAdjustmentSpeed ?? 1.0) + + async function startBrushAdjustment(event: PointerEvent): Promise<void> { + event.preventDefault() + + const coords = { x: event.offsetX, y: event.offsetY } + const coords_canvas = coordinateTransform.screenToCanvas(coords) + + store.brushPreviewGradientVisible = true + initialPoint.value = coords_canvas + initialBrushSize.value = store.brushSettings.size + initialBrushHardness.value = store.brushSettings.hardness + } + + async function handleBrushAdjustment(event: PointerEvent): Promise<void> { + if (!initialPoint.value) { + return + } + + const coords = { x: event.offsetX, y: event.offsetY } + const brushDeadZone = 5 + const coords_canvas = coordinateTransform.screenToCanvas(coords) + + const delta_x = coords_canvas.x - initialPoint.value.x + const delta_y = coords_canvas.y - initialPoint.value.y + + const effectiveDeltaX = Math.abs(delta_x) < brushDeadZone ? 0 : delta_x + const effectiveDeltaY = Math.abs(delta_y) < brushDeadZone ? 0 : delta_y + + let finalDeltaX = effectiveDeltaX + let finalDeltaY = effectiveDeltaY + + if (useDominantAxis.value) { + const ratio = Math.abs(effectiveDeltaX) / Math.abs(effectiveDeltaY) + const threshold = 2.0 + + if (ratio > threshold) { + finalDeltaY = 0 + } else if (ratio < 1 / threshold) { + finalDeltaX = 0 + } + } + + const newSize = Math.max( + 1, + Math.min( + 500, + initialBrushSize.value + (finalDeltaX / 35) * brushAdjustmentSpeed.value + ) + ) + + const newHardness = Math.max( + 0, + Math.min( + 1, + initialBrushHardness.value - + (finalDeltaY / 4000) * brushAdjustmentSpeed.value + ) + ) + + store.setBrushSize(newSize) + store.setBrushHardness(newHardness) + } + + return { startBrushAdjustment, handleBrushAdjustment } +} diff --git a/src/composables/maskeditor/useBrushDrawing.ts b/src/composables/maskeditor/useBrushDrawing.ts index da786609ea..5c8a43abbd 100644 --- a/src/composables/maskeditor/useBrushDrawing.ts +++ b/src/composables/maskeditor/useBrushDrawing.ts @@ -1,14 +1,12 @@ /// <reference types="@webgpu/types" /> import { ref, watch, nextTick, onUnmounted } from 'vue' -import { debounce } from 'es-toolkit/compat' import { parseToRgb } from '@/utils/colorUtil' -import { getStorageValue, setStorageValue } from '@/scripts/utils' import { Tools, BrushShape, CompositionOperation } from '@/extensions/core/maskeditor/types' -import type { Brush, Point } from '@/extensions/core/maskeditor/types' +import type { Point } from '@/extensions/core/maskeditor/types' import { useMaskEditorStore } from '@/stores/maskEditorStore' import { useCoordinateTransform } from './useCoordinateTransform' import { resampleSegment } from './splineUtils' @@ -16,6 +14,7 @@ import { tgpu } from 'typegpu' import { GPUBrushRenderer } from './gpu/GPUBrushRenderer' import { StrokeProcessor } from './StrokeProcessor' import { getEffectiveBrushSize, getEffectiveHardness } from './brushUtils' +import { useBrushAdjustment } from './useBrushAdjustment' import { resetDirtyRect, updateDirtyRect, @@ -24,45 +23,16 @@ import { drawMaskShape } from './brushDrawingUtils' import type { DirtyRect } from './brushDrawingUtils' - -/** - * Saves the brush settings to local storage with a debounce. - * @param key - The storage key. - * @param brush - The brush settings object. - */ -const saveBrushToCache = debounce(function (key: string, brush: Brush): void { - try { - const brushString = JSON.stringify(brush) - setStorageValue(key, brushString) - } catch (error) { - console.error('Failed to save brush to cache:', error) - } -}, 300) - -/** - * Loads brush settings from local storage. - * @param key - The storage key. - * @returns The brush settings object or null if not found. - */ -function loadBrushFromCache(key: string): Brush | null { - try { - const brushString = getStorageValue(key) - if (brushString) { - return JSON.parse(brushString) as Brush - } else { - return null - } - } catch (error) { - console.error('Failed to load brush from cache:', error) - return null - } -} +import { useBrushPersistence } from './useBrushPersistence' export function useBrushDrawing(initialSettings?: { useDominantAxis?: boolean brushAdjustmentSpeed?: number }) { const store = useMaskEditorStore() + const persistence = useBrushPersistence() + const { startBrushAdjustment, handleBrushAdjustment } = + useBrushAdjustment(initialSettings) const coordinateTransform = useCoordinateTransform() @@ -96,18 +66,7 @@ export function useBrushDrawing(initialSettings?: { // Stroke processor instance let strokeProcessor: StrokeProcessor | null = null - const initialPoint = ref<Point | null>(null) - const useDominantAxis = ref(initialSettings?.useDominantAxis ?? false) - const brushAdjustmentSpeed = ref(initialSettings?.brushAdjustmentSpeed ?? 1.0) - - const cachedBrushSettings = loadBrushFromCache('maskeditor_brush_settings') - if (cachedBrushSettings) { - store.setBrushSize(cachedBrushSettings.size) - store.setBrushOpacity(cachedBrushSettings.opacity) - store.setBrushHardness(cachedBrushSettings.hardness) - store.brushSettings.type = cachedBrushSettings.type - store.setBrushStepSize(cachedBrushSettings.stepSize ?? 5) - } + persistence.loadAndApply() // Handle external clear events watch( @@ -793,85 +752,6 @@ export function useBrushDrawing(initialSettings?: { } } - /** - * Starts the brush adjustment interaction. - * @param event - The pointer event. - */ - async function startBrushAdjustment(event: PointerEvent): Promise<void> { - event.preventDefault() - - const coords = { x: event.offsetX, y: event.offsetY } - const coords_canvas = coordinateTransform.screenToCanvas(coords) - - store.brushPreviewGradientVisible = true - initialPoint.value = coords_canvas - } - - /** - * Handles the brush adjustment movement. - * @param event - The pointer event. - */ - async function handleBrushAdjustment(event: PointerEvent): Promise<void> { - if (!initialPoint.value) { - return - } - - const coords = { x: event.offsetX, y: event.offsetY } - const brushDeadZone = 5 - const coords_canvas = coordinateTransform.screenToCanvas(coords) - - const delta_x = coords_canvas.x - initialPoint.value.x - const delta_y = coords_canvas.y - initialPoint.value.y - - const effectiveDeltaX = Math.abs(delta_x) < brushDeadZone ? 0 : delta_x - const effectiveDeltaY = Math.abs(delta_y) < brushDeadZone ? 0 : delta_y - - let finalDeltaX = effectiveDeltaX - let finalDeltaY = effectiveDeltaY - - if (useDominantAxis.value) { - const ratio = Math.abs(effectiveDeltaX) / Math.abs(effectiveDeltaY) - const threshold = 2.0 - - if (ratio > threshold) { - finalDeltaY = 0 - } else if (ratio < 1 / threshold) { - finalDeltaX = 0 - } - } - - const cappedDeltaX = Math.max(-100, Math.min(100, finalDeltaX)) - const cappedDeltaY = Math.max(-100, Math.min(100, finalDeltaY)) - - const newSize = Math.max( - 1, - Math.min( - 500, - store.brushSettings.size + - (cappedDeltaX / 35) * brushAdjustmentSpeed.value - ) - ) - - const newHardness = Math.max( - 0, - Math.min( - 1, - store.brushSettings.hardness - - (cappedDeltaY / 4000) * brushAdjustmentSpeed.value - ) - ) - - store.setBrushSize(newSize) - store.setBrushHardness(newHardness) - } - - /** - * Saves the current brush settings to cache. - */ - function saveBrushSettings(): void { - saveBrushToCache('maskeditor_brush_settings', store.brushSettings) - } - /** * Reads back the GPU textures to CPU ImageDatas. * @returns Object containing mask and rgb ImageDatas. @@ -1272,7 +1152,7 @@ export function useBrushDrawing(initialSettings?: { drawEnd, startBrushAdjustment, handleBrushAdjustment, - saveBrushSettings, + saveBrushSettings: persistence.save, destroy, initGPUResources, initPreviewCanvas, diff --git a/src/composables/maskeditor/useBrushPersistence.test.ts b/src/composables/maskeditor/useBrushPersistence.test.ts new file mode 100644 index 0000000000..ee4b8133d3 --- /dev/null +++ b/src/composables/maskeditor/useBrushPersistence.test.ts @@ -0,0 +1,101 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createTestingPinia } from '@pinia/testing' +import { setActivePinia } from 'pinia' + +vi.mock('es-toolkit/compat', async (importOriginal) => { + const actual = await importOriginal() + return { + ...(actual as object), + debounce: vi.fn((fn: (...args: unknown[]) => void) => { + const immediate = (...args: unknown[]) => fn(...args) + immediate.cancel = vi.fn() + return immediate + }) + } +}) + +vi.mock('@/scripts/utils', () => ({ + getStorageValue: vi.fn((key: string) => localStorage.getItem(key)), + setStorageValue: vi.fn((key: string, value: string) => { + localStorage.setItem(key, value) + }) +})) + +import { useMaskEditorStore } from '@/stores/maskEditorStore' +import { useBrushPersistence } from './useBrushPersistence' + +const STORAGE_KEY = 'maskeditor_brush_settings' + +beforeEach(() => { + setActivePinia(createTestingPinia({ stubActions: false })) + localStorage.clear() + vi.resetAllMocks() +}) + +describe('loadAndApply', () => { + it('does not mutate the store when localStorage is empty', () => { + const store = useMaskEditorStore() + const sizeBefore = store.brushSettings.size + const { loadAndApply } = useBrushPersistence() + loadAndApply() + expect(store.brushSettings.size).toBe(sizeBefore) + }) + + it('restores all brush properties from a previous save', () => { + localStorage.setItem( + STORAGE_KEY, + JSON.stringify({ + size: 42, + opacity: 0.7, + hardness: 0.3, + type: 'arc', + stepSize: 10 + }) + ) + const store = useMaskEditorStore() + const { loadAndApply } = useBrushPersistence() + loadAndApply() + expect(store.brushSettings.size).toBe(42) + expect(store.brushSettings.opacity).toBe(0.7) + expect(store.brushSettings.hardness).toBe(0.3) + expect(store.brushSettings.stepSize).toBe(10) + }) + + it('falls back to stepSize=5 when the field is missing from stored data', () => { + localStorage.setItem( + STORAGE_KEY, + JSON.stringify({ size: 20, opacity: 0.8, hardness: 0.5, type: 'arc' }) + ) + const store = useMaskEditorStore() + const { loadAndApply } = useBrushPersistence() + loadAndApply() + expect(store.brushSettings.stepSize).toBe(5) + }) + + it('does not throw on corrupted localStorage data', () => { + localStorage.setItem(STORAGE_KEY, 'not-valid-json') + const { loadAndApply } = useBrushPersistence() + expect(() => loadAndApply()).not.toThrow() + }) +}) + +describe('save', () => { + it('writes current brush settings to localStorage', () => { + const store = useMaskEditorStore() + store.brushSettings.size = 99 + const { save } = useBrushPersistence() + save() + const saved = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '{}') + expect(saved.size).toBe(99) + }) + + it('captures settings at call time so a subsequent store reset does not overwrite the save', () => { + const store = useMaskEditorStore() + store.brushSettings.size = 77 + const { save } = useBrushPersistence() + save() + store.brushSettings.size = 10 + const saved = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '{}') + expect(saved.size).toBe(77) + }) +}) diff --git a/src/composables/maskeditor/useBrushPersistence.ts b/src/composables/maskeditor/useBrushPersistence.ts new file mode 100644 index 0000000000..fbf6faa5b2 --- /dev/null +++ b/src/composables/maskeditor/useBrushPersistence.ts @@ -0,0 +1,48 @@ +import { debounce } from 'es-toolkit/compat' + +import { getStorageValue, setStorageValue } from '@/scripts/utils' +import type { Brush } from '@/extensions/core/maskeditor/types' +import { useMaskEditorStore } from '@/stores/maskEditorStore' + +const STORAGE_KEY = 'maskeditor_brush_settings' + +function loadBrushFromStorage(): Brush | null { + try { + const brushString = getStorageValue(STORAGE_KEY) + if (brushString) { + return JSON.parse(brushString) as Brush + } + return null + } catch (error) { + console.error('Failed to load brush from cache:', error) + return null + } +} + +const debouncedWrite = debounce((serialized: string): void => { + try { + setStorageValue(STORAGE_KEY, serialized) + } catch (error) { + console.error('Failed to save brush to cache:', error) + } +}, 300) + +export function useBrushPersistence() { + const store = useMaskEditorStore() + + function save(): void { + debouncedWrite(JSON.stringify(store.brushSettings)) + } + + function loadAndApply(): void { + const cached = loadBrushFromStorage() + if (!cached) return + store.setBrushSize(cached.size) + store.setBrushOpacity(cached.opacity) + store.setBrushHardness(cached.hardness) + store.brushSettings.type = cached.type + store.setBrushStepSize(cached.stepSize ?? 5) + } + + return { loadAndApply, save } +} diff --git a/src/composables/node/useNodeAnimatedImage.test.ts b/src/composables/node/useNodeAnimatedImage.test.ts new file mode 100644 index 0000000000..4b5b421cd8 --- /dev/null +++ b/src/composables/node/useNodeAnimatedImage.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it, onTestFinished, vi } from 'vitest' + +import { useNodeAnimatedImage } from '@/composables/node/useNodeAnimatedImage' +import { createMockMediaNode } from '@/renderer/extensions/vueNodes/widgets/composables/domWidgetTestUtils' + +const { canvasInteractionsMock } = vi.hoisted(() => ({ + canvasInteractionsMock: { + handleWheel: vi.fn(), + handlePointer: vi.fn(), + forwardEventToCanvas: vi.fn() + } +})) + +vi.mock('@/renderer/core/canvas/useCanvasInteractions', () => ({ + useCanvasInteractions: () => canvasInteractionsMock +})) +// `@/scripts/app` has a heavy import graph (pinia stores, LGraphCanvas, etc.) +// that we cannot pull in here, so we stub only the constant we need. +vi.mock('@/scripts/app', () => ({ + ANIM_PREVIEW_WIDGET: '$$comfy_animation_preview' +})) + +describe('useNodeAnimatedImage', () => { + function setup() { + vi.clearAllMocks() + const node = createMockMediaNode({ imgs: [document.createElement('img')] }) + const { showAnimatedPreview, removeAnimatedPreview } = + useNodeAnimatedImage() + showAnimatedPreview(node) + const element = node.widgets[0].element + document.body.append(element) + onTestFinished(() => element.remove()) + return { node, element, showAnimatedPreview, removeAnimatedPreview } + } + + it('forwards non-right-click pointer events and wheel to the canvas while alive', () => { + const { element } = setup() + element.dispatchEvent(new WheelEvent('wheel')) + element.dispatchEvent(new PointerEvent('pointermove')) + element.dispatchEvent(new PointerEvent('pointerup')) + element.dispatchEvent(new PointerEvent('pointerdown', { button: 0 })) + + expect(canvasInteractionsMock.handleWheel).toHaveBeenCalledTimes(1) + expect(canvasInteractionsMock.handlePointer).toHaveBeenCalledTimes(3) + expect(canvasInteractionsMock.forwardEventToCanvas).not.toHaveBeenCalled() + }) + + it('routes right-click pointerdown through forwardEventToCanvas, not handlePointer', () => { + const { element } = setup() + element.dispatchEvent(new PointerEvent('pointerdown', { button: 2 })) + + expect(canvasInteractionsMock.forwardEventToCanvas).toHaveBeenCalledTimes(1) + expect(canvasInteractionsMock.handlePointer).not.toHaveBeenCalled() + }) + + it('detaches every listener when the preview is removed', () => { + const { node, element, removeAnimatedPreview } = setup() + removeAnimatedPreview(node) + + element.dispatchEvent(new WheelEvent('wheel')) + element.dispatchEvent(new PointerEvent('pointermove')) + element.dispatchEvent(new PointerEvent('pointerup')) + element.dispatchEvent(new PointerEvent('pointerdown', { button: 0 })) + element.dispatchEvent(new PointerEvent('pointerdown', { button: 2 })) + + expect(canvasInteractionsMock.handleWheel).not.toHaveBeenCalled() + expect(canvasInteractionsMock.handlePointer).not.toHaveBeenCalled() + expect(canvasInteractionsMock.forwardEventToCanvas).not.toHaveBeenCalled() + }) +}) diff --git a/src/composables/node/useNodeAnimatedImage.ts b/src/composables/node/useNodeAnimatedImage.ts index 962396b0e9..39a5eb9ff8 100644 --- a/src/composables/node/useNodeAnimatedImage.ts +++ b/src/composables/node/useNodeAnimatedImage.ts @@ -1,3 +1,4 @@ +import { useChainCallback } from '@/composables/functional/useChainCallback' import type { LGraphNode } from '@/lib/litegraph/src/litegraph' import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions' import { ANIM_PREVIEW_WIDGET } from '@/scripts/app' @@ -39,17 +40,20 @@ export function useNodeAnimatedImage() { const { handleWheel, handlePointer, forwardEventToCanvas } = useCanvasInteractions() node.imgs[0].style.pointerEvents = 'none' - element.addEventListener('wheel', handleWheel) - element.addEventListener('pointermove', handlePointer) - element.addEventListener('pointerup', handlePointer) + const controller = new AbortController() + const { signal } = controller + element.addEventListener('wheel', handleWheel, { signal }) + element.addEventListener('pointermove', handlePointer, { signal }) + element.addEventListener('pointerup', handlePointer, { signal }) element.addEventListener( 'pointerdown', - (e) => { - return e.button !== 2 ? handlePointer(e) : forwardEventToCanvas(e) - }, - true + (e) => (e.button !== 2 ? handlePointer(e) : forwardEventToCanvas(e)), + { capture: true, signal } ) + widget.onRemove = useChainCallback(widget.onRemove, () => { + controller.abort() + }) widget.serialize = false widget.serializeValue = () => undefined } diff --git a/src/composables/node/useNodeDragAndDrop.test.ts b/src/composables/node/useNodeDragAndDrop.test.ts index b88e3400d1..c2204b68d6 100644 --- a/src/composables/node/useNodeDragAndDrop.test.ts +++ b/src/composables/node/useNodeDragAndDrop.test.ts @@ -86,129 +86,6 @@ describe('useNodeDragAndDrop', () => { expect(isDragging).toBe(false) }) - describe('claimEvent flag', () => { - function createClaimableEvent( - options: Parameters<typeof createDragEvent>[0] - ) { - const event = createDragEvent(options) - const preventDefault = vi.fn() - const stopPropagation = vi.fn() - Object.assign(event, { preventDefault, stopPropagation }) - return { event, preventDefault, stopPropagation } - } - - it('claims the event synchronously before awaiting onDrop for valid file drops', async () => { - const { event, preventDefault, stopPropagation } = createClaimableEvent({ - files: [createFile('a.png')] - }) - - const onDrop = vi.fn().mockImplementation(async () => { - // By the time onDrop runs, the event must already be claimed — - // claiming after the await would let document fallback handlers fire. - expect(preventDefault).toHaveBeenCalledTimes(1) - expect(stopPropagation).toHaveBeenCalledTimes(1) - return [] - }) - - const node = createNode() - useNodeDragAndDrop(node, { onDrop }) - - const result = await node.onDragDrop?.(event, true) - - expect(result).toBe(true) - expect(onDrop).toHaveBeenCalledTimes(1) - }) - - it('does not claim the event when files are filtered out', async () => { - const node = createNode() - useNodeDragAndDrop(node, { - onDrop: vi.fn().mockResolvedValue([]), - fileFilter: (file) => file.type === 'image/png' - }) - - const { event, preventDefault, stopPropagation } = createClaimableEvent({ - files: [createFile('a.jpg', 'image/jpeg')] - }) - - const result = await node.onDragDrop?.(event, true) - - expect(result).toBe(false) - expect(preventDefault).not.toHaveBeenCalled() - expect(stopPropagation).not.toHaveBeenCalled() - }) - - it('claims the event for same-origin uri drops before fetching', async () => { - const { event, preventDefault, stopPropagation } = createClaimableEvent({ - uri: `${location.origin}/api/file?filename=uri.png`, - types: ['text/uri-list'] - }) - - vi.spyOn(globalThis, 'fetch').mockImplementation(async () => { - expect(preventDefault).toHaveBeenCalledTimes(1) - expect(stopPropagation).toHaveBeenCalledTimes(1) - return fromAny<Response, unknown>({ - ok: true, - blob: vi - .fn() - .mockResolvedValue(new Blob(['uri'], { type: 'image/png' })) - }) - }) - - const node = createNode() - useNodeDragAndDrop(node, { onDrop: vi.fn().mockResolvedValue([]) }) - - const result = await node.onDragDrop?.(event, true) - - expect(result).toBe(true) - }) - - it('does not claim the event for cross-origin uri drops', async () => { - const node = createNode() - useNodeDragAndDrop(node, { onDrop: vi.fn().mockResolvedValue([]) }) - - const { event, preventDefault, stopPropagation } = createClaimableEvent({ - uri: 'https://example.com/api/file?filename=uri.png', - types: ['text/uri-list'] - }) - - const result = await node.onDragDrop?.(event, true) - - expect(result).toBe(false) - expect(preventDefault).not.toHaveBeenCalled() - expect(stopPropagation).not.toHaveBeenCalled() - }) - - it('does not claim the event when drop has no files and no uri', async () => { - const node = createNode() - useNodeDragAndDrop(node, { onDrop: vi.fn().mockResolvedValue([]) }) - - const { event, preventDefault, stopPropagation } = createClaimableEvent( - {} - ) - - const result = await node.onDragDrop?.(event, true) - - expect(result).toBe(false) - expect(preventDefault).not.toHaveBeenCalled() - expect(stopPropagation).not.toHaveBeenCalled() - }) - - it('does not claim the event when claimEvent is omitted', async () => { - const node = createNode() - useNodeDragAndDrop(node, { onDrop: vi.fn().mockResolvedValue([]) }) - - const { event, preventDefault, stopPropagation } = createClaimableEvent({ - files: [createFile('a.png')] - }) - - const result = await node.onDragDrop?.(event) - - expect(result).toBe(true) - expect(preventDefault).not.toHaveBeenCalled() - expect(stopPropagation).not.toHaveBeenCalled() - }) - }) - it('onDragDrop calls onDrop with filtered files', async () => { const onDrop = vi.fn().mockResolvedValue([]) const node = createNode() diff --git a/src/composables/node/useNodeDragAndDrop.ts b/src/composables/node/useNodeDragAndDrop.ts index 072670167d..dba6b9f672 100644 --- a/src/composables/node/useNodeDragAndDrop.ts +++ b/src/composables/node/useNodeDragAndDrop.ts @@ -1,5 +1,8 @@ import { useChainCallback } from '@/composables/functional/useChainCallback' import type { LGraphNode } from '@/lib/litegraph/src/litegraph' +import { MIME_ASSET_INFO } from '@/platform/assets/schemas/mediaAssetSchema' +import { zResultItem } from '@/schemas/apiSchema' +import type { ResultItem } from '@/schemas/apiSchema' type DragHandler = (e: DragEvent) => boolean type DropHandler<T> = (files: File[]) => Promise<T[]> @@ -7,9 +10,18 @@ type DropHandler<T> = (files: File[]) => Promise<T[]> interface DragAndDropOptions<T> { onDragOver?: DragHandler onDrop: DropHandler<T> + onResultItemDrop?: (item: ResultItem) => void fileFilter?: (file: File) => boolean } +function parseAssetInfo(assetString?: string) { + try { + return zResultItem.safeParse(JSON.parse(assetString ?? '')).data + } catch { + // output was not parsable, allow fallthrough and return undefined + } +} + /** * Adds drag and drop file handling to a node * Will also resolve 'text/uri-list' to a file before passing @@ -47,27 +59,23 @@ export const useNodeDragAndDrop = <T>( const installedDragOver = isDraggingFiles node.onDragOver = installedDragOver - const installedDragDrop = async function (e: DragEvent, claimEvent = false) { + const installedDragDrop = async function (e: DragEvent) { if (!isDraggingValidFiles(e)) return false const files = filterFiles(e.dataTransfer!.files) if (files.length) { - if (claimEvent) { - e.preventDefault() - e.stopPropagation() - } await onDrop(files) return true } + const asset = parseAssetInfo(e?.dataTransfer?.getData(MIME_ASSET_INFO)) + if (asset?.filename && options.onResultItemDrop) { + await options.onResultItemDrop(asset) + return true + } const uri = URL.parse(e?.dataTransfer?.getData('text/uri-list') ?? '') if (!uri || uri.origin !== location.origin) return false - if (claimEvent) { - e.preventDefault() - e.stopPropagation() - } - try { const resp = await fetch(uri) const fileName = uri?.searchParams?.get('filename') diff --git a/src/composables/node/useNodeImage.test.ts b/src/composables/node/useNodeImage.test.ts new file mode 100644 index 0000000000..eb417afe21 --- /dev/null +++ b/src/composables/node/useNodeImage.test.ts @@ -0,0 +1,93 @@ +import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest' + +import { useNodeVideo } from '@/composables/node/useNodeImage' +import { createMockMediaNode } from '@/renderer/extensions/vueNodes/widgets/composables/domWidgetTestUtils' + +const { canvasInteractionsMock, nodeOutputStoreMock } = vi.hoisted(() => ({ + canvasInteractionsMock: { + handleWheel: vi.fn(), + handlePointer: vi.fn() + }, + nodeOutputStoreMock: { + getNodeImageUrls: vi.fn<(node: unknown) => string[] | undefined>() + } +})) + +vi.mock('@/renderer/core/canvas/useCanvasInteractions', () => ({ + useCanvasInteractions: () => canvasInteractionsMock +})) +vi.mock('@/stores/nodeOutputStore', () => ({ + useNodeOutputStore: () => nodeOutputStoreMock +})) +vi.mock('@/utils/imageUtil', () => ({ + fitDimensionsToNodeWidth: () => ({ minHeight: 256, minWidth: 256 }) +})) + +describe('useNodeVideo', () => { + afterEach(() => { + vi.useRealTimers() + vi.restoreAllMocks() + }) + + async function setup() { + vi.clearAllMocks() + vi.useFakeTimers() + + nodeOutputStoreMock.getNodeImageUrls.mockReturnValue(['http://video/1.mp4']) + const node = createMockMediaNode({ + size: [400, 400], + graph: { setDirtyCanvas: vi.fn() } + }) + + const createdVideos: HTMLVideoElement[] = [] + const realCreateElement = document.createElement.bind(document) + vi.spyOn(document, 'createElement').mockImplementation( + (tag: string, opts?: ElementCreationOptions) => { + const el = realCreateElement(tag, opts) + if (tag === 'video') createdVideos.push(el as HTMLVideoElement) + return el + } + ) + + const { showPreview } = useNodeVideo(node) + showPreview() + + // happy-dom does not auto-fire onloadeddata for src assignment, so we + // manually trigger it, then drain the resulting promise chain. + const video = createdVideos[0] + video.onloadeddata?.(new Event('loadeddata')) + await vi.runAllTimersAsync() + + onTestFinished(() => { + node.widgets[0]?.onRemove?.() + }) + + return { node, video } + } + + it('creates a video-preview widget and forwards canvas events while alive', async () => { + const { node, video } = await setup() + + expect(node.widgets[0]?.name).toBe('video-preview') + + video.dispatchEvent(new WheelEvent('wheel', { bubbles: true })) + video.dispatchEvent(new PointerEvent('pointermove', { bubbles: true })) + video.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true })) + + expect(canvasInteractionsMock.handleWheel).toHaveBeenCalledTimes(1) + expect(canvasInteractionsMock.handlePointer).toHaveBeenCalledTimes(2) + }) + + it('detaches every listener when the widget is removed', async () => { + const { node, video } = await setup() + + node.widgets[0]?.onRemove?.() + + video.dispatchEvent(new WheelEvent('wheel', { bubbles: true })) + video.dispatchEvent(new PointerEvent('pointermove', { bubbles: true })) + video.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true })) + + expect(canvasInteractionsMock.handleWheel).not.toHaveBeenCalled() + expect(canvasInteractionsMock.handlePointer).not.toHaveBeenCalled() + }) +}) diff --git a/src/composables/node/useNodeImage.ts b/src/composables/node/useNodeImage.ts index ea3d1d0099..86c5e5ac68 100644 --- a/src/composables/node/useNodeImage.ts +++ b/src/composables/node/useNodeImage.ts @@ -1,3 +1,4 @@ +import { useChainCallback } from '@/composables/functional/useChainCallback' import type { LGraphNode } from '@/lib/litegraph/src/litegraph' import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions' import { useNodeOutputStore } from '@/stores/nodeOutputStore' @@ -151,11 +152,6 @@ export const useNodeVideo = (node: LGraphNode, callback?: () => void) => { const video = document.createElement('video') Object.assign(video, VIDEO_DEFAULT_OPTIONS) - // Add event listeners for canvas interactions - video.addEventListener('wheel', handleWheel) - video.addEventListener('pointermove', handlePointer) - video.addEventListener('pointerdown', handlePointer) - video.onloadeddata = () => { setMinDimensions(video) resolve(video) @@ -176,6 +172,16 @@ export const useNodeVideo = (node: LGraphNode, callback?: () => void) => { minHeight, minWidth }) + + const controller = new AbortController() + const { signal } = controller + container.addEventListener('wheel', handleWheel, { signal }) + container.addEventListener('pointermove', handlePointer, { signal }) + container.addEventListener('pointerdown', handlePointer, { signal }) + + widget.onRemove = useChainCallback(widget.onRemove, () => { + controller.abort() + }) } } diff --git a/src/composables/node/useNodeImageUpload.test.ts b/src/composables/node/useNodeImageUpload.test.ts index b03d80237b..662d39c0b6 100644 --- a/src/composables/node/useNodeImageUpload.test.ts +++ b/src/composables/node/useNodeImageUpload.test.ts @@ -2,6 +2,7 @@ import { fromAny } from '@total-typescript/shoehorn' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { LGraphNode } from '@/lib/litegraph/src/litegraph' +import type { ResultItem } from '@/schemas/apiSchema' const { mockFetchApi, mockAddAlert, mockUpdateInputs } = vi.hoisted(() => ({ mockFetchApi: vi.fn(), @@ -73,7 +74,7 @@ function failResponse(status = 500) { describe('useNodeImageUpload', () => { let node: LGraphNode - let onUploadComplete: (paths: string[]) => void + let onUploadComplete: (paths: (string | ResultItem)[]) => void let onUploadStart: (files: File[]) => void let onUploadError: () => void diff --git a/src/composables/node/useNodeImageUpload.ts b/src/composables/node/useNodeImageUpload.ts index 407f2fbfd0..91ac7eae14 100644 --- a/src/composables/node/useNodeImageUpload.ts +++ b/src/composables/node/useNodeImageUpload.ts @@ -4,7 +4,7 @@ import { useNodePaste } from '@/composables/node/useNodePaste' import { t } from '@/i18n' import type { LGraphNode } from '@/lib/litegraph/src/litegraph' import { useToastStore } from '@/platform/updates/common/toastStore' -import type { ResultItemType } from '@/schemas/apiSchema' +import type { ResultItem, ResultItemType } from '@/schemas/apiSchema' import { api } from '@/scripts/api' import { useAssetsStore } from '@/stores/assetsStore' @@ -53,7 +53,7 @@ const uploadFile = async ( interface ImageUploadOptions { fileFilter?: (file: File) => boolean - onUploadComplete: (paths: string[]) => void + onUploadComplete: (paths: (string | ResultItem)[]) => void allow_batch?: boolean /** * The file types to accept. @@ -127,7 +127,8 @@ export const useNodeImageUpload = ( // Handle drag & drop useNodeDragAndDrop(node, { fileFilter, - onDrop: handleUploadBatch + onDrop: handleUploadBatch, + onResultItemDrop: (item) => onUploadComplete([item]) }) // Handle paste diff --git a/src/composables/node/useNodePricing.test.ts b/src/composables/node/useNodePricing.test.ts index 3713f2c142..950d2e44cb 100644 --- a/src/composables/node/useNodePricing.test.ts +++ b/src/composables/node/useNodePricing.test.ts @@ -10,6 +10,7 @@ import { useNodePricing } from '@/composables/node/useNodePricing' import type { LGraphNode } from '@/lib/litegraph/src/litegraph' +import { LiteGraph } from '@/lib/litegraph/src/litegraph' import type { ComfyNodeDef, PriceBadge } from '@/schemas/nodeDefSchema' import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils' @@ -577,6 +578,81 @@ describe('useNodePricing', () => { const config = getNodePricingConfig(node) expect(config).toBeUndefined() }) + + it('does not leak the compiled JSONata expression', () => { + const { getNodePricingConfig } = useNodePricing() + const node = createMockNodeWithPriceBadge( + 'TestStripCompiledNode', + priceBadge('{"type":"usd","usd":0.05}') + ) + + const config = getNodePricingConfig(node) + expect(config).toBeDefined() + // _compiled is the runtime JSONata instance and must not be exposed to + // tooling/debug consumers. + expect(config).not.toHaveProperty('_compiled') + }) + }) + + describe('reactive revision', () => { + it('bumps pricingRevision after an async evaluation resolves (Nodes 1.0 mode)', async () => { + const { getNodeDisplayPrice, pricingRevision } = useNodePricing() + const node = createMockNodeWithPriceBadge( + 'TestRevisionNode', + priceBadge('{"type":"usd","usd":0.05}') + ) + + const before = pricingRevision.value + getNodeDisplayPrice(node) + await new Promise((r) => setTimeout(r, 50)) + + expect(pricingRevision.value).toBeGreaterThan(before) + }) + + it('bumps the per-node revision ref after async evaluation resolves in VueNodes mode', async () => { + const { getNodeDisplayPrice, getNodeRevisionRef, pricingRevision } = + useNodePricing() + const node = createMockNodeWithPriceBadge( + 'TestVueNodeRevision', + priceBadge('{"type":"usd","usd":0.05}') + ) + + LiteGraph.vueNodesMode = true + try { + const revBefore = getNodeRevisionRef(node.id).value + const tickBefore = pricingRevision.value + + getNodeDisplayPrice(node) + await new Promise((r) => setTimeout(r, 50)) + + // VueNodes path bumps per-node ref instead of the global tick. + expect(getNodeRevisionRef(node.id).value).toBeGreaterThan(revBefore) + expect(pricingRevision.value).toBe(tickBefore) + } finally { + LiteGraph.vueNodesMode = false + } + }) + + it('returns the cached label on a second call with the same signature', async () => { + const { getNodeDisplayPrice, pricingRevision } = useNodePricing() + const node = createMockNodeWithPriceBadge( + 'TestCachedSignatureNode', + priceBadge('{"type":"usd","usd":0.05}') + ) + + // First call schedules eval; second call (after resolution) is a cache hit. + getNodeDisplayPrice(node) + await new Promise((r) => setTimeout(r, 50)) + const first = getNodeDisplayPrice(node) + + const tickAfterFirst = pricingRevision.value + const second = getNodeDisplayPrice(node) + // Cache-hit path must not schedule a new evaluation, so no further tick. + await new Promise((r) => setTimeout(r, 20)) + + expect(second).toBe(first) + expect(pricingRevision.value).toBe(tickAfterFirst) + }) }) describe('getNodeRevisionRef', () => { @@ -977,6 +1053,47 @@ describe('formatPricingResult', () => { expect(result).toBe('') }) }) + + describe('non-finite numbers', () => { + it('returns empty for type:usd when usd is a non-numeric string', () => { + const result = formatPricingResult({ type: 'usd', usd: 'not-a-number' }) + expect(result).toBe('') + }) + + it('returns empty for type:usd when usd is Infinity', () => { + const result = formatPricingResult({ type: 'usd', usd: Infinity }) + expect(result).toBe('') + }) + + it('returns empty for type:range_usd when min_usd or max_usd is NaN', () => { + expect( + formatPricingResult({ type: 'range_usd', min_usd: NaN, max_usd: 0.1 }) + ).toBe('') + expect( + formatPricingResult({ type: 'range_usd', min_usd: 0.05, max_usd: NaN }) + ).toBe('') + }) + + it('returns empty for type:list_usd when usd is empty or all values are non-finite', () => { + expect(formatPricingResult({ type: 'list_usd', usd: [] })).toBe('') + expect( + formatPricingResult({ type: 'list_usd', usd: [NaN, 'x', null] }) + ).toBe('') + }) + + it('drops non-finite entries from type:list_usd while keeping finite ones', () => { + const result = formatPricingResult( + { type: 'list_usd', usd: [0.05, NaN, 0.1] }, + { valueOnly: true } + ) + expect(result).toBe('10.6/21.1') + }) + + it('returns empty for legacy {usd} format when usd is non-finite', () => { + expect(formatPricingResult({ usd: NaN })).toBe('') + expect(formatPricingResult({ usd: 'abc' })).toBe('') + }) + }) }) // ----------------------------------------------------------------------------- diff --git a/src/composables/painter/usePainter.test.ts b/src/composables/painter/usePainter.test.ts index 9e4f643523..80a64fef2d 100644 --- a/src/composables/painter/usePainter.test.ts +++ b/src/composables/painter/usePainter.test.ts @@ -359,7 +359,7 @@ describe('usePainter', () => { expect(result).toBe('') }) - it('returns existing modelValue when not dirty', async () => { + it('returns empty string when canvas has no strokes even if modelValue is set', async () => { const maskWidget = makeWidget('mask', '') mockWidgets.push(maskWidget) @@ -367,20 +367,11 @@ describe('usePainter', () => { modelValue.value = 'painter/existing.png [temp]' const result = await maskWidget.serializeValue!({} as LGraphNode, 0) - // isCanvasEmpty() is true (no strokes drawn), so returns '' expect(result).toBe('') }) }) describe('restoreCanvas', () => { - it('builds correct URL from modelValue on mount', () => { - const { modelValue } = mountPainter() - // Before mount, set the modelValue - // restoreCanvas is called in onMounted, so we test by observing api.apiURL calls - // With empty modelValue, restoreCanvas exits early - expect(modelValue.value).toBe('') - }) - it('calls api.apiURL with parsed filename params when modelValue is set', () => { vi.mocked(api.apiURL).mockClear() @@ -424,6 +415,27 @@ describe('usePainter', () => { expect(mockSetPointerCapture).not.toHaveBeenCalled() }) + + it('tolerates setPointerCapture throwing for synthetic events', () => { + const { painter } = mountPainter() + + const event = new PointerEvent('pointerdown', { button: 0, pointerId: 1 }) + Object.defineProperty(event, 'target', { + value: { + setPointerCapture: vi.fn(() => { + throw new DOMException('NotFoundError') + }), + getBoundingClientRect: vi.fn(() => ({ + left: 0, + top: 0, + width: 100, + height: 100 + })) + } + }) + + expect(() => painter.handlePointerDown(event)).not.toThrow() + }) }) describe('handlePointerUp', () => { @@ -442,5 +454,21 @@ describe('usePainter', () => { expect(mockReleasePointerCapture).not.toHaveBeenCalled() }) + + it('tolerates releasePointerCapture throwing for synthetic events', () => { + const { painter } = mountPainter() + + const event = { + button: 0, + pointerId: 1, + target: { + releasePointerCapture: vi.fn(() => { + throw new DOMException('NotFoundError') + }) + } + } as unknown as PointerEvent + + expect(() => painter.handlePointerUp(event)).not.toThrow() + }) }) }) diff --git a/src/composables/painter/usePainter.ts b/src/composables/painter/usePainter.ts index 79b9a24c30..3c60ddfbf7 100644 --- a/src/composables/painter/usePainter.ts +++ b/src/composables/painter/usePainter.ts @@ -525,10 +525,14 @@ export function usePainter(nodeId: string, options: UsePainterOptions) { function handlePointerDown(e: PointerEvent) { if (e.button !== 0) return - ;(e.target as HTMLElement).setPointerCapture(e.pointerId) cacheCanvasRect() updateCursorPos(e) startStroke(e) + try { + ;(e.target as HTMLElement).setPointerCapture(e.pointerId) + } catch { + // setPointerCapture may throw for synthetic events (e.g. in tests) + } } let pendingMoveEvent: PointerEvent | null = null @@ -558,7 +562,11 @@ export function usePainter(nodeId: string, options: UsePainterOptions) { cancelAnimationFrame(rafId) flushPendingStroke() } - ;(e.target as HTMLElement).releasePointerCapture(e.pointerId) + try { + ;(e.target as HTMLElement).releasePointerCapture(e.pointerId) + } catch { + // releasePointerCapture may throw for synthetic events (e.g. in tests) + } endStroke() } diff --git a/src/composables/useCoreCommands.test.ts b/src/composables/useCoreCommands.test.ts index 0f8589cc59..8a37e2e7e1 100644 --- a/src/composables/useCoreCommands.test.ts +++ b/src/composables/useCoreCommands.test.ts @@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { ref } from 'vue' import { useCoreCommands } from '@/composables/useCoreCommands' +import { useExternalLink } from '@/composables/useExternalLink' import type { LGraphNode } from '@/lib/litegraph/src/litegraph' import { useSettingStore } from '@/platform/settings/settingStore' import { api } from '@/scripts/api' @@ -23,12 +24,22 @@ vi.mock('vue-i18n', async () => { vi.mock('@/scripts/app', () => { const mockGraphClear = vi.fn() + const mockDs = { + scale: 1, + element: { width: 800, height: 600 } as Pick< + HTMLCanvasElement, + 'width' | 'height' + >, + changeScale: vi.fn() + } const mockCanvas = { subgraph: undefined, selectedItems: new Set(), copyToClipboard: vi.fn(), pasteFromClipboard: vi.fn(), - selectItems: vi.fn() + selectItems: vi.fn(), + ds: mockDs, + setDirty: vi.fn() } return { @@ -39,6 +50,8 @@ vi.mock('@/scripts/app', () => { mockGraphClear() } }), + openClipspace: vi.fn(), + refreshComboInNodes: vi.fn().mockResolvedValue(undefined), canvas: mockCanvas, rootGraph: { clear: mockGraphClear @@ -81,8 +94,27 @@ vi.mock('@/services/dialogService', () => ({ useDialogService: vi.fn(() => mockDialogService) })) +const mockResetView = vi.hoisted(() => vi.fn()) vi.mock('@/services/litegraphService', () => ({ - useLitegraphService: vi.fn(() => ({})) + useLitegraphService: vi.fn(() => ({ + resetView: mockResetView + })) +})) + +const mockTrackHelpResourceClicked = vi.hoisted(() => vi.fn()) +vi.mock('@/platform/telemetry', () => ({ + useTelemetry: vi.fn(() => ({ + trackHelpResourceClicked: mockTrackHelpResourceClicked + })) +})) + +const mockShowAbout = vi.hoisted(() => vi.fn()) +const mockShowSettings = vi.hoisted(() => vi.fn()) +vi.mock('@/platform/settings/composables/useSettingsDialog', () => ({ + useSettingsDialog: vi.fn(() => ({ + show: mockShowSettings, + showAbout: mockShowAbout + })) })) vi.mock('@/stores/executionStore', () => ({ @@ -482,4 +514,97 @@ describe('useCoreCommands', () => { }) }) }) + + describe('Canvas view commands', () => { + const findCmd = (id: string) => + useCoreCommands().find((cmd) => cmd.id === id)! + + it('Comfy.Canvas.ResetView delegates to litegraphService.resetView', async () => { + await findCmd('Comfy.Canvas.ResetView').function() + + expect(mockResetView).toHaveBeenCalled() + }) + + it('Comfy.Canvas.ZoomIn scales the canvas up by 1.1× and marks it dirty', async () => { + app.canvas.ds.scale = 1 + await findCmd('Comfy.Canvas.ZoomIn').function() + + expect(app.canvas.ds.changeScale).toHaveBeenCalledWith( + 1.1, + expect.any(Array) + ) + expect(app.canvas.setDirty).toHaveBeenCalledWith(true, true) + }) + + it('Comfy.Canvas.ZoomOut scales the canvas down by 1/1.1× and marks it dirty', async () => { + app.canvas.ds.scale = 1 + await findCmd('Comfy.Canvas.ZoomOut').function() + + expect(app.canvas.ds.changeScale).toHaveBeenCalledWith( + 1 / 1.1, + expect.any(Array) + ) + expect(app.canvas.setDirty).toHaveBeenCalledWith(true, true) + }) + }) + + describe('Workflow lifecycle commands', () => { + const findCmd = (id: string) => + useCoreCommands().find((cmd) => cmd.id === id)! + + it('Comfy.OpenClipspace delegates to app.openClipspace', async () => { + await findCmd('Comfy.OpenClipspace').function() + + expect(app.openClipspace).toHaveBeenCalled() + }) + + it('Comfy.RefreshNodeDefinitions awaits app.refreshComboInNodes', async () => { + await findCmd('Comfy.RefreshNodeDefinitions').function() + + expect(app.refreshComboInNodes).toHaveBeenCalled() + }) + }) + + describe('Help commands', () => { + const findCmd = (id: string) => + useCoreCommands().find((cmd) => cmd.id === id)! + const { staticUrls } = useExternalLink() + let openSpy: ReturnType<typeof vi.spyOn> + + beforeEach(() => { + openSpy = vi + .spyOn(window, 'open') + .mockImplementation(() => null as unknown as Window) + }) + + it('Comfy.Help.OpenComfyUIIssues opens the GitHub issues URL and tracks telemetry', async () => { + await findCmd('Comfy.Help.OpenComfyUIIssues').function() + + expect(mockTrackHelpResourceClicked).toHaveBeenCalledWith( + expect.objectContaining({ + resource_type: 'github', + is_external: true, + source: 'menu' + }) + ) + expect(openSpy).toHaveBeenCalledWith(staticUrls.githubIssues, '_blank') + }) + + it('Comfy.Help.OpenComfyOrgDiscord opens the Discord URL and tracks telemetry', async () => { + await findCmd('Comfy.Help.OpenComfyOrgDiscord').function() + + expect(mockTrackHelpResourceClicked).toHaveBeenCalledWith( + expect.objectContaining({ + resource_type: 'discord' + }) + ) + expect(openSpy).toHaveBeenCalledWith(staticUrls.discord, '_blank') + }) + + it('Comfy.Help.AboutComfyUI opens the About dialog', async () => { + await findCmd('Comfy.Help.AboutComfyUI').function() + + expect(mockShowAbout).toHaveBeenCalled() + }) + }) }) diff --git a/src/composables/useLoad3d.test.ts b/src/composables/useLoad3d.test.ts index 083e001f72..9c0027d21b 100644 --- a/src/composables/useLoad3d.test.ts +++ b/src/composables/useLoad3d.test.ts @@ -4,6 +4,7 @@ import { nextTick, ref, shallowRef } from 'vue' import { nodeToLoad3dMap, useLoad3d } from '@/composables/useLoad3d' import Load3d from '@/extensions/core/load3d/Load3d' import Load3dUtils from '@/extensions/core/load3d/Load3dUtils' +import { createLoad3d } from '@/extensions/core/load3d/createLoad3d' import type { Size } from '@/lib/litegraph/src/interfaces' import type { LGraph } from '@/lib/litegraph/src/LGraph' import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode' @@ -19,6 +20,10 @@ vi.mock('@/extensions/core/load3d/Load3d', () => ({ default: vi.fn() })) +vi.mock('@/extensions/core/load3d/createLoad3d', () => ({ + createLoad3d: vi.fn() +})) + vi.mock('@/extensions/core/load3d/Load3dUtils', () => ({ default: { splitFilePath: vi.fn(), @@ -136,6 +141,15 @@ describe('useLoad3d', () => { exportModel: vi.fn().mockResolvedValue(undefined), isSplatModel: vi.fn().mockReturnValue(false), isPlyModel: vi.fn().mockReturnValue(false), + getCurrentModelCapabilities: vi.fn().mockReturnValue({ + fitToViewer: true, + requiresMaterialRebuild: false, + gizmoTransform: true, + lighting: true, + exportable: true, + materialModes: ['original', 'normal', 'wireframe'], + fitTargetSize: 5 + }), hasSkeleton: vi.fn().mockReturnValue(false), setShowSkeleton: vi.fn(), loadHDRI: vi.fn().mockResolvedValue(undefined), @@ -161,6 +175,7 @@ describe('useLoad3d', () => { Object.assign(this, mockLoad3d) return this }) + vi.mocked(createLoad3d).mockImplementation(() => mockLoad3d as Load3d) mockToastStore = { addAlert: vi.fn() @@ -181,7 +196,7 @@ describe('useLoad3d', () => { await composable.initializeLoad3d(containerRef) - expect(Load3d).toHaveBeenCalledWith( + expect(createLoad3d).toHaveBeenCalledWith( containerRef, expect.objectContaining({ width: 512, @@ -291,7 +306,7 @@ describe('useLoad3d', () => { }) it('should handle initialization errors', async () => { - vi.mocked(Load3d).mockImplementationOnce(function () { + vi.mocked(createLoad3d).mockImplementationOnce(() => { throw new Error('Load3d creation failed') }) @@ -310,7 +325,7 @@ describe('useLoad3d', () => { await composable.initializeLoad3d(null!) - expect(Load3d).not.toHaveBeenCalled() + expect(createLoad3d).not.toHaveBeenCalled() }) it('should accept ref as parameter', () => { @@ -1029,7 +1044,7 @@ describe('useLoad3d', () => { await composable.initializeLoad3d(containerRef) // Should not throw and should use defaults - expect(Load3d).toHaveBeenCalled() + expect(createLoad3d).toHaveBeenCalled() }) it('should handle background image with existing config', async () => { diff --git a/src/composables/useLoad3d.ts b/src/composables/useLoad3d.ts index b6362c2c53..619aa89f41 100644 --- a/src/composables/useLoad3d.ts +++ b/src/composables/useLoad3d.ts @@ -5,8 +5,9 @@ import { getActivePinia } from 'pinia' import { ref, toRaw, watch } from 'vue' import { useChainCallback } from '@/composables/functional/useChainCallback' -import Load3d from '@/extensions/core/load3d/Load3d' +import type Load3d from '@/extensions/core/load3d/Load3d' import Load3dUtils from '@/extensions/core/load3d/Load3dUtils' +import { createLoad3d } from '@/extensions/core/load3d/createLoad3d' import { isAssetPreviewSupported, persistThumbnail @@ -96,6 +97,15 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => { const isPreview = ref(false) const isSplatModel = ref(false) const isPlyModel = ref(false) + const canFitToViewer = ref(true) + const canUseGizmo = ref(true) + const canUseLighting = ref(true) + const canExport = ref(true) + const materialModes = ref<readonly MaterialMode[]>([ + 'original', + 'normal', + 'wireframe' + ]) const initializeLoad3d = async (containerRef: HTMLElement) => { const rawNode = toRaw(nodeRef.value) @@ -111,7 +121,7 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => { isPreview.value = true } - load3d = new Load3d(containerRef, { + load3d = createLoad3d(containerRef, { width: widthWidget?.value as number | undefined, height: heightWidget?.value as number | undefined, // Provide dynamic dimension getter for reactive updates @@ -784,6 +794,16 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => { loading.value = false isSplatModel.value = load3d?.isSplatModel() ?? false isPlyModel.value = load3d?.isPlyModel() ?? false + const caps = load3d?.getCurrentModelCapabilities() + canFitToViewer.value = caps?.fitToViewer ?? true + canUseGizmo.value = caps?.gizmoTransform ?? true + canUseLighting.value = caps?.lighting ?? true + canExport.value = caps?.exportable ?? true + materialModes.value = caps?.materialModes ?? [ + 'original', + 'normal', + 'wireframe' + ] hasSkeleton.value = load3d?.hasSkeleton() ?? false applyGizmoConfigToLoad3d() isFirstModelLoad = false @@ -924,6 +944,11 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => { isPreview, isSplatModel, isPlyModel, + canFitToViewer, + canUseGizmo, + canUseLighting, + canExport, + materialModes, hasSkeleton, hasRecording, recordingDuration, diff --git a/src/composables/useLoad3dViewer.test.ts b/src/composables/useLoad3dViewer.test.ts index 1866bdf68f..a692871a3f 100644 --- a/src/composables/useLoad3dViewer.test.ts +++ b/src/composables/useLoad3dViewer.test.ts @@ -4,6 +4,7 @@ import { nextTick } from 'vue' import { useLoad3dViewer } from '@/composables/useLoad3dViewer' import Load3d from '@/extensions/core/load3d/Load3d' import Load3dUtils from '@/extensions/core/load3d/Load3dUtils' +import { createLoad3d } from '@/extensions/core/load3d/createLoad3d' import type { LGraph } from '@/lib/litegraph/src/LGraph' import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode' import { useToastStore } from '@/platform/updates/common/toastStore' @@ -20,7 +21,21 @@ vi.mock('@/platform/updates/common/toastStore', () => ({ vi.mock('@/extensions/core/load3d/Load3dUtils', () => ({ default: { - uploadFile: vi.fn() + uploadFile: vi.fn(), + splitFilePath: vi.fn((path: string) => { + const parts = path.split('/') + return [parts.slice(0, -1).join('/'), parts[parts.length - 1] ?? ''] + }), + getResourceURL: vi.fn( + (subfolder: string, filename: string, type: string) => + `api/view?type=${type}&subfolder=${encodeURIComponent(subfolder)}&filename=${filename}` + ) + } +})) + +vi.mock('@/scripts/api', () => ({ + api: { + apiURL: vi.fn((url: string) => `/${url}`) } })) @@ -32,6 +47,10 @@ vi.mock('@/extensions/core/load3d/Load3d', () => ({ default: vi.fn() })) +vi.mock('@/extensions/core/load3d/createLoad3d', () => ({ + createLoad3d: vi.fn() +})) + function createMockSceneManager(): Load3d['sceneManager'] { const mock: Partial<Load3d['sceneManager']> = { scene: {} as Load3d['sceneManager']['scene'], @@ -111,6 +130,15 @@ describe('useLoad3dViewer', () => { hasAnimations: vi.fn().mockReturnValue(false), isSplatModel: vi.fn().mockReturnValue(false), isPlyModel: vi.fn().mockReturnValue(false), + getCurrentModelCapabilities: vi.fn().mockReturnValue({ + fitToViewer: true, + requiresMaterialRebuild: false, + gizmoTransform: true, + lighting: true, + exportable: true, + materialModes: ['original', 'normal', 'wireframe'], + fitTargetSize: 5 + }), setGizmoEnabled: vi.fn(), setGizmoMode: vi.fn(), setBackgroundRenderMode: vi.fn(), @@ -148,6 +176,7 @@ describe('useLoad3dViewer', () => { vi.mocked(Load3d).mockImplementation(function () { Object.assign(this, mockLoad3d) }) + vi.mocked(createLoad3d).mockImplementation(() => mockLoad3d as Load3d) mockLoad3dService = { copyLoad3dState: vi.fn().mockResolvedValue(undefined), @@ -177,7 +206,7 @@ describe('useLoad3dViewer', () => { await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d) - expect(Load3d).toHaveBeenCalledWith(containerRef, { + expect(createLoad3d).toHaveBeenCalledWith(containerRef, { width: undefined, height: undefined, getDimensions: undefined, @@ -219,7 +248,7 @@ describe('useLoad3dViewer', () => { }) it('should handle initialization errors', async () => { - vi.mocked(Load3d).mockImplementationOnce(function () { + vi.mocked(createLoad3d).mockImplementationOnce(() => { throw new Error('Load3d creation failed') }) @@ -530,6 +559,78 @@ describe('useLoad3dViewer', () => { }) }) + describe('handleModelDrop', () => { + it('refreshes the capability refs after the dropped model loads, so the sidebar reflects the new model', async () => { + vi.mocked(Load3dUtils.uploadFile).mockResolvedValueOnce( + '3d/dropped.splat' + ) + + const viewer = useLoad3dViewer(mockNode) + const containerRef = document.createElement('div') + await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d) + + expect(viewer.canUseLighting.value).toBe(true) + expect(viewer.canUseGizmo.value).toBe(true) + expect(viewer.canExport.value).toBe(true) + expect([...viewer.materialModes.value]).toEqual([ + 'original', + 'normal', + 'wireframe' + ]) + + vi.mocked(mockLoad3d.isSplatModel!).mockReturnValueOnce(true) + vi.mocked(mockLoad3d.getCurrentModelCapabilities!).mockReturnValueOnce({ + fitToViewer: true, + requiresMaterialRebuild: false, + gizmoTransform: true, + lighting: false, + exportable: false, + materialModes: [], + fitTargetSize: 20 + }) + + const file = new File([''], 'dropped.splat') + await viewer.handleModelDrop(file) + + expect(mockLoad3d.loadModel).toHaveBeenCalledWith( + expect.stringContaining('dropped.splat') + ) + expect(viewer.canUseLighting.value).toBe(false) + expect(viewer.canExport.value).toBe(false) + expect(viewer.isSplatModel.value).toBe(true) + expect([...viewer.materialModes.value]).toEqual([]) + }) + + it('alerts and does not call loadModel when there is no active load3d instance', async () => { + const viewer = useLoad3dViewer(mockNode) + + const file = new File([''], 'whatever.glb') + await viewer.handleModelDrop(file) + + expect(mockToastStore.addAlert).toHaveBeenCalledWith( + 'toastMessages.no3dScene' + ) + expect(mockLoad3d.loadModel).not.toHaveBeenCalled() + }) + + it('alerts and skips loadModel when the file upload fails', async () => { + vi.mocked(Load3dUtils.uploadFile).mockResolvedValueOnce('') + + const viewer = useLoad3dViewer(mockNode) + const containerRef = document.createElement('div') + await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d) + vi.mocked(mockLoad3d.loadModel!).mockClear() + + const file = new File([''], 'whatever.glb') + await viewer.handleModelDrop(file) + + expect(mockToastStore.addAlert).toHaveBeenCalledWith( + 'toastMessages.fileUploadFailed' + ) + expect(mockLoad3d.loadModel).not.toHaveBeenCalled() + }) + }) + describe('cleanup', () => { it('should clean up resources', async () => { const viewer = useLoad3dViewer(mockNode) diff --git a/src/composables/useLoad3dViewer.ts b/src/composables/useLoad3dViewer.ts index f9126e4625..4de6ea8a54 100644 --- a/src/composables/useLoad3dViewer.ts +++ b/src/composables/useLoad3dViewer.ts @@ -1,8 +1,9 @@ import { ref, toRaw, watch } from 'vue' import QuickLRU from '@alloc/quick-lru' -import Load3d from '@/extensions/core/load3d/Load3d' +import type Load3d from '@/extensions/core/load3d/Load3d' import Load3dUtils from '@/extensions/core/load3d/Load3dUtils' +import { createLoad3d } from '@/extensions/core/load3d/createLoad3d' import type { AnimationItem, BackgroundRenderModeType, @@ -81,6 +82,26 @@ export const useLoad3dViewer = (node?: LGraphNode) => { const isStandaloneMode = ref(false) const isSplatModel = ref(false) const isPlyModel = ref(false) + const canFitToViewer = ref(true) + const canUseGizmo = ref(true) + const canUseLighting = ref(true) + const canExport = ref(true) + const materialModes = ref<readonly MaterialMode[]>([ + 'original', + 'normal', + 'wireframe' + ]) + + const captureAdapterFlags = (source: Load3d) => { + isSplatModel.value = source.isSplatModel() + isPlyModel.value = source.isPlyModel() + const caps = source.getCurrentModelCapabilities() + canFitToViewer.value = caps.fitToViewer + canUseGizmo.value = caps.gizmoTransform + canUseLighting.value = caps.lighting + canExport.value = caps.exportable + materialModes.value = caps.materialModes + } // Animation state const animations = ref<AnimationItem[]>([]) @@ -314,7 +335,7 @@ export const useLoad3dViewer = (node?: LGraphNode) => { const hasTargetDimensions = !!(width && height) - load3d = new Load3d(containerRef, { + load3d = createLoad3d(containerRef, { width: width ? (toRaw(width).value as number) : undefined, height: height ? (toRaw(height).value as number) : undefined, getDimensions: hasTargetDimensions @@ -394,8 +415,7 @@ export const useLoad3dViewer = (node?: LGraphNode) => { } } - isSplatModel.value = source.isSplatModel() - isPlyModel.value = source.isPlyModel() + captureAdapterFlags(source) initialState.value = { backgroundColor: backgroundColor.value, @@ -442,7 +462,7 @@ export const useLoad3dViewer = (node?: LGraphNode) => { isStandaloneMode.value = true - load3d = new Load3d(containerRef, { + load3d = createLoad3d(containerRef, { width: 800, height: 600, isViewerMode: true @@ -455,8 +475,7 @@ export const useLoad3dViewer = (node?: LGraphNode) => { await load3d.loadModel(modelUrl) currentModelUrl = modelUrl restoreStandaloneConfig(modelUrl) - isSplatModel.value = load3d.isSplatModel() - isPlyModel.value = load3d.isPlyModel() + captureAdapterFlags(load3d) isPreview.value = true @@ -479,8 +498,7 @@ export const useLoad3dViewer = (node?: LGraphNode) => { await load3d.loadModel(modelUrl) currentModelUrl = modelUrl restoreStandaloneConfig(modelUrl) - isSplatModel.value = load3d.isSplatModel() - isPlyModel.value = load3d.isPlyModel() + captureAdapterFlags(load3d) } catch (error) { console.error('Error loading model in standalone viewer:', error) useToastStore().addAlert('Failed to load 3D model') @@ -764,6 +782,8 @@ export const useLoad3dViewer = (node?: LGraphNode) => { await load3d.loadModel(modelUrl) + captureAdapterFlags(load3d) + const modelWidget = node?.widgets?.find((w) => w.name === 'model_file') if (modelWidget) { const options = modelWidget.options as { values?: string[] } | undefined @@ -811,6 +831,11 @@ export const useLoad3dViewer = (node?: LGraphNode) => { isStandaloneMode, isSplatModel, isPlyModel, + canFitToViewer, + canUseGizmo, + canUseLighting, + canExport, + materialModes, // Animation state animations, diff --git a/src/composables/useReconnectingNotification.test.ts b/src/composables/useReconnectingNotification.test.ts index d8485231a2..583c94f6c6 100644 --- a/src/composables/useReconnectingNotification.test.ts +++ b/src/composables/useReconnectingNotification.test.ts @@ -1,6 +1,9 @@ import { createTestingPinia } from '@pinia/testing' +import { render } from '@testing-library/vue' import { setActivePinia } from 'pinia' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { defineComponent } from 'vue' +import { createI18n } from 'vue-i18n' import { useReconnectingNotification } from '@/composables/useReconnectingNotification' @@ -14,11 +17,29 @@ vi.mock('primevue/usetoast', () => ({ }) })) -vi.mock('vue-i18n', () => ({ - useI18n: () => ({ - t: (key: string) => key +function setupComposable(): ReturnType<typeof useReconnectingNotification> { + const i18n = createI18n({ + legacy: false, + locale: 'en', + messages: { + en: { + g: { + reconnecting: 'Reconnecting', + reconnected: 'Reconnected' + } + } + } }) -})) + let result!: ReturnType<typeof useReconnectingNotification> + const Wrapper = defineComponent({ + setup() { + result = useReconnectingNotification() + return () => null + } + }) + render(Wrapper, { global: { plugins: [i18n] } }) + return result +} const settingMocks = vi.hoisted(() => ({ disableToast: false @@ -47,7 +68,7 @@ describe('useReconnectingNotification', () => { }) it('does not show toast immediately on reconnecting', () => { - const { onReconnecting } = useReconnectingNotification() + const { onReconnecting } = setupComposable() onReconnecting() @@ -55,7 +76,7 @@ describe('useReconnectingNotification', () => { }) it('shows error toast after delay', () => { - const { onReconnecting } = useReconnectingNotification() + const { onReconnecting } = setupComposable() onReconnecting() vi.advanceTimersByTime(1500) @@ -63,13 +84,13 @@ describe('useReconnectingNotification', () => { expect(mockToastAdd).toHaveBeenCalledWith( expect.objectContaining({ severity: 'error', - summary: 'g.reconnecting' + summary: 'Reconnecting' }) ) }) it('suppresses toast when reconnected before delay expires', () => { - const { onReconnecting, onReconnected } = useReconnectingNotification() + const { onReconnecting, onReconnected } = setupComposable() onReconnecting() vi.advanceTimersByTime(500) @@ -81,7 +102,7 @@ describe('useReconnectingNotification', () => { }) it('removes toast and shows success when reconnected after delay', () => { - const { onReconnecting, onReconnected } = useReconnectingNotification() + const { onReconnecting, onReconnected } = setupComposable() onReconnecting() vi.advanceTimersByTime(1500) @@ -92,13 +113,13 @@ describe('useReconnectingNotification', () => { expect(mockToastRemove).toHaveBeenCalledWith( expect.objectContaining({ severity: 'error', - summary: 'g.reconnecting' + summary: 'Reconnecting' }) ) expect(mockToastAdd).toHaveBeenCalledWith( expect.objectContaining({ severity: 'success', - summary: 'g.reconnected', + summary: 'Reconnected', life: 2000 }) ) @@ -106,7 +127,7 @@ describe('useReconnectingNotification', () => { it('does nothing when toast is disabled via setting', () => { settingMocks.disableToast = true - const { onReconnecting, onReconnected } = useReconnectingNotification() + const { onReconnecting, onReconnected } = setupComposable() onReconnecting() vi.advanceTimersByTime(1500) @@ -117,7 +138,7 @@ describe('useReconnectingNotification', () => { }) it('does nothing when onReconnected is called without prior onReconnecting', () => { - const { onReconnected } = useReconnectingNotification() + const { onReconnected } = setupComposable() onReconnected() @@ -126,7 +147,7 @@ describe('useReconnectingNotification', () => { }) it('handles multiple reconnecting events without duplicating toasts', () => { - const { onReconnecting } = useReconnectingNotification() + const { onReconnecting } = setupComposable() onReconnecting() vi.advanceTimersByTime(1500) // first toast fires diff --git a/src/core/graph/subgraph/promotedWidgetTypes.ts b/src/core/graph/subgraph/promotedWidgetTypes.ts index a1e52f6a5e..69f28879d9 100644 --- a/src/core/graph/subgraph/promotedWidgetTypes.ts +++ b/src/core/graph/subgraph/promotedWidgetTypes.ts @@ -24,8 +24,6 @@ export interface PromotedWidgetView extends IBaseWidget { * origin. */ readonly disambiguatingSourceNodeId?: string - /** Whether the resolved source widget is workflow-persistent. */ - readonly sourceSerialize: boolean } export function isPromotedWidgetView( diff --git a/src/core/graph/subgraph/promotedWidgetView.ts b/src/core/graph/subgraph/promotedWidgetView.ts index f7e5ee1789..610ba4cbec 100644 --- a/src/core/graph/subgraph/promotedWidgetView.ts +++ b/src/core/graph/subgraph/promotedWidgetView.ts @@ -1,6 +1,3 @@ -import { isEqual } from 'es-toolkit' - -import type { LGraph } from '@/lib/litegraph/src/LGraph' import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode' import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas' import type { CanvasPointer } from '@/lib/litegraph/src/CanvasPointer' @@ -53,43 +50,6 @@ function hasLegacyMouse(widget: IBaseWidget): widget is LegacyMouseWidget { } const designTokenCache = new Map<string, string>() -const promotedSourceWriteMetaByGraph = new WeakMap< - LGraph, - Map<string, PromotedSourceWriteMeta> ->() - -interface PromotedSourceWriteMeta { - value: IBaseWidget['value'] - writerInstanceId: string -} - -function cloneWidgetValue<TValue extends IBaseWidget['value']>( - value: TValue -): TValue { - return value != null && typeof value === 'object' - ? (JSON.parse(JSON.stringify(value)) as TValue) - : value -} - -function getPromotedSourceWriteMeta( - graph: LGraph, - sourceKey: string -): PromotedSourceWriteMeta | undefined { - return promotedSourceWriteMetaByGraph.get(graph)?.get(sourceKey) -} - -function setPromotedSourceWriteMeta( - graph: LGraph, - sourceKey: string, - meta: PromotedSourceWriteMeta -): void { - let metaBySource = promotedSourceWriteMetaByGraph.get(graph) - if (!metaBySource) { - metaBySource = new Map<string, PromotedSourceWriteMeta>() - promotedSourceWriteMetaByGraph.set(graph, metaBySource) - } - metaBySource.set(sourceKey, meta) -} export function createPromotedWidgetView( subgraphNode: SubgraphNode, @@ -117,15 +77,6 @@ class PromotedWidgetView implements IPromotedWidgetView { readonly serialize = false - /** - * Whether the resolved source widget is workflow-persistent. - * Used by SubgraphNode.serialize to skip preview/audio/video widgets - * whose source sets serialize = false. - */ - get sourceSerialize(): boolean { - return this.resolveDeepest()?.widget.serialize !== false - } - last_y?: number computedHeight?: number @@ -198,52 +149,13 @@ class PromotedWidgetView implements IPromotedWidgetView { return this.resolveDeepest()?.widget.linkedWidgets } - private get _instanceKey(): string { - return this.disambiguatingSourceNodeId - ? `${this.sourceNodeId}:${this.sourceWidgetName}:${this.disambiguatingSourceNodeId}` - : `${this.sourceNodeId}:${this.sourceWidgetName}` - } - - private get _sharedSourceKey(): string { - return this.disambiguatingSourceNodeId - ? `${this.subgraphNode.subgraph.id}:${this.sourceNodeId}:${this.sourceWidgetName}:${this.disambiguatingSourceNodeId}` - : `${this.subgraphNode.subgraph.id}:${this.sourceNodeId}:${this.sourceWidgetName}` - } - get value(): IBaseWidget['value'] { - return this.getTrackedValue() - } - - /** - * Execution-time serialization — returns the per-instance value stored - * during configure, falling back to the regular value getter. - * - * The widget state store is shared across instances (keyed by inner node - * ID), so the regular getter returns the last-configured value for all - * instances. graphToPrompt already prefers serializeValue over .value, - * so this is the hook that makes multi-instance execution correct. - */ - serializeValue(): IBaseWidget['value'] { - return this.getTrackedValue() + const state = this.getWidgetState() + if (state && isWidgetValue(state.value)) return state.value + return this.resolveAtHost()?.widget.value } set value(value: IBaseWidget['value']) { - this.captureSiblingFallbackValues() - - // Keep per-instance map in sync for execution (graphToPrompt) - this.subgraphNode._instanceWidgetValues.set( - this._instanceKey, - cloneWidgetValue(value) - ) - setPromotedSourceWriteMeta( - this.subgraphNode.rootGraph, - this._sharedSourceKey, - { - value: cloneWidgetValue(value), - writerInstanceId: String(this.subgraphNode.id) - } - ) - const linkedWidgets = this.getLinkedInputWidgets() if (linkedWidgets.length > 0) { const widgetStore = useWidgetValueStore() @@ -473,39 +385,6 @@ class PromotedWidgetView implements IPromotedWidgetView { return resolved } - private getTrackedValue(): IBaseWidget['value'] { - const instanceValue = this.subgraphNode._instanceWidgetValues.get( - this._instanceKey - ) - const sharedValue = this.getSharedValue() - - if (instanceValue === undefined) return sharedValue - - const sourceWriteMeta = getPromotedSourceWriteMeta( - this.subgraphNode.rootGraph, - this._sharedSourceKey - ) - if ( - sharedValue !== undefined && - sourceWriteMeta && - !isEqual(sharedValue, sourceWriteMeta.value) - ) { - this.subgraphNode._instanceWidgetValues.set( - this._instanceKey, - cloneWidgetValue(sharedValue) - ) - return sharedValue - } - - return instanceValue as IBaseWidget['value'] - } - - private getSharedValue(): IBaseWidget['value'] { - const state = this.getWidgetState() - if (state && isWidgetValue(state.value)) return state.value - return this.resolveAtHost()?.widget.value - } - private getWidgetState() { const linkedState = this.getLinkedInputWidgetStates()[0] if (linkedState) return linkedState @@ -572,30 +451,6 @@ class PromotedWidgetView implements IPromotedWidgetView { .filter((state): state is WidgetState => state !== undefined) } - private captureSiblingFallbackValues(): void { - const { rootGraph } = this.subgraphNode - - for (const node of rootGraph.nodes) { - if (node === this.subgraphNode || !node.isSubgraphNode()) continue - if (node.subgraph.id !== this.subgraphNode.subgraph.id) continue - if (node._instanceWidgetValues.has(this._instanceKey)) continue - - const siblingView = node.widgets.find( - (widget): widget is IPromotedWidgetView => - isPromotedWidgetView(widget) && - widget.sourceNodeId === this.sourceNodeId && - widget.sourceWidgetName === this.sourceWidgetName && - widget.disambiguatingSourceNodeId === this.disambiguatingSourceNodeId - ) - if (!siblingView) continue - - node._instanceWidgetValues.set( - this._instanceKey, - cloneWidgetValue(siblingView.value) - ) - } - } - private getProjectedWidget(resolved: { node: LGraphNode widget: IBaseWidget diff --git a/src/core/graph/subgraph/subgraphNodePromotion.test.ts b/src/core/graph/subgraph/subgraphNodePromotion.test.ts index a6aaba7f58..b0203fe4b7 100644 --- a/src/core/graph/subgraph/subgraphNodePromotion.test.ts +++ b/src/core/graph/subgraph/subgraphNodePromotion.test.ts @@ -253,7 +253,7 @@ describe('Subgraph proxyWidgets', () => { expect(subgraphNode.widgets).toHaveLength(0) }) - test('serialize stores widgets_values for promoted views', () => { + test('serialize does not produce widgets_values for promoted views', () => { const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1) innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {}) usePromotionStore().setPromotions( @@ -265,7 +265,9 @@ describe('Subgraph proxyWidgets', () => { const serialized = subgraphNode.serialize() - expect(serialized.widgets_values).toEqual(['value']) + // 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() }) test('serialize preserves proxyWidgets in properties', () => { diff --git a/src/extensions/core/load3d.ts b/src/extensions/core/load3d.ts index 2c1cff51d3..0a0fe143a2 100644 --- a/src/extensions/core/load3d.ts +++ b/src/extensions/core/load3d.ts @@ -19,8 +19,9 @@ import { useToastStore } from '@/platform/updates/common/toastStore' import type { NodeOutputWith } from '@/schemas/apiSchema' import type { ComfyNodeDef } from '@/schemas/nodeDefSchema' +type Matrix = number[][] type Load3dPreviewOutput = NodeOutputWith<{ - result?: [string?, CameraState?, string?] + result?: [string?, CameraState?, string?, Matrix?, Matrix?] }> import type { CustomInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2' import { api } from '@/scripts/api' @@ -496,7 +497,8 @@ useExtensionService().registerExtension({ const settings = { loadFolder: 'output', modelWidget: modelWidget, - cameraState: cameraState + cameraState: cameraState, + silentOnNotFound: true } config.configure(settings) @@ -516,6 +518,8 @@ useExtensionService().registerExtension({ const cameraState = result?.[1] const bgImagePath = result?.[2] + const extrinsics = result?.[3] + const intrinsics = result?.[4] modelWidget.value = filePath?.replaceAll('\\', '/') @@ -525,7 +529,8 @@ useExtensionService().registerExtension({ loadFolder: 'output', modelWidget: modelWidget, cameraState: cameraState, - bgImagePath: bgImagePath + bgImagePath: bgImagePath, + silentOnNotFound: true } config.configure(settings) @@ -533,6 +538,27 @@ useExtensionService().registerExtension({ if (bgImagePath) { load3d.setBackgroundImage(bgImagePath) } + + if (filePath && extrinsics && intrinsics) { + // configure(settings) above triggered loadModel for this + // execution; capture its generation so that if a newer + // execution queues another load before whenLoadIdle resolves, + // we don't apply this execution's matrices on top of that + // newer model. + const targetGeneration = load3d.currentLoadGeneration + void load3d + .whenLoadIdle() + .then(() => { + if (load3d.currentLoadGeneration !== targetGeneration) return + load3d.setCameraFromMatrices(extrinsics, intrinsics) + }) + .catch((error) => { + console.error( + 'Failed to apply camera matrices from Preview3D output:', + error + ) + }) + } } } }) diff --git a/src/extensions/core/load3d/AnimationManager.test.ts b/src/extensions/core/load3d/AnimationManager.test.ts new file mode 100644 index 0000000000..7607b490ce --- /dev/null +++ b/src/extensions/core/load3d/AnimationManager.test.ts @@ -0,0 +1,324 @@ +import * as THREE from 'three' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { AnimationManager } from './AnimationManager' +import type { EventManagerInterface } from './interfaces' + +function makeMockEventManager() { + return { + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + emitEvent: vi.fn() + } satisfies EventManagerInterface +} + +function makeClip(name: string, duration: number): THREE.AnimationClip { + return new THREE.AnimationClip(name, duration, []) +} + +function makeAnimatedModel( + clips: THREE.AnimationClip[] = [] +): THREE.Object3D & { animations: THREE.AnimationClip[] } { + const obj = new THREE.Object3D() as THREE.Object3D & { + animations: THREE.AnimationClip[] + } + obj.animations = clips + return obj +} + +describe('AnimationManager', () => { + let events: ReturnType<typeof makeMockEventManager> + let manager: AnimationManager + + beforeEach(() => { + vi.clearAllMocks() + events = makeMockEventManager() + manager = new AnimationManager(events) + }) + + describe('setupModelAnimations', () => { + it('creates a mixer and selects the first clip when the model has animations', () => { + const clips = [makeClip('walk', 2), makeClip('run', 3)] + const model = makeAnimatedModel(clips) + + manager.setupModelAnimations(model, null) + + expect(manager.currentAnimation).not.toBeNull() + expect(manager.animationClips).toEqual(clips) + expect(manager.selectedAnimationIndex).toBe(0) + expect(manager.animationActions).toHaveLength(1) + }) + + it('falls back to originalModel.animations when the model itself has none', () => { + const clips = [makeClip('idle', 1.5)] + const model = makeAnimatedModel([]) + const originalModel = { animations: clips } as unknown as THREE.Object3D + + manager.setupModelAnimations(model, originalModel) + + expect(manager.animationClips).toEqual(clips) + expect(manager.currentAnimation).not.toBeNull() + }) + + it('emits the localized animation list with default names when clips are unnamed', () => { + const clips = [makeClip('', 1), makeClip('named', 1)] + const model = makeAnimatedModel(clips) + + manager.setupModelAnimations(model, null) + + expect(events.emitEvent).toHaveBeenCalledWith('animationListChange', [ + { name: 'Animation 1', index: 0 }, + { name: 'named', index: 1 } + ]) + }) + + it('emits an empty list and leaves no actions when neither source has animations', () => { + const model = makeAnimatedModel([]) + + manager.setupModelAnimations(model, null) + + expect(manager.animationClips).toEqual([]) + expect(manager.animationActions).toEqual([]) + expect(events.emitEvent).toHaveBeenLastCalledWith( + 'animationListChange', + [] + ) + }) + + it('stops previously running actions before loading a new model', () => { + const firstClips = [makeClip('a', 1)] + manager.setupModelAnimations(makeAnimatedModel(firstClips), null) + const firstAction = manager.animationActions[0] + const stopSpy = vi.spyOn(firstAction, 'stop') + + const secondClips = [makeClip('b', 1)] + manager.setupModelAnimations(makeAnimatedModel(secondClips), null) + + expect(stopSpy).toHaveBeenCalled() + expect(manager.animationClips).toEqual(secondClips) + }) + }) + + describe('updateSelectedAnimation', () => { + it('warns and does nothing when called before any setup', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + manager.updateSelectedAnimation(0) + + expect(warn).toHaveBeenCalled() + expect(manager.animationActions).toEqual([]) + warn.mockRestore() + }) + + it('warns when the index is out of bounds', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + manager.setupModelAnimations( + makeAnimatedModel([makeClip('only', 1)]), + null + ) + + manager.updateSelectedAnimation(5) + + expect(warn).toHaveBeenCalled() + warn.mockRestore() + }) + + it('switches to the requested clip and emits an initial progress event', () => { + const clips = [makeClip('a', 2), makeClip('b', 4)] + manager.setupModelAnimations(makeAnimatedModel(clips), null) + events.emitEvent.mockClear() + + manager.updateSelectedAnimation(1) + + expect(manager.selectedAnimationIndex).toBe(1) + expect(manager.animationActions).toHaveLength(1) + expect(events.emitEvent).toHaveBeenCalledWith('animationProgressChange', { + progress: 0, + currentTime: 0, + duration: 4 + }) + }) + + it('starts the action paused when the manager is not currently playing', () => { + const clips = [makeClip('a', 2)] + manager.setupModelAnimations(makeAnimatedModel(clips), null) + + const action = manager.animationActions[0] + expect(action.paused).toBe(true) + }) + + it('starts the action running when the manager is already playing', () => { + const clips = [makeClip('a', 2), makeClip('b', 2)] + manager.setupModelAnimations(makeAnimatedModel(clips), null) + manager.toggleAnimation(true) + + manager.updateSelectedAnimation(1) + + expect(manager.animationActions[0].paused).toBe(false) + }) + }) + + describe('toggleAnimation', () => { + it('warns and is a no-op when there is no animation loaded', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + manager.toggleAnimation(true) + + expect(warn).toHaveBeenCalled() + expect(manager.isAnimationPlaying).toBe(false) + warn.mockRestore() + }) + + it('flips the playing state when called without an explicit value', () => { + manager.setupModelAnimations(makeAnimatedModel([makeClip('a', 1)]), null) + + manager.toggleAnimation() + expect(manager.isAnimationPlaying).toBe(true) + manager.toggleAnimation() + expect(manager.isAnimationPlaying).toBe(false) + }) + + it('resets time to zero when starting from the end of the clip', () => { + manager.setupModelAnimations(makeAnimatedModel([makeClip('a', 2)]), null) + const action = manager.animationActions[0] + action.time = action.getClip().duration + + manager.toggleAnimation(true) + + expect(action.time).toBe(0) + expect(action.paused).toBe(false) + }) + }) + + describe('setAnimationSpeed', () => { + it('records the speed and propagates it to all current actions', () => { + manager.setupModelAnimations(makeAnimatedModel([makeClip('a', 1)]), null) + const action = manager.animationActions[0] + const setEffectiveTimeScale = vi.spyOn(action, 'setEffectiveTimeScale') + + manager.setAnimationSpeed(2.5) + + expect(manager.animationSpeed).toBe(2.5) + expect(setEffectiveTimeScale).toHaveBeenCalledWith(2.5) + }) + }) + + describe('setAnimationTime', () => { + it('clamps the requested time to [0, duration]', () => { + manager.setupModelAnimations(makeAnimatedModel([makeClip('a', 4)]), null) + events.emitEvent.mockClear() + + manager.setAnimationTime(-5) + expect(events.emitEvent).toHaveBeenLastCalledWith( + 'animationProgressChange', + { progress: 0, currentTime: 0, duration: 4 } + ) + + manager.setAnimationTime(99) + expect(events.emitEvent).toHaveBeenLastCalledWith( + 'animationProgressChange', + { progress: 100, currentTime: 4, duration: 4 } + ) + }) + + it('preserves the paused state across the seek', () => { + manager.setupModelAnimations(makeAnimatedModel([makeClip('a', 4)]), null) + const action = manager.animationActions[0] + action.paused = true + + manager.setAnimationTime(2) + + expect(action.paused).toBe(true) + expect(action.time).toBe(2) + }) + + it('emits a progress event reflecting the seek target', () => { + manager.setupModelAnimations(makeAnimatedModel([makeClip('a', 4)]), null) + events.emitEvent.mockClear() + + manager.setAnimationTime(1) + + expect(events.emitEvent).toHaveBeenCalledWith('animationProgressChange', { + progress: 25, + currentTime: 1, + duration: 4 + }) + }) + + it('is a no-op when no actions are loaded', () => { + expect(() => manager.setAnimationTime(1)).not.toThrow() + expect(events.emitEvent).not.toHaveBeenCalledWith( + 'animationProgressChange', + expect.anything() + ) + }) + }) + + describe('update', () => { + it('does not advance the mixer when not playing', () => { + manager.setupModelAnimations(makeAnimatedModel([makeClip('a', 4)]), null) + const updateSpy = vi.spyOn(manager.currentAnimation!, 'update') + + manager.update(0.5) + + expect(updateSpy).not.toHaveBeenCalled() + }) + + it('advances the mixer and emits progress while playing', () => { + manager.setupModelAnimations(makeAnimatedModel([makeClip('a', 4)]), null) + manager.toggleAnimation(true) + const updateSpy = vi.spyOn(manager.currentAnimation!, 'update') + events.emitEvent.mockClear() + + manager.update(0.25) + + expect(updateSpy).toHaveBeenCalledWith(0.25) + expect(events.emitEvent).toHaveBeenCalledWith( + 'animationProgressChange', + expect.objectContaining({ duration: 4 }) + ) + }) + }) + + describe('getters', () => { + it('return zero when nothing is loaded', () => { + expect(manager.getAnimationTime()).toBe(0) + expect(manager.getAnimationDuration()).toBe(0) + }) + + it('reflect the current action time and clip duration', () => { + manager.setupModelAnimations(makeAnimatedModel([makeClip('a', 7)]), null) + + expect(manager.getAnimationDuration()).toBe(7) + manager.animationActions[0].time = 3 + expect(manager.getAnimationTime()).toBe(3) + }) + }) + + describe('dispose', () => { + it('stops all actions, clears state, and emits an empty list', () => { + manager.setupModelAnimations( + makeAnimatedModel([makeClip('a', 1), makeClip('b', 1)]), + null + ) + manager.toggleAnimation(true) + manager.setAnimationSpeed(2) + manager.selectedAnimationIndex = 1 + const stopSpies = manager.animationActions.map((action) => + vi.spyOn(action, 'stop') + ) + events.emitEvent.mockClear() + + manager.dispose() + + stopSpies.forEach((spy) => expect(spy).toHaveBeenCalled()) + expect(manager.currentAnimation).toBeNull() + expect(manager.animationActions).toEqual([]) + expect(manager.animationClips).toEqual([]) + expect(manager.selectedAnimationIndex).toBe(0) + expect(manager.isAnimationPlaying).toBe(false) + expect(manager.animationSpeed).toBe(1.0) + expect(events.emitEvent).toHaveBeenCalledWith('animationListChange', []) + }) + }) +}) diff --git a/src/extensions/core/load3d/CameraManager.test.ts b/src/extensions/core/load3d/CameraManager.test.ts new file mode 100644 index 0000000000..c25b1dfe63 --- /dev/null +++ b/src/extensions/core/load3d/CameraManager.test.ts @@ -0,0 +1,233 @@ +import * as THREE from 'three' +import type { OrbitControls } from 'three/examples/jsm/controls/OrbitControls' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { CameraManager } from './CameraManager' +import type { CameraState, EventManagerInterface } from './interfaces' + +function makeMockEventManager() { + return { + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + emitEvent: vi.fn() + } satisfies EventManagerInterface +} + +type ControlsListener = () => void + +function makeControlsStub() { + const listeners: Record<string, ControlsListener[]> = {} + return { + target: new THREE.Vector3(), + object: null as THREE.Camera | null, + update: vi.fn(), + addEventListener: vi.fn((event: string, cb: ControlsListener) => { + listeners[event] = listeners[event] ?? [] + listeners[event].push(cb) + }), + fire(event: string) { + listeners[event]?.forEach((cb) => cb()) + } + } +} + +function makeRenderer(): THREE.WebGLRenderer { + // CameraManager only stores `_renderer` but never reads it. An empty object + // suffices and avoids needing a WebGL context in happy-dom. + return {} as THREE.WebGLRenderer +} + +describe('CameraManager', () => { + let events: ReturnType<typeof makeMockEventManager> + let manager: CameraManager + + beforeEach(() => { + vi.clearAllMocks() + events = makeMockEventManager() + manager = new CameraManager(makeRenderer(), events) + }) + + describe('construction', () => { + it('creates both cameras and starts in perspective mode at the default position', () => { + expect(manager.perspectiveCamera).toBeInstanceOf(THREE.PerspectiveCamera) + expect(manager.orthographicCamera).toBeInstanceOf( + THREE.OrthographicCamera + ) + expect(manager.activeCamera).toBe(manager.perspectiveCamera) + expect(manager.getCurrentCameraType()).toBe('perspective') + expect(manager.perspectiveCamera.position.toArray()).toEqual([10, 10, 10]) + }) + }) + + describe('toggleCamera', () => { + it('without an argument flips between perspective and orthographic', () => { + manager.toggleCamera() + expect(manager.getCurrentCameraType()).toBe('orthographic') + manager.toggleCamera() + expect(manager.getCurrentCameraType()).toBe('perspective') + }) + + it('with an explicit type switches to that type', () => { + manager.toggleCamera('orthographic') + expect(manager.activeCamera).toBe(manager.orthographicCamera) + }) + + it('is a no-op when explicitly switched to the active type', () => { + const before = manager.activeCamera + manager.toggleCamera('perspective') + expect(manager.activeCamera).toBe(before) + expect(events.emitEvent).not.toHaveBeenCalledWith( + 'cameraTypeChange', + 'perspective' + ) + }) + + it('copies position, rotation, and zoom from the old camera to the new one', () => { + manager.perspectiveCamera.position.set(1, 2, 3) + manager.perspectiveCamera.rotation.set(0.1, 0.2, 0.3) + manager.perspectiveCamera.zoom = 1.5 + + manager.toggleCamera('orthographic') + + expect(manager.orthographicCamera.position.toArray()).toEqual([1, 2, 3]) + expect(manager.orthographicCamera.zoom).toBe(1.5) + }) + + it('emits cameraTypeChange with the requested type', () => { + manager.toggleCamera('orthographic') + expect(events.emitEvent).toHaveBeenCalledWith( + 'cameraTypeChange', + 'orthographic' + ) + }) + + it('rebinds the controls object and target after switching', () => { + const controls = makeControlsStub() + controls.target.set(5, 6, 7) + manager.setControls(controls as unknown as OrbitControls) + + manager.toggleCamera('orthographic') + + expect(controls.object).toBe(manager.orthographicCamera) + expect(controls.target.toArray()).toEqual([5, 6, 7]) + expect(controls.update).toHaveBeenCalled() + }) + }) + + describe('setFOV', () => { + it('updates the perspective FOV when perspective is active and emits the value', () => { + manager.setFOV(60) + + expect(manager.perspectiveCamera.fov).toBe(60) + expect(events.emitEvent).toHaveBeenCalledWith('fovChange', 60) + }) + + it('does not modify the perspective FOV when orthographic is active', () => { + manager.toggleCamera('orthographic') + events.emitEvent.mockClear() + const before = manager.perspectiveCamera.fov + + manager.setFOV(99) + + expect(manager.perspectiveCamera.fov).toBe(before) + expect(events.emitEvent).toHaveBeenCalledWith('fovChange', 99) + }) + }) + + describe('camera state round-trip', () => { + it('captures and restores position, target, zoom, and type', () => { + const controls = makeControlsStub() + controls.target.set(2, 3, 4) + manager.setControls(controls as unknown as OrbitControls) + manager.perspectiveCamera.position.set(7, 8, 9) + manager.perspectiveCamera.zoom = 2 + + const snapshot = manager.getCameraState() + + expect(snapshot.position.toArray()).toEqual([7, 8, 9]) + expect(snapshot.target.toArray()).toEqual([2, 3, 4]) + expect(snapshot.zoom).toBe(2) + expect(snapshot.cameraType).toBe('perspective') + + manager.perspectiveCamera.position.set(0, 0, 0) + manager.perspectiveCamera.zoom = 1 + manager.setCameraState(snapshot) + + expect(manager.perspectiveCamera.position.toArray()).toEqual([7, 8, 9]) + expect(manager.perspectiveCamera.zoom).toBe(2) + expect(controls.target.toArray()).toEqual([2, 3, 4]) + }) + + it('returns a default target when no controls are attached', () => { + const snapshot = manager.getCameraState() + expect(snapshot.target.toArray()).toEqual([0, 0, 0]) + }) + }) + + describe('setControls', () => { + it('emits cameraChanged when the controls fire their end event', () => { + const controls = makeControlsStub() + manager.setControls(controls as unknown as OrbitControls) + events.emitEvent.mockClear() + + controls.fire('end') + + expect(events.emitEvent).toHaveBeenCalledWith( + 'cameraChanged', + expect.objectContaining({ + cameraType: 'perspective' + }) satisfies Partial<CameraState> + ) + }) + }) + + describe('handleResize', () => { + it('updates perspective aspect when perspective is active', () => { + manager.handleResize(800, 400) + expect(manager.perspectiveCamera.aspect).toBeCloseTo(2) + }) + + it('updates orthographic frustum bounds when orthographic is active', () => { + manager.toggleCamera('orthographic') + + manager.handleResize(800, 400) + + const cam = manager.orthographicCamera + const aspect = 2 + const frustumSize = 10 + expect(cam.left).toBeCloseTo((-frustumSize * aspect) / 2) + expect(cam.right).toBeCloseTo((frustumSize * aspect) / 2) + expect(cam.top).toBeCloseTo(frustumSize / 2) + expect(cam.bottom).toBeCloseTo(-frustumSize / 2) + }) + }) + + describe('setupForModel', () => { + it('positions both cameras based on the model size and centers controls on the target', () => { + const controls = makeControlsStub() + manager.setControls(controls as unknown as OrbitControls) + + const size = new THREE.Vector3(2, 4, 2) + manager.setupForModel(size) + + expect(manager.perspectiveCamera.position.toArray()).toEqual([4, 6, 4]) + expect(manager.orthographicCamera.position.toArray()).toEqual([4, 6, 4]) + expect(controls.target.toArray()).toEqual([0, 2, 0]) + expect(controls.update).toHaveBeenCalled() + }) + }) + + describe('reset', () => { + it('returns both cameras to the default starting position', () => { + manager.perspectiveCamera.position.set(99, 99, 99) + manager.orthographicCamera.position.set(99, 99, 99) + + manager.reset() + + expect(manager.perspectiveCamera.position.toArray()).toEqual([10, 10, 10]) + expect(manager.orthographicCamera.position.toArray()).toEqual([ + 10, 10, 10 + ]) + }) + }) +}) diff --git a/src/extensions/core/load3d/ControlsManager.test.ts b/src/extensions/core/load3d/ControlsManager.test.ts new file mode 100644 index 0000000000..0769f9c2dd --- /dev/null +++ b/src/extensions/core/load3d/ControlsManager.test.ts @@ -0,0 +1,168 @@ +import * as THREE from 'three' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { ControlsManager } from './ControlsManager' +import type { EventManagerInterface } from './interfaces' + +const { mockOrbitControls } = vi.hoisted(() => ({ + mockOrbitControls: vi.fn() +})) + +vi.mock('three/examples/jsm/controls/OrbitControls', () => { + type Listener = () => void + class OrbitControls { + object: THREE.Camera + domElement: HTMLElement + enableDamping = false + target = new THREE.Vector3() + update = vi.fn() + dispose = vi.fn() + private listeners = new Map<string, Listener[]>() + constructor(camera: THREE.Camera, domElement: HTMLElement) { + this.object = camera + this.domElement = domElement + mockOrbitControls(camera, domElement) + } + addEventListener(event: string, cb: Listener) { + if (!this.listeners.has(event)) this.listeners.set(event, []) + this.listeners.get(event)!.push(cb) + } + fire(event: string) { + this.listeners.get(event)?.forEach((cb) => cb()) + } + } + return { OrbitControls } +}) + +function makeMockEventManager() { + return { + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + emitEvent: vi.fn() + } satisfies EventManagerInterface +} + +function makeRenderer(opts: { withParent?: boolean } = {}) { + const canvas = document.createElement('canvas') + if (opts.withParent) { + const parent = document.createElement('div') + parent.appendChild(canvas) + } + return { domElement: canvas } as unknown as THREE.WebGLRenderer +} + +describe('ControlsManager', () => { + let events: ReturnType<typeof makeMockEventManager> + let camera: THREE.PerspectiveCamera + let manager: ControlsManager + + beforeEach(() => { + vi.clearAllMocks() + events = makeMockEventManager() + camera = new THREE.PerspectiveCamera() + }) + + describe('construction', () => { + it('attaches OrbitControls to the canvas parent when one exists', () => { + const renderer = makeRenderer({ withParent: true }) + + manager = new ControlsManager(renderer, camera, events) + + expect(mockOrbitControls).toHaveBeenCalledWith( + camera, + renderer.domElement.parentElement + ) + expect(manager.controls.enableDamping).toBe(true) + }) + + it('falls back to the canvas itself when there is no parent', () => { + const renderer = makeRenderer({ withParent: false }) + + manager = new ControlsManager(renderer, camera, events) + + expect(mockOrbitControls).toHaveBeenCalledWith( + camera, + renderer.domElement + ) + }) + }) + + describe('init', () => { + it('emits cameraChanged with a perspective state when the controls fire end', () => { + manager = new ControlsManager(makeRenderer(), camera, events) + camera.position.set(1, 2, 3) + camera.zoom = 1.25 + manager.controls.target.set(4, 5, 6) + manager.init() + + ;(manager.controls as unknown as { fire(e: string): void }).fire('end') + + expect(events.emitEvent).toHaveBeenCalledWith('cameraChanged', { + position: expect.objectContaining({ x: 1, y: 2, z: 3 }), + target: expect.objectContaining({ x: 4, y: 5, z: 6 }), + zoom: 1.25, + cameraType: 'perspective' + }) + }) + + it('reports orthographic camera type when initialized with one', () => { + const ortho = new THREE.OrthographicCamera() + ortho.zoom = 0.5 + manager = new ControlsManager(makeRenderer(), ortho, events) + manager.init() + + ;(manager.controls as unknown as { fire(e: string): void }).fire('end') + + expect(events.emitEvent).toHaveBeenCalledWith( + 'cameraChanged', + expect.objectContaining({ cameraType: 'orthographic', zoom: 0.5 }) + ) + }) + }) + + describe('updateCamera', () => { + it('rebinds controls to the new camera, copies position from the previous one, and preserves the target', () => { + manager = new ControlsManager(makeRenderer(), camera, events) + camera.position.set(7, 8, 9) + manager.controls.target.set(1, 1, 1) + + const newCamera = new THREE.PerspectiveCamera() + manager.updateCamera(newCamera) + + expect(manager.controls.object).toBe(newCamera) + expect(newCamera.position.toArray()).toEqual([7, 8, 9]) + expect(manager.controls.target.toArray()).toEqual([1, 1, 1]) + expect(manager.controls.update).toHaveBeenCalled() + }) + }) + + describe('update / reset', () => { + it('update delegates to controls.update', () => { + manager = new ControlsManager(makeRenderer(), camera, events) + + manager.update() + + expect(manager.controls.update).toHaveBeenCalled() + }) + + it('reset clears the target back to the origin and refreshes', () => { + manager = new ControlsManager(makeRenderer(), camera, events) + manager.controls.target.set(5, 6, 7) + + manager.reset() + + expect(manager.controls.target.toArray()).toEqual([0, 0, 0]) + expect(manager.controls.update).toHaveBeenCalled() + }) + }) + + describe('dispose', () => { + it('disposes the underlying OrbitControls', () => { + manager = new ControlsManager(makeRenderer(), camera, events) + + manager.dispose() + + expect(manager.controls.dispose).toHaveBeenCalled() + }) + }) +}) diff --git a/src/extensions/core/load3d/EventManager.test.ts b/src/extensions/core/load3d/EventManager.test.ts new file mode 100644 index 0000000000..0ecad94123 --- /dev/null +++ b/src/extensions/core/load3d/EventManager.test.ts @@ -0,0 +1,68 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { EventManager } from './EventManager' + +describe('EventManager', () => { + let manager: EventManager + + beforeEach(() => { + vi.clearAllMocks() + manager = new EventManager() + }) + + describe('emitEvent', () => { + it('does nothing when there are no listeners for the event', () => { + expect(() => manager.emitEvent('unknown', { x: 1 })).not.toThrow() + }) + + it('invokes every listener registered for the event with the payload', () => { + const a = vi.fn() + const b = vi.fn() + manager.addEventListener('change', a) + manager.addEventListener('change', b) + + manager.emitEvent('change', { value: 7 }) + + expect(a).toHaveBeenCalledWith({ value: 7 }) + expect(b).toHaveBeenCalledWith({ value: 7 }) + }) + + it('does not invoke listeners registered for a different event', () => { + const cb = vi.fn() + manager.addEventListener('a', cb) + + manager.emitEvent('b', null) + + expect(cb).not.toHaveBeenCalled() + }) + }) + + describe('removeEventListener', () => { + it('detaches a previously added listener', () => { + const cb = vi.fn() + manager.addEventListener('change', cb) + + manager.removeEventListener('change', cb) + manager.emitEvent('change', null) + + expect(cb).not.toHaveBeenCalled() + }) + + it('leaves other listeners on the same event intact', () => { + const a = vi.fn() + const b = vi.fn() + manager.addEventListener('change', a) + manager.addEventListener('change', b) + + manager.removeEventListener('change', a) + manager.emitEvent('change', null) + + expect(a).not.toHaveBeenCalled() + expect(b).toHaveBeenCalled() + }) + + it('is safely a no-op for an event that has never been listened to', () => { + expect(() => manager.removeEventListener('never', vi.fn())).not.toThrow() + }) + }) +}) diff --git a/src/extensions/core/load3d/LightingManager.test.ts b/src/extensions/core/load3d/LightingManager.test.ts new file mode 100644 index 0000000000..7cf3ccb7f3 --- /dev/null +++ b/src/extensions/core/load3d/LightingManager.test.ts @@ -0,0 +1,150 @@ +import * as THREE from 'three' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import type { EventManagerInterface } from './interfaces' +import { LightingManager } from './LightingManager' + +function makeMockEventManager() { + return { + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + emitEvent: vi.fn() + } satisfies EventManagerInterface +} + +describe('LightingManager', () => { + let scene: THREE.Scene + let events: ReturnType<typeof makeMockEventManager> + let manager: LightingManager + + beforeEach(() => { + vi.clearAllMocks() + scene = new THREE.Scene() + events = makeMockEventManager() + manager = new LightingManager(scene, events) + }) + + describe('init / setupLights', () => { + it('adds six lights — one ambient and five directionals — to the scene', () => { + manager.init() + + expect(manager.lights).toHaveLength(6) + const ambient = manager.lights.filter( + (l) => l instanceof THREE.AmbientLight + ) + const directional = manager.lights.filter( + (l) => l instanceof THREE.DirectionalLight + ) + expect(ambient).toHaveLength(1) + expect(directional).toHaveLength(5) + manager.lights.forEach((light) => { + expect(scene.children).toContain(light) + }) + }) + + it('positions the directional lights to surround the model', () => { + manager.init() + + const positions = manager.lights + .filter( + (l): l is THREE.DirectionalLight => + l instanceof THREE.DirectionalLight + ) + .map((l) => l.position.toArray()) + + expect(positions).toEqual( + expect.arrayContaining([ + [0, 10, 10], + [0, 10, -10], + [-10, 0, 0], + [10, 0, 0], + [0, -10, 0] + ]) + ) + }) + }) + + describe('setLightIntensity', () => { + it('scales each light by its stored multiplier and records the requested intensity', () => { + manager.init() + const ambient = manager.lights.find( + (l): l is THREE.AmbientLight => l instanceof THREE.AmbientLight + )! + const mainLight = manager.lights.find( + (l): l is THREE.DirectionalLight => + l instanceof THREE.DirectionalLight && + l.position.y === 10 && + l.position.z === 10 + )! + + manager.setLightIntensity(2) + + expect(manager.currentIntensity).toBe(2) + expect(ambient.intensity).toBeCloseTo(2 * 0.5) + expect(mainLight.intensity).toBeCloseTo(2 * 0.8) + }) + + it('emits lightIntensityChange with the new intensity', () => { + manager.init() + + manager.setLightIntensity(1.5) + + expect(events.emitEvent).toHaveBeenCalledWith('lightIntensityChange', 1.5) + }) + + it('is a no-op (no error) when called before init', () => { + expect(() => manager.setLightIntensity(1)).not.toThrow() + expect(events.emitEvent).toHaveBeenCalledWith('lightIntensityChange', 1) + }) + }) + + describe('setHDRIMode', () => { + it('hides every light when HDRI is active', () => { + manager.init() + + manager.setHDRIMode(true) + + manager.lights.forEach((light) => { + expect(light.visible).toBe(false) + }) + }) + + it('restores visibility when HDRI is turned off', () => { + manager.init() + manager.setHDRIMode(true) + + manager.setHDRIMode(false) + + manager.lights.forEach((light) => { + expect(light.visible).toBe(true) + }) + }) + }) + + describe('dispose', () => { + it('removes every light from the scene and clears internal state', () => { + manager.init() + const lightCount = manager.lights.length + + manager.dispose() + + expect(manager.lights).toEqual([]) + expect( + scene.children.filter((c) => c instanceof THREE.Light) + ).toHaveLength(0) + expect(lightCount).toBeGreaterThan(0) + }) + + it('resets multipliers so subsequent setLightIntensity calls are no-ops', () => { + manager.init() + manager.dispose() + + manager.setLightIntensity(5) + + expect(events.emitEvent).toHaveBeenLastCalledWith( + 'lightIntensityChange', + 5 + ) + }) + }) +}) diff --git a/src/extensions/core/load3d/Load3DConfiguration.test.ts b/src/extensions/core/load3d/Load3DConfiguration.test.ts index b26881ec3f..0aa445673f 100644 --- a/src/extensions/core/load3d/Load3DConfiguration.test.ts +++ b/src/extensions/core/load3d/Load3DConfiguration.test.ts @@ -1,11 +1,13 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type Load3d from '@/extensions/core/load3d/Load3d' import Load3DConfiguration from '@/extensions/core/load3d/Load3DConfiguration' +import Load3dUtils from '@/extensions/core/load3d/Load3dUtils' import type { GizmoConfig, ModelConfig } from '@/extensions/core/load3d/interfaces' +import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets' import type { Dictionary } from '@/lib/litegraph/src/interfaces' import type { NodeProperty } from '@/lib/litegraph/src/LGraphNode' @@ -162,3 +164,88 @@ describe('Load3DConfiguration.loadModelConfig', () => { expect(result.gizmo).toEqual(fullGizmo) }) }) + +describe('Load3DConfiguration.silentOnNotFound propagation', () => { + let loadModelSpy: ReturnType<typeof vi.fn> + + function makeLoad3dMock(): Load3d { + loadModelSpy = vi.fn().mockResolvedValue(undefined) + return { + loadModel: loadModelSpy, + setUpDirection: vi.fn(), + setMaterialMode: vi.fn(), + setTargetSize: vi.fn(), + setCameraState: vi.fn(), + toggleGrid: vi.fn(), + setBackgroundColor: vi.fn(), + setBackgroundImage: vi.fn().mockResolvedValue(undefined), + setBackgroundRenderMode: vi.fn(), + toggleCamera: vi.fn(), + setFOV: vi.fn(), + setLightIntensity: vi.fn(), + setHDRIIntensity: vi.fn(), + setHDRIAsBackground: vi.fn(), + setHDRIEnabled: vi.fn() + } as unknown as Load3d + } + + async function flush() { + await new Promise<void>((resolve) => setTimeout(resolve, 0)) + } + + beforeEach(() => { + vi.mocked(Load3dUtils.splitFilePath).mockReturnValue(['', 'model.glb']) + vi.mocked(Load3dUtils.getResourceURL).mockReturnValue( + '/view?filename=model.glb' + ) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('configureForSaveMesh forwards silentOnNotFound: true to loadModel', async () => { + const config = new Load3DConfiguration(makeLoad3dMock()) + config.configureForSaveMesh('output', 'model.glb', { + silentOnNotFound: true + }) + await flush() + expect(loadModelSpy).toHaveBeenCalledWith(expect.any(String), 'model.glb', { + silentOnNotFound: true + }) + }) + + it('configureForSaveMesh uses silentOnNotFound: false when option is omitted', async () => { + const config = new Load3DConfiguration(makeLoad3dMock()) + config.configureForSaveMesh('output', 'model.glb') + await flush() + expect(loadModelSpy).toHaveBeenCalledWith(expect.any(String), 'model.glb', { + silentOnNotFound: false + }) + }) + + it('configure forwards silentOnNotFound: true from settings to loadModel', async () => { + const config = new Load3DConfiguration(makeLoad3dMock()) + config.configure({ + modelWidget: { value: 'model.glb' } as unknown as IBaseWidget, + loadFolder: 'output', + silentOnNotFound: true + }) + await flush() + expect(loadModelSpy).toHaveBeenCalledWith(expect.any(String), 'model.glb', { + silentOnNotFound: true + }) + }) + + it('configure uses silentOnNotFound: false when setting is omitted', async () => { + const config = new Load3DConfiguration(makeLoad3dMock()) + config.configure({ + modelWidget: { value: 'model.glb' } as unknown as IBaseWidget, + loadFolder: 'output' + }) + await flush() + expect(loadModelSpy).toHaveBeenCalledWith(expect.any(String), 'model.glb', { + silentOnNotFound: false + }) + }) +}) diff --git a/src/extensions/core/load3d/Load3DConfiguration.ts b/src/extensions/core/load3d/Load3DConfiguration.ts index 4906213abf..2486e8472a 100644 --- a/src/extensions/core/load3d/Load3DConfiguration.ts +++ b/src/extensions/core/load3d/Load3DConfiguration.ts @@ -21,6 +21,7 @@ type Load3DConfigurationSettings = { width?: IBaseWidget height?: IBaseWidget bgImagePath?: string + silentOnNotFound?: boolean } class Load3DConfiguration { @@ -29,8 +30,16 @@ class Load3DConfiguration { private properties?: Dictionary<NodeProperty | undefined> ) {} - configureForSaveMesh(loadFolder: 'input' | 'output', filePath: string) { - this.setupModelHandlingForSaveMesh(filePath, loadFolder) + configureForSaveMesh( + loadFolder: 'input' | 'output', + filePath: string, + options?: { silentOnNotFound?: boolean } + ) { + this.setupModelHandlingForSaveMesh( + filePath, + loadFolder, + options?.silentOnNotFound ?? false + ) this.setupDefaultProperties() } @@ -38,7 +47,8 @@ class Load3DConfiguration { this.setupModelHandling( setting.modelWidget, setting.loadFolder, - setting.cameraState + setting.cameraState, + setting.silentOnNotFound ?? false ) this.setupTargetSize(setting.width, setting.height) this.setupDefaultProperties(setting.bgImagePath) @@ -58,8 +68,16 @@ class Load3DConfiguration { } } - private setupModelHandlingForSaveMesh(filePath: string, loadFolder: string) { - const onModelWidgetUpdate = this.createModelUpdateHandler(loadFolder) + private setupModelHandlingForSaveMesh( + filePath: string, + loadFolder: string, + silentOnNotFound: boolean + ) { + const onModelWidgetUpdate = this.createModelUpdateHandler( + loadFolder, + undefined, + silentOnNotFound + ) if (filePath) { onModelWidgetUpdate(filePath) @@ -69,11 +87,13 @@ class Load3DConfiguration { private setupModelHandling( modelWidget: IBaseWidget, loadFolder: string, - cameraState?: CameraState + cameraState?: CameraState, + silentOnNotFound: boolean = false ) { const onModelWidgetUpdate = this.createModelUpdateHandler( loadFolder, - cameraState + cameraState, + silentOnNotFound ) if (modelWidget.value) { onModelWidgetUpdate(modelWidget.value) @@ -241,7 +261,8 @@ class Load3DConfiguration { private createModelUpdateHandler( loadFolder: string, - cameraState?: CameraState + cameraState?: CameraState, + silentOnNotFound: boolean = false ) { let isFirstLoad = true return async (value: string | number | boolean | object) => { @@ -258,7 +279,7 @@ class Load3DConfiguration { ) ) - await this.load3d.loadModel(modelUrl, filename) + await this.load3d.loadModel(modelUrl, filename, { silentOnNotFound }) const modelConfig = this.loadModelConfig() this.applyModelConfig(modelConfig) diff --git a/src/extensions/core/load3d/Load3d.test.ts b/src/extensions/core/load3d/Load3d.test.ts index f3305c8dc3..7cf76122e0 100644 --- a/src/extensions/core/load3d/Load3d.test.ts +++ b/src/extensions/core/load3d/Load3d.test.ts @@ -37,11 +37,6 @@ type SceneManagerStub = { dispose: ReturnType<typeof vi.fn> } -type Load3dPrivate = { - setGizmo(model: THREE.Object3D): void - setupCamera(size: THREE.Vector3, center: THREE.Vector3): void -} - function makeGizmoStub(): GizmoStub { return { setEnabled: vi.fn(), @@ -97,6 +92,7 @@ function makeInstance() { controlsManager, viewHelperManager, animationManager, + adapterRef: { current: null }, forceRender: vi.fn(), handleResize: vi.fn() }) @@ -208,6 +204,29 @@ describe('Load3d', () => { expect(ctx.forceRender).toHaveBeenCalledOnce() }) + it('clearModel nulls adapterRef.current so capability queries fall back to defaults', () => { + Object.assign(ctx.load3d, { + adapterRef: { current: { kind: 'splat' } } + }) + let adapterDuringModelManagerClear: + | { kind: string; current?: unknown } + | null + | undefined + ctx.modelManager.clearModel.mockImplementation(() => { + adapterDuringModelManagerClear = ( + ctx.load3d as unknown as { adapterRef: { current: unknown } } + ).adapterRef.current as { kind: string } | null + }) + + ctx.load3d.clearModel() + + expect(adapterDuringModelManagerClear).toEqual({ kind: 'splat' }) + expect( + (ctx.load3d as unknown as { adapterRef: { current: unknown } }) + .adapterRef.current + ).toBeNull() + }) + it('toggleCamera updates both controls and gizmo with the active camera', () => { ctx.load3d.toggleCamera('orthographic') @@ -222,23 +241,6 @@ describe('Load3d', () => { ) expect(ctx.viewHelperManager.recreateViewHelper).toHaveBeenCalledOnce() }) - - it('setGizmo (private) forwards the model to gizmoManager.setupForModel', () => { - const model = new THREE.Object3D() - - ;(ctx.load3d as unknown as Load3dPrivate).setGizmo(model) - - expect(ctx.gizmo.setupForModel).toHaveBeenCalledWith(model) - }) - - it('setupCamera (private) forwards size and center to cameraManager', () => { - const size = new THREE.Vector3(1, 2, 3) - const center = new THREE.Vector3(4, 5, 6) - - ;(ctx.load3d as unknown as Load3dPrivate).setupCamera(size, center) - - expect(ctx.cameraManager.setupForModel).toHaveBeenCalledWith(size, center) - }) }) describe('viewport wiring', () => { @@ -473,7 +475,7 @@ describe('Load3d', () => { function makeWithAdapter(kind: 'mesh' | 'pointCloud' | 'splat' | null) { const adapter = kind === null ? null : { kind } Object.assign(ctx.load3d, { - loaderManager: { getCurrentAdapter: vi.fn(() => adapter) } + adapterRef: { current: adapter } }) } @@ -494,6 +496,185 @@ describe('Load3d', () => { }) }) + describe('setCameraFromMatrices', () => { + it('derives the camera pose from extrinsics+intrinsics and applies it via setCameraState + setFOV', () => { + const setCameraState = vi.fn() + const setFOVImpl = vi.fn() + const getCameraState = vi.fn(() => ({ + position: new THREE.Vector3(0, 0, 0), + target: new THREE.Vector3(0, 0, 0), + zoom: 1.5, + cameraType: 'orthographic' as const + })) + + Object.assign(ctx.load3d, { + setCameraState, + setFOV: setFOVImpl, + cameraManager: { ...ctx.cameraManager, getCameraState } + }) + + // Identity rotation, zero translation, fy=cy=1 → fovY = 2*atan(1) = 90°. + // OpenCV → three.js flips Y/Z, so position (0,0,0) stays at origin + // and forward (0,0,1) → target (0,0,-1). + const extrinsics = [ + [1, 0, 0, 0], + [0, 1, 0, 0], + [0, 0, 1, 0], + [0, 0, 0, 1] + ] + const intrinsics = [ + [1, 0, 0], + [0, 1, 1], + [0, 0, 1] + ] + + ctx.load3d.setCameraFromMatrices(extrinsics, intrinsics) + + expect(setCameraState).toHaveBeenCalledOnce() + const stateArg = setCameraState.mock.calls[0][0] as { + position: THREE.Vector3 + target: THREE.Vector3 + zoom: number + cameraType: string + } + expect(stateArg.position.x).toBeCloseTo(0) + expect(stateArg.position.y).toBeCloseTo(0) + expect(stateArg.position.z).toBeCloseTo(0) + expect(stateArg.target.x).toBeCloseTo(0) + expect(stateArg.target.y).toBeCloseTo(0) + expect(stateArg.target.z).toBeCloseTo(-1) + // Zoom and cameraType must be preserved from the current state. + expect(stateArg.zoom).toBe(1.5) + expect(stateArg.cameraType).toBe('orthographic') + + expect(setFOVImpl).toHaveBeenCalledOnce() + expect(setFOVImpl.mock.calls[0][0]).toBeCloseTo(90) + }) + }) + + describe('whenLoadIdle', () => { + it('resolves immediately when no load is in flight', async () => { + Object.assign(ctx.load3d, { loadingPromise: null }) + await expect(ctx.load3d.whenLoadIdle()).resolves.toBeUndefined() + }) + + it('waits for the current loadingPromise to settle', async () => { + let resolveLoad!: () => void + const p = new Promise<void>((resolve) => { + resolveLoad = resolve + }) + Object.assign(ctx.load3d, { loadingPromise: p }) + + const idle = ctx.load3d.whenLoadIdle() + let settled = false + void idle.then(() => { + settled = true + }) + + await Promise.resolve() + expect(settled).toBe(false) + + resolveLoad() + + Object.assign(ctx.load3d, { loadingPromise: null }) + await idle + expect(settled).toBe(true) + }) + + it('drains a chained sequence of loads before resolving', async () => { + let resolveFirst!: () => void + const first = new Promise<void>((resolve) => { + resolveFirst = resolve + }) + let resolveSecond!: () => void + const second = new Promise<void>((resolve) => { + resolveSecond = resolve + }) + + Object.assign(ctx.load3d, { loadingPromise: first }) + void first.then(() => { + Object.assign(ctx.load3d, { loadingPromise: second }) + }) + + const idle = ctx.load3d.whenLoadIdle() + let settled = false + void idle.then(() => { + settled = true + }) + + resolveFirst() + await new Promise((r) => setTimeout(r, 0)) + expect(settled).toBe(false) + + resolveSecond() + Object.assign(ctx.load3d, { loadingPromise: null }) + await idle + expect(settled).toBe(true) + }) + + it('swallows a rejected loadingPromise and continues draining', async () => { + const failing = Promise.reject(new Error('boom')) + failing.catch(() => {}) + Object.assign(ctx.load3d, { loadingPromise: failing }) + + const idle = ctx.load3d.whenLoadIdle() + Object.assign(ctx.load3d, { loadingPromise: null }) + + await expect(idle).resolves.toBeUndefined() + }) + }) + + describe('currentLoadGeneration', () => { + it('starts at 0', () => { + const fresh = Object.create(Load3d.prototype) as Load3d + Object.assign(fresh, { + _loadGeneration: 0 + }) + expect(fresh.currentLoadGeneration).toBe(0) + }) + + it('ticks synchronously on every loadModel call, before any await', async () => { + const internal = vi.fn().mockResolvedValue(undefined) + Object.assign(ctx.load3d, { + _loadGeneration: 0, + loadingPromise: null, + _loadModelInternal: internal + }) + + const baseline = ctx.load3d.currentLoadGeneration + + const p1 = ctx.load3d.loadModel('api/view?filename=a.glb') + expect(ctx.load3d.currentLoadGeneration).toBe(baseline + 1) + const p2 = ctx.load3d.loadModel('api/view?filename=b.glb') + expect(ctx.load3d.currentLoadGeneration).toBe(baseline + 2) + + await Promise.all([p1, p2]) + }) + + it('lets a chained whenLoadIdle continuation skip when a newer load was queued in between', async () => { + const internal = vi.fn().mockResolvedValue(undefined) + Object.assign(ctx.load3d, { + _loadGeneration: 0, + loadingPromise: null, + _loadModelInternal: internal + }) + + const aGeneration = ctx.load3d.currentLoadGeneration + const aPromise = ctx.load3d.loadModel('api/view?filename=a.glb') + const aTarget = ctx.load3d.currentLoadGeneration + expect(aTarget).toBe(aGeneration + 1) + + const bPromise = ctx.load3d.loadModel('api/view?filename=b.glb') + expect(ctx.load3d.currentLoadGeneration).toBe(aGeneration + 2) + + await Promise.all([aPromise, bPromise]) + + const apply = vi.fn() + if (ctx.load3d.currentLoadGeneration === aTarget) apply() + expect(apply).not.toHaveBeenCalled() + }) + }) + describe('captureScene', () => { it('hides the gizmo helper during capture and restores it after success', async () => { const captureResult = { scene: 'a', mask: 'b', normal: 'c' } diff --git a/src/extensions/core/load3d/Load3d.ts b/src/extensions/core/load3d/Load3d.ts index 0ad5729a89..28579297ed 100644 --- a/src/extensions/core/load3d/Load3d.ts +++ b/src/extensions/core/load3d/Load3d.ts @@ -1,24 +1,28 @@ import * as THREE from 'three' -import { AnimationManager } from './AnimationManager' -import { CameraManager } from './CameraManager' -import { ControlsManager } from './ControlsManager' -import { EventManager } from './EventManager' -import { HDRIManager } from './HDRIManager' -import { GizmoManager } from './GizmoManager' -import { LightingManager } from './LightingManager' -import { LoaderManager } from './LoaderManager' +import type { AnimationManager } from './AnimationManager' +import type { CameraManager } from './CameraManager' +import type { ControlsManager } from './ControlsManager' +import type { EventManager } from './EventManager' +import type { GizmoManager } from './GizmoManager' +import type { HDRIManager } from './HDRIManager' +import type { LightingManager } from './LightingManager' +import type { LoaderManager } from './LoaderManager' import { ModelExporter } from './ModelExporter' -import { RecordingManager } from './RecordingManager' -import { SceneManager } from './SceneManager' -import { SceneModelManager } from './SceneModelManager' -import { ViewHelperManager } from './ViewHelperManager' +import { DEFAULT_MODEL_CAPABILITIES } from './ModelAdapter' +import type { AdapterRef, ModelAdapterCapabilities } from './ModelAdapter' +import type { RecordingManager } from './RecordingManager' +import type { SceneManager } from './SceneManager' +import type { SceneModelManager } from './SceneModelManager' +import type { ViewHelperManager } from './ViewHelperManager' +import { computeCameraFromMatrices } from './cameraFromMatrices' import type { CameraState, CaptureResult, EventCallback, GizmoMode, Load3DOptions, + LoadModelOptions, MaterialMode, UpDirection } from './interfaces' @@ -27,6 +31,23 @@ import type { RenderLoopHandle } from './load3dRenderLoop' import { startRenderLoop } from './load3dRenderLoop' import { computeLetterboxedViewport, isLoad3dActive } from './load3dViewport' +export type Load3dDeps = { + renderer: THREE.WebGLRenderer + eventManager: EventManager + sceneManager: SceneManager + cameraManager: CameraManager + controlsManager: ControlsManager + lightingManager: LightingManager + hdriManager: HDRIManager + viewHelperManager: ViewHelperManager + loaderManager: LoaderManager + modelManager: SceneModelManager + recordingManager: RecordingManager + animationManager: AnimationManager + gizmoManager: GizmoManager + adapterRef: AdapterRef +} + function positionThumbnailCamera( camera: THREE.PerspectiveCamera, model: THREE.Object3D @@ -51,6 +72,7 @@ class Load3d { protected clock: THREE.Clock private renderLoop: RenderLoopHandle | null = null private loadingPromise: Promise<void> | null = null + private _loadGeneration: number = 0 private onContextMenuCallback?: (event: MouseEvent) => void private getDimensionsCallback?: () => { width: number; height: number } | null @@ -66,6 +88,7 @@ class Load3d { recordingManager: RecordingManager animationManager: AnimationManager gizmoManager: GizmoManager + adapterRef: AdapterRef STATUS_MOUSE_ON_NODE: boolean STATUS_MOUSE_ON_SCENE: boolean @@ -80,7 +103,11 @@ class Load3d { private disposeContextMenuGuard: (() => void) | null = null private resizeObserver: ResizeObserver | null = null - constructor(container: Element | HTMLElement, options: Load3DOptions = {}) { + constructor( + container: Element | HTMLElement, + deps: Load3dDeps, + options: Load3DOptions = {} + ) { this.clock = new THREE.Clock() this.isViewerMode = options.isViewerMode || false this.onContextMenuCallback = options.onContextMenu @@ -92,90 +119,20 @@ class Load3d { this.targetAspectRatio = options.width / options.height } - this.renderer = new THREE.WebGLRenderer({ alpha: true, antialias: true }) - this.renderer.setSize(300, 300) - this.renderer.setClearColor(0x282828) - this.renderer.autoClear = false - this.renderer.outputColorSpace = THREE.SRGBColorSpace - this.renderer.domElement.classList.add( - 'absolute', - 'inset-0', - 'h-full', - 'w-full', - 'outline-none' - ) - container.appendChild(this.renderer.domElement) - - this.eventManager = new EventManager() - - this.sceneManager = new SceneManager( - this.renderer, - this.getActiveCamera.bind(this), - this.getControls.bind(this), - this.eventManager - ) - - this.cameraManager = new CameraManager(this.renderer, this.eventManager) - - this.controlsManager = new ControlsManager( - this.renderer, - this.cameraManager.activeCamera, - this.eventManager - ) - - this.cameraManager.setControls(this.controlsManager.controls) - - this.lightingManager = new LightingManager( - this.sceneManager.scene, - this.eventManager - ) - - this.hdriManager = new HDRIManager( - this.sceneManager.scene, - this.renderer, - this.eventManager - ) - - this.viewHelperManager = new ViewHelperManager( - this.renderer, - this.getActiveCamera.bind(this), - this.getControls.bind(this), - this.eventManager - ) - - this.modelManager = new SceneModelManager( - this.sceneManager.scene, - this.renderer, - this.eventManager, - this.getActiveCamera.bind(this), - this.setupCamera.bind(this), - this.setGizmo.bind(this) - ) - - this.loaderManager = new LoaderManager(this.modelManager, this.eventManager) - - this.recordingManager = new RecordingManager( - this.sceneManager.scene, - this.renderer, - this.eventManager - ) - - this.animationManager = new AnimationManager(this.eventManager) - - this.gizmoManager = new GizmoManager( - this.sceneManager.scene, - this.renderer, - this.controlsManager.controls, - this.getActiveCamera.bind(this), - () => { - const transform = this.gizmoManager.getTransform() - this.eventManager.emitEvent('gizmoTransformChange', { - ...transform, - enabled: this.gizmoManager.isEnabled(), - mode: this.gizmoManager.getMode() - }) - } - ) + this.renderer = deps.renderer + this.eventManager = deps.eventManager + this.sceneManager = deps.sceneManager + this.cameraManager = deps.cameraManager + this.controlsManager = deps.controlsManager + this.lightingManager = deps.lightingManager + this.hdriManager = deps.hdriManager + this.viewHelperManager = deps.viewHelperManager + this.loaderManager = deps.loaderManager + this.modelManager = deps.modelManager + this.recordingManager = deps.recordingManager + this.animationManager = deps.animationManager + this.gizmoManager = deps.gizmoManager + this.adapterRef = deps.adapterRef this.sceneManager.init() this.cameraManager.init() @@ -334,22 +291,6 @@ class Load3d { this.renderer.setScissorTest(false) } - private getActiveCamera(): THREE.Camera { - return this.cameraManager.activeCamera - } - - private getControls() { - return this.controlsManager.controls - } - - private setGizmo(model: THREE.Object3D): void { - this.gizmoManager.setupForModel(model) - } - - private setupCamera(size: THREE.Vector3, center: THREE.Vector3): void { - this.cameraManager.setupForModel(size, center) - } - private startAnimation(): void { this.renderLoop = startRenderLoop({ tick: () => { @@ -525,25 +466,76 @@ class Load3d { this.forceRender() } + setCameraFromMatrices( + extrinsics: readonly (readonly number[])[], + intrinsics: readonly (readonly number[])[] + ): void { + const { position, target, fovYDegrees } = computeCameraFromMatrices( + extrinsics, + intrinsics + ) + const current = this.cameraManager.getCameraState() + this.setCameraState({ + position: new THREE.Vector3(position[0], position[1], position[2]), + target: new THREE.Vector3(target[0], target[1], target[2]), + zoom: current.zoom, + cameraType: current.cameraType + }) + this.setFOV(fovYDegrees) + } + setMaterialMode(mode: MaterialMode): void { this.modelManager.setMaterialMode(mode) this.forceRender() } - async loadModel(url: string, originalFileName?: string): Promise<void> { + /** + * Monotonic counter that ticks once per loadModel call, **before** any + * await. Callers can capture this immediately after triggering a load and + * later compare against `currentLoadGeneration` to verify their load is + * still the latest one — useful when chaining post-load work + * (e.g. applying camera matrices) through `whenLoadIdle()`, which would + * otherwise wait for any newer queued load and apply stale state to it. + */ + get currentLoadGeneration(): number { + return this._loadGeneration + } + + async loadModel( + url: string, + originalFileName?: string, + options?: LoadModelOptions + ): Promise<void> { + this._loadGeneration += 1 + if (this.loadingPromise) { try { await this.loadingPromise } catch (e) {} } - this.loadingPromise = this._loadModelInternal(url, originalFileName) + this.loadingPromise = this._loadModelInternal( + url, + originalFileName, + options + ) return this.loadingPromise } + async whenLoadIdle(): Promise<void> { + let last: Promise<void> | null = null + while (this.loadingPromise && this.loadingPromise !== last) { + last = this.loadingPromise + try { + await last + } catch (e) {} + } + } + private async _loadModelInternal( url: string, - originalFileName?: string + originalFileName?: string, + options?: LoadModelOptions ): Promise<void> { this.cameraManager.reset() this.controlsManager.reset() @@ -551,7 +543,7 @@ class Load3d { this.modelManager.clearModel() this.animationManager.dispose() - await this.loaderManager.loadModel(url, originalFileName) + await this.loaderManager.loadModel(url, originalFileName, options) // Auto-detect and setup animations if present if (this.modelManager.currentModel) { @@ -567,17 +559,22 @@ class Load3d { } isSplatModel(): boolean { - return this.loaderManager.getCurrentAdapter()?.kind === 'splat' + return this.adapterRef.current?.kind === 'splat' } isPlyModel(): boolean { - return this.loaderManager.getCurrentAdapter()?.kind === 'pointCloud' + return this.adapterRef.current?.kind === 'pointCloud' + } + + getCurrentModelCapabilities(): ModelAdapterCapabilities { + return this.adapterRef.current?.capabilities ?? DEFAULT_MODEL_CAPABILITIES } clearModel(): void { this.animationManager.dispose() this.gizmoManager.detach() this.modelManager.clearModel() + this.adapterRef.current = null this.forceRender() } @@ -812,16 +809,19 @@ class Load3d { } public setGizmoEnabled(enabled: boolean): void { + if (enabled && !this.getCurrentModelCapabilities().gizmoTransform) return this.gizmoManager.setEnabled(enabled) this.forceRender() } public setGizmoMode(mode: GizmoMode): void { + if (!this.getCurrentModelCapabilities().gizmoTransform) return this.gizmoManager.setMode(mode) this.forceRender() } public resetGizmoTransform(): void { + if (!this.getCurrentModelCapabilities().gizmoTransform) return this.gizmoManager.reset() this.forceRender() } @@ -831,6 +831,7 @@ class Load3d { rotation: { x: number; y: number; z: number }, scale?: { x: number; y: number; z: number } ): void { + if (!this.getCurrentModelCapabilities().gizmoTransform) return this.gizmoManager.applyTransform(position, rotation, scale) this.forceRender() } @@ -876,6 +877,7 @@ class Load3d { this.viewHelperManager.dispose() this.loaderManager.dispose() this.modelManager.dispose() + this.adapterRef.current = null this.recordingManager.dispose() this.animationManager.dispose() this.gizmoManager.dispose() diff --git a/src/extensions/core/load3d/LoaderManager.test.ts b/src/extensions/core/load3d/LoaderManager.test.ts index 75a5ee20c8..ca4b636f9d 100644 --- a/src/extensions/core/load3d/LoaderManager.test.ts +++ b/src/extensions/core/load3d/LoaderManager.test.ts @@ -141,10 +141,9 @@ describe('LoaderManager', () => { expect(lm.getCurrentAdapter()).toBeNull() }) - it('stays null when the adapter rejects', async () => { + it('stays null when the adapter rejects (does not publish stale adapter)', async () => { const { lm } = makeLoaderManager() - // Seed with a previously-successful mesh load so we can prove a later - // failed splat load does not leave the splat adapter published. + meshLoad.mockResolvedValueOnce(new THREE.Object3D()) await lm.loadModel('api/view?filename=cube.glb') expect(lm.getCurrentAdapter()?.kind).toBe('mesh') @@ -196,10 +195,13 @@ describe('LoaderManager', () => { } let adapterDuringClear: ModelAdapter | null | undefined - const lm = new LoaderManager(modelManager, eventManager, [oldAdapter]) - // Prime the loader with an active adapter, then trigger a new load. - ;(lm as unknown as { _currentAdapter: ModelAdapter })._currentAdapter = - oldAdapter + const adapterRef = { current: oldAdapter as ModelAdapter | null } + const lm = new LoaderManager( + modelManager, + eventManager, + [oldAdapter], + adapterRef + ) ;(modelManager.clearModel as ReturnType<typeof vi.fn>).mockImplementation( () => { adapterDuringClear = lm.getCurrentAdapter() @@ -213,6 +215,29 @@ describe('LoaderManager', () => { expect(adapterDuringClear).toBe(oldAdapter) }) + + it('does not let a slow stale load clobber adapterRef after a newer load took over', async () => { + const { lm } = makeLoaderManager() + + let resolveSplatLoad!: (model: THREE.Object3D) => void + const slowSplatLoad = new Promise<THREE.Object3D>((resolve) => { + resolveSplatLoad = resolve + }) + splatLoad.mockReturnValueOnce(slowSplatLoad) + meshLoad.mockResolvedValueOnce(new THREE.Object3D()) + + const aPromise = lm.loadModel('api/view?filename=a.splat') + + await Promise.resolve() + + await lm.loadModel('api/view?filename=b.glb') + expect(lm.getCurrentAdapter()?.kind).toBe('mesh') + + resolveSplatLoad(new THREE.Object3D()) + await aPromise + + expect(lm.getCurrentAdapter()?.kind).toBe('mesh') + }) }) describe('pickAdapter', () => { @@ -411,6 +436,55 @@ describe('LoaderManager', () => { expect(consoleError).toHaveBeenCalled() }) + it('suppresses the alert on a 404 when silentOnNotFound is set', async () => { + const { lm } = makeLoaderManager() + const notFound = new Error( + 'fetch for "..." responded with 404: Not Found' + ) + meshLoad.mockRejectedValueOnce(notFound) + const consoleError = vi + .spyOn(console, 'error') + .mockImplementation(() => {}) + + await lm.loadModel('api/view?filename=cube.glb', undefined, { + silentOnNotFound: true + }) + + expect(consoleError).toHaveBeenCalled() + expect(addAlert).not.toHaveBeenCalledWith( + 'toastMessages.errorLoadingModel' + ) + }) + + it('detects a 404 from the response status field on three.js HttpError', async () => { + const { lm } = makeLoaderManager() + const httpError = Object.assign(new Error('not found'), { + response: { status: 404 } + }) + meshLoad.mockRejectedValueOnce(httpError) + vi.spyOn(console, 'error').mockImplementation(() => {}) + + await lm.loadModel('api/view?filename=cube.glb', undefined, { + silentOnNotFound: true + }) + + expect(addAlert).not.toHaveBeenCalledWith( + 'toastMessages.errorLoadingModel' + ) + }) + + it('still alerts on non-404 errors when silentOnNotFound is set', async () => { + const { lm } = makeLoaderManager() + meshLoad.mockRejectedValueOnce(new Error('parse failure: bad header')) + vi.spyOn(console, 'error').mockImplementation(() => {}) + + await lm.loadModel('api/view?filename=cube.glb', undefined, { + silentOnNotFound: true + }) + + expect(addAlert).toHaveBeenCalledWith('toastMessages.errorLoadingModel') + }) + it('discards the result of a stale load when a newer one has started', async () => { const { lm, modelManager, eventManager } = makeLoaderManager() diff --git a/src/extensions/core/load3d/LoaderManager.ts b/src/extensions/core/load3d/LoaderManager.ts index 315f34e4b4..fce9699d1e 100644 --- a/src/extensions/core/load3d/LoaderManager.ts +++ b/src/extensions/core/load3d/LoaderManager.ts @@ -4,15 +4,37 @@ import { t } from '@/i18n' import { useToastStore } from '@/platform/updates/common/toastStore' import { MeshModelAdapter } from './MeshModelAdapter' -import type { ModelAdapter, ModelLoadContext } from './ModelAdapter' +import { createAdapterRef } from './ModelAdapter' +import type { AdapterRef, ModelAdapter, ModelLoadContext } from './ModelAdapter' import { PointCloudModelAdapter, getPLYEngine } from './PointCloudModelAdapter' import { SplatModelAdapter } from './SplatModelAdapter' -import { - type EventManagerInterface, - type LoaderManagerInterface, - type ModelManagerInterface +import type { + EventManagerInterface, + LoadModelOptions, + LoaderManagerInterface, + ModelManagerInterface } from './interfaces' +/** + * three.js's HttpError attaches the failed `Response` to the thrown Error. + * fetchModelData throws a plain Error whose message embeds the status code. + * Detect both forms so we can keep the toast for parse / network failures + * but stay silent on 404 when the caller opted in. + */ +function isNotFoundError(error: unknown): boolean { + if (!(error instanceof Error)) return false + 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) +} + /** * Default adapter set: mesh + pointCloud + splat. Each adapter declares the * file extensions it owns; LoaderManager picks one by extension. @@ -29,35 +51,41 @@ export class LoaderManager implements LoaderManagerInterface { private readonly modelManager: ModelManagerInterface private readonly eventManager: EventManagerInterface private readonly adapters: ModelAdapter[] + private readonly adapterRef: AdapterRef private currentLoadId: number = 0 - private _currentAdapter: ModelAdapter | null = null constructor( modelManager: ModelManagerInterface, eventManager: EventManagerInterface, - adapters?: readonly ModelAdapter[] + adapters?: readonly ModelAdapter[], + adapterRef?: AdapterRef ) { this.modelManager = modelManager this.eventManager = eventManager this.adapters = adapters ? [...adapters] : defaultAdapters() + this.adapterRef = adapterRef ?? createAdapterRef() } getCurrentAdapter(): ModelAdapter | null { - return this._currentAdapter + return this.adapterRef.current } init(): void {} dispose(): void {} - async loadModel(url: string, originalFileName?: string): Promise<void> { + async loadModel( + url: string, + originalFileName?: string, + options?: LoadModelOptions + ): Promise<void> { const loadId = ++this.currentLoadId try { this.eventManager.emitEvent('modelLoadingStart', null) this.modelManager.clearModel() - this._currentAdapter = null + this.adapterRef.current = null this.modelManager.originalURL = url @@ -83,21 +111,28 @@ export class LoaderManager implements LoaderManagerInterface { const result = await this.loadModelInternal(url, fileExtension) if (loadId !== this.currentLoadId) { + // A newer loadModel has superseded us — do not publish our adapter + // and do not setup the model. Whichever load is current owns the + // shared state. return } - if (result && result.model) { - this._currentAdapter = result.adapter + if (result) { + // Publish only after the staleness check so a slow older load + // can't clobber adapterRef.current that a newer load already + // wrote (or cleared). + this.adapterRef.current = result.adapter await this.modelManager.setupModel(result.model) } this.eventManager.emitEvent('modelLoadingEnd', null) } catch (error) { if (loadId === this.currentLoadId) { - this._currentAdapter = null this.eventManager.emitEvent('modelLoadingEnd', null) console.error('Error loading model:', error) - useToastStore().addAlert(t('toastMessages.errorLoadingModel')) + if (!(options?.silentOnNotFound && isNotFoundError(error))) { + useToastStore().addAlert(t('toastMessages.errorLoadingModel')) + } } } } @@ -135,7 +170,7 @@ export class LoaderManager implements LoaderManagerInterface { private async loadModelInternal( url: string, fileExtension: string - ): Promise<{ adapter: ModelAdapter; model: THREE.Object3D | null } | null> { + ): Promise<{ model: THREE.Object3D; adapter: ModelAdapter } | null> { const params = new URLSearchParams(url.split('?')[1]) const filename = params.get('filename') @@ -157,6 +192,6 @@ export class LoaderManager implements LoaderManagerInterface { if (!adapter) return null const model = await adapter.load(this.createLoadContext(), path, filename) - return { adapter, model } + return model ? { model, adapter } : null } } diff --git a/src/extensions/core/load3d/ModelAdapter.ts b/src/extensions/core/load3d/ModelAdapter.ts index 5d9ee84806..a25fa0c35e 100644 --- a/src/extensions/core/load3d/ModelAdapter.ts +++ b/src/extensions/core/load3d/ModelAdapter.ts @@ -64,6 +64,16 @@ export const DEFAULT_MODEL_CAPABILITIES: ModelAdapterCapabilities = { fitTargetSize: 5 } +/** + * Mutable handle to the currently active ModelAdapter. A single ref is + * created in `createLoad3d` and shared between LoaderManager (writer) and + * SceneModelManager + Load3d (readers), so capability/bounds/dispose lookups + * don't depend on construction order between those collaborators. + */ +export type AdapterRef = { current: ModelAdapter | null } + +export const createAdapterRef = (): AdapterRef => ({ current: null }) + export interface ModelAdapter { readonly kind: ModelAdapterKind readonly extensions: readonly string[] @@ -73,6 +83,29 @@ export interface ModelAdapter { path: string, filename: string ): Promise<THREE.Object3D | null> + /** + * Optional. Return a world-space AABB for the given model. Adapters for + * renderers whose geometry is not walked by `Box3.setFromObject` (e.g. + * Gaussian splats) implement this; the default path uses + * `Box3.setFromObject` when this is absent. + */ + computeBounds?(model: THREE.Object3D): THREE.Box3 | null + /** + * Optional. Release renderer-owned resources on this model beyond what + * THREE's geometry/material.dispose covers (e.g. sparkjs SplatMesh + * internal GPU/worker state). Called when the model is removed from the + * scene due to a reload or teardown. Missing for adapters whose models + * the default traversal already handles. + */ + disposeModel?(model: THREE.Object3D): void + /** + * Optional. Default camera placement for adapters that opt out of + * fit-to-viewer (capabilities.fitToViewer === false). The size/center + * pair is forwarded to CameraManager.setupForModel as if the model had + * been normalized. Splat geometry is self-sized and uses this to seat + * the camera at a known-good distance. + */ + defaultCameraPose?(): { size: THREE.Vector3; center: THREE.Vector3 } } export async function fetchModelData( diff --git a/src/extensions/core/load3d/ModelExporter.test.ts b/src/extensions/core/load3d/ModelExporter.test.ts new file mode 100644 index 0000000000..a961d5125b --- /dev/null +++ b/src/extensions/core/load3d/ModelExporter.test.ts @@ -0,0 +1,303 @@ +import * as THREE from 'three' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { ModelExporter } from './ModelExporter' + +const { + downloadBlobMock, + addAlertMock, + gltfParseMock, + objParseMock, + stlParseMock +} = vi.hoisted(() => ({ + downloadBlobMock: vi.fn(), + addAlertMock: vi.fn(), + gltfParseMock: vi.fn(), + objParseMock: vi.fn(), + stlParseMock: vi.fn() +})) + +vi.mock('@/base/common/downloadUtil', () => ({ + downloadBlob: downloadBlobMock +})) + +vi.mock('@/i18n', () => ({ + t: (key: string, vars?: Record<string, unknown>) => + vars ? `${key}:${JSON.stringify(vars)}` : key +})) + +vi.mock('@/platform/updates/common/toastStore', () => ({ + useToastStore: () => ({ addAlert: addAlertMock }) +})) + +vi.mock('three/examples/jsm/exporters/GLTFExporter', () => ({ + GLTFExporter: class { + parse = gltfParseMock + } +})) + +vi.mock('three/examples/jsm/exporters/OBJExporter', () => ({ + OBJExporter: class { + parse = objParseMock + } +})) + +vi.mock('three/examples/jsm/exporters/STLExporter', () => ({ + STLExporter: class { + parse = stlParseMock + } +})) + +describe('ModelExporter', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + vi.restoreAllMocks() + }) + + describe('detectFormatFromURL', () => { + it('extracts the lowercase extension from the filename query parameter', () => { + expect( + ModelExporter.detectFormatFromURL( + 'http://example.com/api/view?filename=model.GLB' + ) + ).toBe('glb') + }) + + it('returns null when there is no filename parameter', () => { + expect( + ModelExporter.detectFormatFromURL('http://example.com/api/view?foo=bar') + ).toBeNull() + }) + + it('returns null when there is no query string at all', () => { + expect( + ModelExporter.detectFormatFromURL('http://example.com/file.glb') + ).toBeNull() + }) + + it('returns the whole basename when the filename has no dotted extension', () => { + // split('.').pop() returns the only segment when no dot is present. + expect( + ModelExporter.detectFormatFromURL( + 'http://example.com/api/view?filename=cube' + ) + ).toBe('cube') + }) + }) + + describe('canUseDirectURL', () => { + it('returns false for a null URL', () => { + expect(ModelExporter.canUseDirectURL(null, 'glb')).toBe(false) + }) + + it('returns true when the URL extension matches the requested format (case-insensitive)', () => { + expect( + ModelExporter.canUseDirectURL( + 'http://example.com/api/view?filename=cube.GLB', + 'glb' + ) + ).toBe(true) + }) + + it('returns false when the URL extension does not match', () => { + expect( + ModelExporter.canUseDirectURL( + 'http://example.com/api/view?filename=cube.obj', + 'glb' + ) + ).toBe(false) + }) + + it('returns false when the URL has no detectable format', () => { + expect( + ModelExporter.canUseDirectURL('http://example.com/file.glb', 'glb') + ).toBe(false) + }) + }) + + describe('downloadFromURL', () => { + it('fetches the URL and downloads the resulting blob', async () => { + const blob = new Blob(['x']) + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ blob: () => Promise.resolve(blob) }) + ) + + await ModelExporter.downloadFromURL( + 'http://example.com/cube.glb', + 'cube.glb' + ) + + expect(downloadBlobMock).toHaveBeenCalledWith('cube.glb', blob) + vi.unstubAllGlobals() + }) + + it('rethrows and shows a toast alert when fetch fails', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network'))) + + await expect( + ModelExporter.downloadFromURL('http://example.com/cube.glb', 'cube.glb') + ).rejects.toThrow('network') + expect(addAlertMock).toHaveBeenCalledWith( + 'toastMessages.failedToDownloadFile' + ) + vi.unstubAllGlobals() + }) + }) + + describe('exportGLB', () => { + it('takes the direct-URL fast path when the original URL is already a .glb', async () => { + const blob = new Blob(['x']) + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ blob: () => Promise.resolve(blob) }) + ) + const model = new THREE.Object3D() + + await ModelExporter.exportGLB( + model, + 'out.glb', + 'http://example.com/api/view?filename=src.glb' + ) + + expect(downloadBlobMock).toHaveBeenCalledWith('out.glb', blob) + expect(gltfParseMock).not.toHaveBeenCalled() + vi.unstubAllGlobals() + }) + + it('falls through to GLTFExporter when there is no direct URL', async () => { + gltfParseMock.mockImplementation( + ( + _model: unknown, + onDone: (gltf: ArrayBuffer) => void, + _onError: unknown, + options: { binary: boolean } + ) => { + expect(options.binary).toBe(true) + onDone(new ArrayBuffer(8)) + } + ) + + const promise = ModelExporter.exportGLB(new THREE.Object3D(), 'out.glb') + await vi.runAllTimersAsync() + await promise + + expect(gltfParseMock).toHaveBeenCalled() + expect(downloadBlobMock).toHaveBeenCalledWith('out.glb', expect.any(Blob)) + }) + + it('alerts and rethrows when GLTFExporter rejects', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + gltfParseMock.mockImplementation( + (_model: unknown, _onDone: unknown, onError: (e: Error) => void) => + onError(new Error('parse fail')) + ) + + const promise = ModelExporter.exportGLB(new THREE.Object3D(), 'out.glb') + const assertion = expect(promise).rejects.toThrow('parse fail') + await vi.runAllTimersAsync() + await assertion + expect(addAlertMock).toHaveBeenCalledWith( + 'toastMessages.failedToExportModel:{"format":"GLB"}' + ) + }) + }) + + describe('exportOBJ', () => { + it('uses the direct-URL fast path for matching .obj URLs', async () => { + const blob = new Blob(['x']) + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ blob: () => Promise.resolve(blob) }) + ) + + await ModelExporter.exportOBJ( + new THREE.Object3D(), + 'out.obj', + 'http://example.com/api/view?filename=src.obj' + ) + + expect(downloadBlobMock).toHaveBeenCalledWith('out.obj', blob) + expect(objParseMock).not.toHaveBeenCalled() + vi.unstubAllGlobals() + }) + + it('serializes via OBJExporter and downloads as text when there is no direct URL', async () => { + objParseMock.mockReturnValue('# obj data') + + const promise = ModelExporter.exportOBJ(new THREE.Object3D(), 'out.obj') + await vi.runAllTimersAsync() + await promise + + expect(objParseMock).toHaveBeenCalled() + expect(downloadBlobMock).toHaveBeenCalledWith('out.obj', expect.any(Blob)) + }) + + it('alerts and rethrows when OBJExporter throws', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + objParseMock.mockImplementation(() => { + throw new Error('obj fail') + }) + + const promise = ModelExporter.exportOBJ(new THREE.Object3D(), 'out.obj') + const assertion = expect(promise).rejects.toThrow('obj fail') + await vi.runAllTimersAsync() + await assertion + expect(addAlertMock).toHaveBeenCalledWith( + 'toastMessages.failedToExportModel:{"format":"OBJ"}' + ) + }) + }) + + describe('exportSTL', () => { + it('uses the direct-URL fast path for matching .stl URLs', async () => { + const blob = new Blob(['x']) + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ blob: () => Promise.resolve(blob) }) + ) + + await ModelExporter.exportSTL( + new THREE.Object3D(), + 'out.stl', + 'http://example.com/api/view?filename=src.stl' + ) + + expect(downloadBlobMock).toHaveBeenCalledWith('out.stl', blob) + expect(stlParseMock).not.toHaveBeenCalled() + vi.unstubAllGlobals() + }) + + it('serializes via STLExporter and downloads as text when there is no direct URL', async () => { + stlParseMock.mockReturnValue('solid model') + + const promise = ModelExporter.exportSTL(new THREE.Object3D(), 'out.stl') + await vi.runAllTimersAsync() + await promise + + expect(stlParseMock).toHaveBeenCalled() + expect(downloadBlobMock).toHaveBeenCalledWith('out.stl', expect.any(Blob)) + }) + + it('alerts and rethrows when STLExporter throws', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + stlParseMock.mockImplementation(() => { + throw new Error('stl fail') + }) + + const promise = ModelExporter.exportSTL(new THREE.Object3D(), 'out.stl') + const assertion = expect(promise).rejects.toThrow('stl fail') + await vi.runAllTimersAsync() + await assertion + expect(addAlertMock).toHaveBeenCalledWith( + 'toastMessages.failedToExportModel:{"format":"STL"}' + ) + }) + }) +}) diff --git a/src/extensions/core/load3d/PointCloudModelAdapter.test.ts b/src/extensions/core/load3d/PointCloudModelAdapter.test.ts index 96f227f761..dd6f56d573 100644 --- a/src/extensions/core/load3d/PointCloudModelAdapter.test.ts +++ b/src/extensions/core/load3d/PointCloudModelAdapter.test.ts @@ -65,10 +65,10 @@ describe('PointCloudModelAdapter', () => { expect([...adapter.extensions]).toEqual(['ply']) }) - it('identifies as pointCloud with rebuild + gizmo/fit disabled', () => { + it('identifies as pointCloud with material rebuild + fit-to-viewer + lighting + export, gizmo disabled', () => { const adapter = new PointCloudModelAdapter() expect(adapter.kind).toBe('pointCloud') - expect(adapter.capabilities.fitToViewer).toBe(false) + expect(adapter.capabilities.fitToViewer).toBe(true) expect(adapter.capabilities.requiresMaterialRebuild).toBe(true) expect(adapter.capabilities.gizmoTransform).toBe(false) expect(adapter.capabilities.lighting).toBe(true) diff --git a/src/extensions/core/load3d/PointCloudModelAdapter.ts b/src/extensions/core/load3d/PointCloudModelAdapter.ts index 262d0a42e9..74b6cf276e 100644 --- a/src/extensions/core/load3d/PointCloudModelAdapter.ts +++ b/src/extensions/core/load3d/PointCloudModelAdapter.ts @@ -10,6 +10,7 @@ import type { ModelAdapterCapabilities, ModelLoadContext } from './ModelAdapter' +import type { MaterialMode } from './interfaces' import { FastPLYLoader } from './loader/FastPLYLoader' export function getPLYEngine(): string { @@ -20,7 +21,7 @@ export class PointCloudModelAdapter implements ModelAdapter { readonly kind = 'pointCloud' as const readonly extensions = ['ply'] as const readonly capabilities: ModelAdapterCapabilities = { - fitToViewer: false, + fitToViewer: true, requiresMaterialRebuild: true, gizmoTransform: false, lighting: true, @@ -43,7 +44,7 @@ export class PointCloudModelAdapter implements ModelAdapter { const plyGeometry = isASCII && getPLYEngine() === 'fastply' ? this.fastPlyLoader.parse(arrayBuffer) - : this.plyLoader.parse(arrayBuffer) + : (this.plyLoader.setPath(path), this.plyLoader.parse(arrayBuffer)) ctx.setOriginalModel(plyGeometry) plyGeometry.computeVertexNormals() @@ -118,3 +119,43 @@ function buildMeshGroup( group.add(mesh) return group } + +export function buildPointCloudForMaterialMode( + originalGeometry: THREE.BufferGeometry, + mode: MaterialMode, + standardMaterial: THREE.MeshStandardMaterial, + originalMaterials: WeakMap<THREE.Mesh, THREE.Material | THREE.Material[]> +): THREE.Group { + const geometry = originalGeometry.clone() + const hasVertexColors = geometry.attributes.color !== undefined + + const ctx: ModelLoadContext = { + setOriginalModel: () => {}, + registerOriginalMaterial: (mesh, material) => + originalMaterials.set(mesh, material), + standardMaterial, + materialMode: mode + } + + if (mode === 'pointCloud') { + return buildPointsGroup(ctx, geometry, hasVertexColors) + } + + const group = buildMeshGroup(ctx, geometry, hasVertexColors) + + if (mode === 'normal' || mode === 'wireframe') { + const mesh = group.children[0] as THREE.Mesh + mesh.material = + mode === 'normal' + ? new THREE.MeshNormalMaterial({ + flatShading: false, + side: THREE.DoubleSide + }) + : new THREE.MeshBasicMaterial({ + color: 0xffffff, + wireframe: true + }) + } + + return group +} diff --git a/src/extensions/core/load3d/RecordingManager.test.ts b/src/extensions/core/load3d/RecordingManager.test.ts new file mode 100644 index 0000000000..06d6508144 --- /dev/null +++ b/src/extensions/core/load3d/RecordingManager.test.ts @@ -0,0 +1,317 @@ +import * as THREE from 'three' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import type { EventManagerInterface } from './interfaces' +import { RecordingManager } from './RecordingManager' + +const { downloadBlobMock } = vi.hoisted(() => ({ + downloadBlobMock: vi.fn() +})) + +vi.mock('@/base/common/downloadUtil', () => ({ + downloadBlob: downloadBlobMock +})) + +vi.mock('three', async (importOriginal) => { + const actual = await importOriginal<typeof THREE>() + // Avoid TextureLoader -> ImageLoader -> new Image() in happy-dom. + class StubTextureLoader { + load() { + return new actual.Texture() + } + } + return { ...actual, TextureLoader: StubTextureLoader } +}) + +type DataAvailableHandler = (event: { data: Blob }) => void +type StopHandler = () => void + +class MockMediaRecorder { + static instances: MockMediaRecorder[] = [] + ondataavailable: DataAvailableHandler | null = null + onstop: StopHandler | null = null + state: 'inactive' | 'recording' | 'paused' = 'inactive' + constructor( + public stream: MediaStream, + public options?: MediaRecorderOptions + ) { + MockMediaRecorder.instances.push(this) + } + start = vi.fn(() => { + this.state = 'recording' + }) + stop = vi.fn(() => { + this.state = 'inactive' + this.onstop?.() + }) + pushChunk(blob: Blob) { + this.ondataavailable?.({ data: blob }) + } +} + +function makeMockEventManager() { + return { + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + emitEvent: vi.fn() + } satisfies EventManagerInterface +} + +function makeStream(): MediaStream { + const tracks: { stop: ReturnType<typeof vi.fn> }[] = [{ stop: vi.fn() }] + return { + getTracks: () => tracks + } as unknown as MediaStream +} + +function makeRenderer(): THREE.WebGLRenderer { + const canvas = document.createElement('canvas') + canvas.width = 800 + canvas.height = 600 + return { domElement: canvas } as unknown as THREE.WebGLRenderer +} + +describe('RecordingManager', () => { + let scene: THREE.Scene + let renderer: THREE.WebGLRenderer + let events: ReturnType<typeof makeMockEventManager> + let manager: RecordingManager + let rafSpy: ReturnType<typeof vi.spyOn> + + beforeEach(() => { + vi.clearAllMocks() + MockMediaRecorder.instances = [] + vi.stubGlobal('MediaRecorder', MockMediaRecorder) + vi.stubGlobal('URL', { + ...URL, + createObjectURL: vi.fn(() => 'blob:mock'), + revokeObjectURL: vi.fn() + }) + // happy-dom canvases lack captureStream; stub it on the prototype so + // every canvas the production code creates gets a usable stream. + vi.spyOn( + HTMLCanvasElement.prototype as unknown as { + captureStream: (fps?: number) => MediaStream + }, + 'captureStream' + ).mockImplementation(makeStream) + // happy-dom returns null from getContext('2d'); production code throws + // without it. Provide a minimal context with the methods the manager calls. + vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({ + drawImage: vi.fn() + } as unknown as ReturnType<HTMLCanvasElement['getContext']>) + rafSpy = vi.spyOn(window, 'requestAnimationFrame').mockReturnValue(1) + vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {}) + + scene = new THREE.Scene() + renderer = makeRenderer() + events = makeMockEventManager() + manager = new RecordingManager(scene, renderer, events) + }) + + afterEach(() => { + vi.unstubAllGlobals() + vi.restoreAllMocks() + }) + + describe('construction', () => { + it('adds a hidden recording indicator sprite to the scene', () => { + const sprite = scene.children.find((c) => c instanceof THREE.Sprite) as + | THREE.Sprite + | undefined + + expect(sprite).toBeDefined() + expect(sprite!.visible).toBe(false) + }) + }) + + describe('startRecording', () => { + it('initializes a MediaRecorder, marks recording state, and emits recordingStarted', async () => { + await manager.startRecording() + + expect(MockMediaRecorder.instances).toHaveLength(1) + expect(MockMediaRecorder.instances[0].start).toHaveBeenCalledWith(100) + expect(manager.getIsRecording()).toBe(true) + expect(events.emitEvent).toHaveBeenCalledWith('recordingStarted', null) + }) + + it('shows the recording indicator sprite', async () => { + const sprite = scene.children.find( + (c) => c instanceof THREE.Sprite + ) as THREE.Sprite + + await manager.startRecording() + + expect(sprite.visible).toBe(true) + }) + + it('begins capturing frames via requestAnimationFrame', async () => { + await manager.startRecording() + expect(rafSpy).toHaveBeenCalled() + }) + + it('is idempotent — a second startRecording while already recording is ignored', async () => { + await manager.startRecording() + await manager.startRecording() + + expect(MockMediaRecorder.instances).toHaveLength(1) + }) + + it('emits recordingError when MediaRecorder construction fails', async () => { + vi.stubGlobal( + 'MediaRecorder', + class { + constructor() { + throw new Error('codec not supported') + } + } + ) + + await manager.startRecording() + + expect(events.emitEvent).toHaveBeenCalledWith( + 'recordingError', + expect.any(Error) + ) + expect(manager.getIsRecording()).toBe(false) + }) + }) + + describe('stopRecording', () => { + it('is a no-op when not currently recording', () => { + manager.stopRecording() + expect(events.emitEvent).not.toHaveBeenCalledWith( + 'recordingStopped', + expect.anything() + ) + }) + + it('hides the indicator, clears recording state, and emits recordingStopped', async () => { + await manager.startRecording() + const sprite = scene.children.find( + (c) => c instanceof THREE.Sprite + ) as THREE.Sprite + + manager.stopRecording() + + expect(sprite.visible).toBe(false) + expect(manager.getIsRecording()).toBe(false) + expect(events.emitEvent).toHaveBeenCalledWith( + 'recordingStopped', + expect.objectContaining({ hasRecording: false }) + ) + }) + + it('reports a non-zero duration after recording', async () => { + await manager.startRecording() + // Force a known startTime so duration math is deterministic. + ;( + manager as unknown as { recordingStartTime: number } + ).recordingStartTime = Date.now() - 2000 + + manager.stopRecording() + + expect(manager.getRecordingDuration()).toBeGreaterThanOrEqual(2) + }) + }) + + describe('hasRecording / getRecordingData', () => { + it('reports no recording until chunks have been received', async () => { + await manager.startRecording() + expect(manager.hasRecording()).toBe(false) + expect(manager.getRecordingData()).toBeNull() + }) + + it('returns a blob URL once chunks exist', async () => { + await manager.startRecording() + MockMediaRecorder.instances[0].pushChunk(new Blob(['x'])) + + expect(manager.hasRecording()).toBe(true) + expect(manager.getRecordingData()).toBe('blob:mock') + }) + + it('does not push zero-byte chunks', async () => { + await manager.startRecording() + MockMediaRecorder.instances[0].pushChunk(new Blob([])) + + expect(manager.hasRecording()).toBe(false) + }) + }) + + describe('exportRecording', () => { + it('emits a recordingError when there is nothing to export', () => { + manager.exportRecording() + + expect(events.emitEvent).toHaveBeenCalledWith( + 'recordingError', + expect.any(Error) + ) + expect(downloadBlobMock).not.toHaveBeenCalled() + }) + + it('downloads the blob with the requested filename and emits exportingRecording then recordingExported', async () => { + await manager.startRecording() + MockMediaRecorder.instances[0].pushChunk(new Blob(['x'])) + + manager.exportRecording('clip.webm') + + expect(downloadBlobMock).toHaveBeenCalledWith( + 'clip.webm', + expect.any(Blob) + ) + expect(events.emitEvent).toHaveBeenCalledWith('exportingRecording', null) + expect(events.emitEvent).toHaveBeenCalledWith('recordingExported', null) + }) + + it('uses the default filename when none is provided', async () => { + await manager.startRecording() + MockMediaRecorder.instances[0].pushChunk(new Blob(['x'])) + + manager.exportRecording() + + expect(downloadBlobMock).toHaveBeenCalledWith( + 'scene-recording.mp4', + expect.any(Blob) + ) + }) + }) + + describe('clearRecording', () => { + it('drops all chunks, resets duration, and emits recordingCleared', async () => { + await manager.startRecording() + MockMediaRecorder.instances[0].pushChunk(new Blob(['x'])) + manager.stopRecording() + + manager.clearRecording() + + expect(manager.hasRecording()).toBe(false) + expect(manager.getRecordingDuration()).toBe(0) + expect(events.emitEvent).toHaveBeenCalledWith('recordingCleared', null) + }) + }) + + describe('dispose', () => { + it('removes the indicator sprite from the scene and disposes its material', async () => { + const sprite = scene.children.find( + (c) => c instanceof THREE.Sprite + ) as THREE.Sprite + const disposeSpy = vi.spyOn( + sprite.material as THREE.SpriteMaterial, + 'dispose' + ) + + manager.dispose() + + expect(scene.children).not.toContain(sprite) + expect(disposeSpy).toHaveBeenCalled() + }) + + it('stops an in-flight recording before disposing', async () => { + await manager.startRecording() + + manager.dispose() + + expect(MockMediaRecorder.instances[0].stop).toHaveBeenCalled() + }) + }) +}) diff --git a/src/extensions/core/load3d/SceneManager.test.ts b/src/extensions/core/load3d/SceneManager.test.ts new file mode 100644 index 0000000000..c087f27681 --- /dev/null +++ b/src/extensions/core/load3d/SceneManager.test.ts @@ -0,0 +1,546 @@ +import * as THREE from 'three' +import type { OrbitControls } from 'three/examples/jsm/controls/OrbitControls' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import type { EventManagerInterface } from './interfaces' +import Load3dUtils from './Load3dUtils' +import { SceneManager } from './SceneManager' + +const { mockTextureLoad } = vi.hoisted(() => ({ + mockTextureLoad: vi.fn() +})) + +vi.mock('./Load3dUtils', () => ({ + default: { + splitFilePath: vi.fn(), + getResourceURL: vi.fn() + } +})) + +vi.mock('three', async (importOriginal) => { + const actual = await importOriginal<typeof THREE>() + class StubTextureLoader { + load = mockTextureLoad + } + return { ...actual, TextureLoader: StubTextureLoader } +}) + +function makeMockEventManager() { + return { + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + emitEvent: vi.fn() + } satisfies EventManagerInterface +} + +function makeRenderer() { + const canvas = document.createElement('canvas') + Object.defineProperty(canvas, 'clientWidth', { + configurable: true, + value: 800 + }) + Object.defineProperty(canvas, 'clientHeight', { + configurable: true, + value: 600 + }) + canvas.width = 800 + canvas.height = 600 + vi.spyOn(canvas, 'toDataURL').mockReturnValue('data:image/png;base64,FAKE') + return { + domElement: canvas, + setClearColor: vi.fn(), + setSize: vi.fn(), + render: vi.fn(), + clear: vi.fn(), + getClearColor: vi.fn().mockReturnValue(new THREE.Color(0xffffff)), + getClearAlpha: vi.fn().mockReturnValue(1), + toneMapping: THREE.NoToneMapping, + toneMappingExposure: 1, + outputColorSpace: THREE.SRGBColorSpace + } as unknown as THREE.WebGLRenderer +} + +function makeImageTexture(width = 200, height = 100): THREE.Texture { + const texture = new THREE.Texture() + ;(texture as unknown as { image: { width: number; height: number } }).image = + { + width, + height + } + return texture +} + +describe('SceneManager', () => { + let renderer: THREE.WebGLRenderer + let camera: THREE.PerspectiveCamera + let events: ReturnType<typeof makeMockEventManager> + let manager: SceneManager + + beforeEach(() => { + vi.clearAllMocks() + renderer = makeRenderer() + camera = new THREE.PerspectiveCamera() + events = makeMockEventManager() + manager = new SceneManager( + renderer, + () => camera, + () => ({}) as unknown as OrbitControls, + events + ) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + describe('construction', () => { + it('builds the main scene with a grid helper at the origin', () => { + expect(manager.scene).toBeInstanceOf(THREE.Scene) + expect(manager.gridHelper).toBeInstanceOf(THREE.GridHelper) + expect(manager.scene.children).toContain(manager.gridHelper) + }) + + it('builds a separate background scene with a tiled mesh', () => { + expect(manager.backgroundScene).toBeInstanceOf(THREE.Scene) + expect(manager.backgroundMesh).toBeInstanceOf(THREE.Mesh) + expect(manager.backgroundScene.children).toContain(manager.backgroundMesh) + expect(manager.backgroundColorMaterial).toBeInstanceOf( + THREE.MeshBasicMaterial + ) + }) + + it('initializes the background to color mode at the default color', () => { + expect(manager.currentBackgroundType).toBe('color') + expect(manager.currentBackgroundColor).toBe('#282828') + expect(manager.backgroundRenderMode).toBe('tiled') + }) + }) + + describe('toggleGrid', () => { + it('hides and shows the grid and emits showGridChange', () => { + manager.toggleGrid(false) + expect(manager.gridHelper.visible).toBe(false) + expect(events.emitEvent).toHaveBeenCalledWith('showGridChange', false) + + manager.toggleGrid(true) + expect(manager.gridHelper.visible).toBe(true) + expect(events.emitEvent).toHaveBeenCalledWith('showGridChange', true) + }) + }) + + describe('setBackgroundColor', () => { + it('updates the material color and emits backgroundColorChange', () => { + manager.setBackgroundColor('#ff0000') + + expect(manager.currentBackgroundColor).toBe('#ff0000') + expect(manager.currentBackgroundType).toBe('color') + expect(events.emitEvent).toHaveBeenCalledWith( + 'backgroundColorChange', + '#ff0000' + ) + }) + + it('clears any prior texture-based scene background', () => { + manager.scene.background = makeImageTexture() + + manager.setBackgroundColor('#abcdef') + + expect(manager.scene.background).toBeNull() + }) + + it('demotes panorama mode back to tiled and emits the change', () => { + manager.backgroundRenderMode = 'panorama' + + manager.setBackgroundColor('#abcdef') + + expect(manager.backgroundRenderMode).toBe('tiled') + expect(events.emitEvent).toHaveBeenCalledWith( + 'backgroundRenderModeChange', + 'tiled' + ) + }) + + it('disposes any prior background texture', () => { + const texture = makeImageTexture() + const dispose = vi.spyOn(texture, 'dispose') + manager.backgroundTexture = texture + + manager.setBackgroundColor('#000000') + + expect(dispose).toHaveBeenCalled() + expect(manager.backgroundTexture).toBeNull() + }) + }) + + describe('setBackgroundImage', () => { + it('falls back to setBackgroundColor when given an empty path', async () => { + const setBackgroundColor = vi.spyOn(manager, 'setBackgroundColor') + + await manager.setBackgroundImage('') + + expect(setBackgroundColor).toHaveBeenCalledWith( + manager.currentBackgroundColor + ) + }) + + it('emits a loading-start event before fetching', async () => { + vi.mocked(Load3dUtils.splitFilePath).mockReturnValue(['', 'bg.png']) + vi.mocked(Load3dUtils.getResourceURL).mockReturnValue('/api/view?bg.png') + mockTextureLoad.mockImplementation( + (_url: string, resolve: (t: THREE.Texture) => void) => + resolve(makeImageTexture()) + ) + + const promise = manager.setBackgroundImage('bg.png') + expect(events.emitEvent).toHaveBeenCalledWith( + 'backgroundImageLoadingStart', + null + ) + await promise + }) + + it('rewrites temp/output subfolders to a flat path with the right type', async () => { + vi.mocked(Load3dUtils.splitFilePath).mockReturnValue(['temp', 'out.png']) + vi.mocked(Load3dUtils.getResourceURL).mockReturnValue('/view?out.png') + mockTextureLoad.mockImplementation( + (_url: string, resolve: (t: THREE.Texture) => void) => + resolve(makeImageTexture()) + ) + + await manager.setBackgroundImage('temp/out.png') + + expect(Load3dUtils.getResourceURL).toHaveBeenCalledWith( + '', + 'out.png', + 'temp' + ) + }) + + it('prefixes /api when getResourceURL returns a non-/api URL', async () => { + vi.mocked(Load3dUtils.splitFilePath).mockReturnValue(['', 'bg.png']) + vi.mocked(Load3dUtils.getResourceURL).mockReturnValue('/view?bg.png') + const captured: string[] = [] + mockTextureLoad.mockImplementation( + (url: string, resolve: (t: THREE.Texture) => void) => { + captured.push(url) + resolve(makeImageTexture()) + } + ) + + await manager.setBackgroundImage('bg.png') + + expect(captured[0]).toBe('/api/view?bg.png') + }) + + it('in tiled mode, swaps the background mesh material to use the new texture', async () => { + vi.mocked(Load3dUtils.splitFilePath).mockReturnValue(['', 'bg.png']) + vi.mocked(Load3dUtils.getResourceURL).mockReturnValue('/api/bg') + const texture = makeImageTexture() + mockTextureLoad.mockImplementation( + (_url: string, resolve: (t: THREE.Texture) => void) => resolve(texture) + ) + + await manager.setBackgroundImage('bg.png') + + expect(manager.currentBackgroundType).toBe('image') + expect(manager.backgroundTexture).toBe(texture) + expect(manager.backgroundMesh!.material).not.toBe( + manager.backgroundColorMaterial + ) + expect(events.emitEvent).toHaveBeenCalledWith( + 'backgroundImageChange', + 'bg.png' + ) + expect(events.emitEvent).toHaveBeenCalledWith( + 'backgroundImageLoadingEnd', + null + ) + }) + + it('in panorama mode, assigns the texture as the scene background with equirectangular mapping', async () => { + manager.backgroundRenderMode = 'panorama' + vi.mocked(Load3dUtils.splitFilePath).mockReturnValue(['', 'bg.png']) + vi.mocked(Load3dUtils.getResourceURL).mockReturnValue('/api/bg') + const texture = makeImageTexture() + mockTextureLoad.mockImplementation( + (_url: string, resolve: (t: THREE.Texture) => void) => resolve(texture) + ) + + await manager.setBackgroundImage('bg.png') + + expect(manager.scene.background).toBe(texture) + expect(texture.mapping).toBe(THREE.EquirectangularReflectionMapping) + }) + + it('disposes a previously loaded background texture before assigning the new one', async () => { + const previous = makeImageTexture() + const disposePrev = vi.spyOn(previous, 'dispose') + manager.backgroundTexture = previous + + vi.mocked(Load3dUtils.splitFilePath).mockReturnValue(['', 'bg.png']) + vi.mocked(Load3dUtils.getResourceURL).mockReturnValue('/api/bg') + mockTextureLoad.mockImplementation( + (_url: string, resolve: (t: THREE.Texture) => void) => + resolve(makeImageTexture()) + ) + + await manager.setBackgroundImage('bg.png') + + expect(disposePrev).toHaveBeenCalled() + }) + + it('on load failure, emits loading-end and falls back to a color background', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + vi.mocked(Load3dUtils.splitFilePath).mockReturnValue(['', 'bg.png']) + vi.mocked(Load3dUtils.getResourceURL).mockReturnValue('/api/bg') + mockTextureLoad.mockImplementation( + ( + _url: string, + _resolve: unknown, + _onProgress: unknown, + reject: (e: Error) => void + ) => reject(new Error('load failed')) + ) + const setBackgroundColor = vi.spyOn(manager, 'setBackgroundColor') + + await manager.setBackgroundImage('bg.png') + + expect(events.emitEvent).toHaveBeenCalledWith( + 'backgroundImageLoadingEnd', + null + ) + expect(setBackgroundColor).toHaveBeenCalledWith( + manager.currentBackgroundColor + ) + }) + }) + + describe('removeBackgroundImage', () => { + it('reverts to the current color and emits loading-end', () => { + const setBackgroundColor = vi.spyOn(manager, 'setBackgroundColor') + + manager.removeBackgroundImage() + + expect(setBackgroundColor).toHaveBeenCalledWith( + manager.currentBackgroundColor + ) + expect(events.emitEvent).toHaveBeenCalledWith( + 'backgroundImageLoadingEnd', + null + ) + }) + }) + + describe('setBackgroundRenderMode', () => { + it('is a no-op when the requested mode equals the current mode', () => { + events.emitEvent.mockClear() + + manager.setBackgroundRenderMode('tiled') + + expect(events.emitEvent).not.toHaveBeenCalled() + }) + + it('switches to panorama on a color background and just emits the change', () => { + manager.setBackgroundRenderMode('panorama') + + expect(manager.backgroundRenderMode).toBe('panorama') + expect(events.emitEvent).toHaveBeenCalledWith( + 'backgroundRenderModeChange', + 'panorama' + ) + }) + + it('promotes an image background to scene.background when switching to panorama', () => { + manager.currentBackgroundType = 'image' + const texture = makeImageTexture() + manager.backgroundTexture = texture + + manager.setBackgroundRenderMode('panorama') + + expect(manager.scene.background).toBe(texture) + expect(texture.mapping).toBe(THREE.EquirectangularReflectionMapping) + }) + + it('demotes back to tiled by clearing scene.background and updating the mesh map', () => { + manager.currentBackgroundType = 'image' + const texture = makeImageTexture() + manager.backgroundTexture = texture + manager.scene.background = texture + manager.backgroundRenderMode = 'panorama' + + manager.setBackgroundRenderMode('tiled') + + expect(manager.scene.background).toBeNull() + const mat = manager.backgroundMesh!.material as THREE.MeshBasicMaterial + expect(mat.map).toBe(texture) + // THREE's `needsUpdate` is a write-only setter — reading is undefined. + // Asserting the map swap is sufficient to validate the demote path. + }) + }) + + describe('updateBackgroundSize', () => { + it('does nothing without a texture or mesh', () => { + const mesh = new THREE.Mesh( + new THREE.PlaneGeometry(1, 1), + new THREE.MeshBasicMaterial() + ) + const before = mesh.scale.toArray() + + manager.updateBackgroundSize(null, mesh, 100, 100) + + expect(mesh.scale.toArray()).toEqual(before) + }) + + it('does nothing without a mesh', () => { + expect(() => + manager.updateBackgroundSize(makeImageTexture(), null, 100, 100) + ).not.toThrow() + }) + + it('does nothing when the mesh material has no map', () => { + const texture = makeImageTexture(400, 100) + const mesh = new THREE.Mesh( + new THREE.PlaneGeometry(1, 1), + new THREE.MeshBasicMaterial() // no map + ) + const before = mesh.scale.toArray() + + manager.updateBackgroundSize(texture, mesh, 200, 100) + + expect(mesh.scale.toArray()).toEqual(before) + }) + + it('scales horizontally when the image is wider than the target', () => { + const texture = makeImageTexture(400, 100) // imageAspect = 4 + const mat = new THREE.MeshBasicMaterial({ map: texture }) + const mesh = new THREE.Mesh(new THREE.PlaneGeometry(1, 1), mat) + + manager.updateBackgroundSize(texture, mesh, 200, 100) // targetAspect = 2 + + expect(mesh.scale.x).toBeCloseTo(2) + expect(mesh.scale.y).toBe(1) + }) + + it('scales vertically when the image is taller than the target', () => { + const texture = makeImageTexture(100, 400) // imageAspect = 0.25 + const mat = new THREE.MeshBasicMaterial({ map: texture }) + const mesh = new THREE.Mesh(new THREE.PlaneGeometry(1, 1), mat) + + manager.updateBackgroundSize(texture, mesh, 200, 100) // targetAspect = 2 + + expect(mesh.scale.x).toBe(1) + expect(mesh.scale.y).toBeCloseTo(8) + }) + }) + + describe('handleResize', () => { + it('updates background size when an image background is active', () => { + const texture = makeImageTexture(400, 100) + manager.backgroundTexture = texture + manager.currentBackgroundType = 'image' + ;(manager.backgroundMesh!.material as THREE.MeshBasicMaterial).map = + texture + const update = vi.spyOn(manager, 'updateBackgroundSize') + + manager.handleResize(800, 600) + + expect(update).toHaveBeenCalledWith( + texture, + manager.backgroundMesh, + 800, + 600 + ) + }) + + it('does nothing when only a color background is active', () => { + const update = vi.spyOn(manager, 'updateBackgroundSize') + + manager.handleResize(800, 600) + + expect(update).not.toHaveBeenCalled() + }) + }) + + describe('getCurrentBackgroundInfo', () => { + it('returns the color when in color mode', () => { + manager.setBackgroundColor('#abc123') + + expect(manager.getCurrentBackgroundInfo()).toEqual({ + type: 'color', + value: '#abc123' + }) + }) + + it('returns an empty value when in image mode', () => { + manager.currentBackgroundType = 'image' + + expect(manager.getCurrentBackgroundInfo()).toEqual({ + type: 'image', + value: '' + }) + }) + }) + + describe('captureScene', () => { + it('returns three data URLs and restores the renderer to its original state', async () => { + const result = await manager.captureScene(400, 300) + + expect(result.scene).toBe('data:image/png;base64,FAKE') + expect(result.mask).toBe('data:image/png;base64,FAKE') + expect(result.normal).toBe('data:image/png;base64,FAKE') + // Renderer.setSize is called once with the capture size and once to restore. + expect(renderer.setSize).toHaveBeenCalledWith(400, 300) + expect(renderer.setSize).toHaveBeenLastCalledWith(800, 600) + }) + + it('restores grid visibility after rendering the normal pass', async () => { + manager.gridHelper.visible = true + + await manager.captureScene(100, 100) + + expect(manager.gridHelper.visible).toBe(true) + }) + + it('rejects when the renderer throws during capture', async () => { + vi.mocked(renderer.render).mockImplementationOnce(() => { + throw new Error('renderer fail') + }) + + await expect(manager.captureScene(100, 100)).rejects.toThrow( + 'renderer fail' + ) + }) + }) + + describe('dispose', () => { + it('disposes the texture, color material, and mesh resources, then clears scenes', () => { + const texture = makeImageTexture() + const disposeTexture = vi.spyOn(texture, 'dispose') + manager.backgroundTexture = texture + const disposeColorMat = vi.spyOn( + manager.backgroundColorMaterial!, + 'dispose' + ) + const disposeGeometry = vi.spyOn( + manager.backgroundMesh!.geometry, + 'dispose' + ) + + manager.dispose() + + expect(disposeTexture).toHaveBeenCalled() + expect(disposeColorMat).toHaveBeenCalled() + expect(disposeGeometry).toHaveBeenCalled() + expect(manager.scene.children).toHaveLength(0) + expect(manager.backgroundScene.children).toHaveLength(0) + }) + + it('clears the scene background when one was set', () => { + manager.scene.background = makeImageTexture() + + manager.dispose() + + expect(manager.scene.background).toBeNull() + }) + }) +}) diff --git a/src/extensions/core/load3d/SceneModelManager.test.ts b/src/extensions/core/load3d/SceneModelManager.test.ts index 88613848b4..cc9e122980 100644 --- a/src/extensions/core/load3d/SceneModelManager.test.ts +++ b/src/extensions/core/load3d/SceneModelManager.test.ts @@ -1,8 +1,10 @@ import * as THREE from 'three' import { describe, expect, it, vi } from 'vitest' -import type { EventManagerInterface } from './interfaces' +import { DEFAULT_MODEL_CAPABILITIES } from './ModelAdapter' +import type { ModelAdapterCapabilities } from './ModelAdapter' import { SceneModelManager } from './SceneModelManager' +import type { EventManagerInterface } from './interfaces' function createMockRenderer(): THREE.WebGLRenderer { return { @@ -23,6 +25,7 @@ function createManager( overrides: { scene?: THREE.Scene eventManager?: EventManagerInterface + capabilities?: Partial<ModelAdapterCapabilities> } = {} ) { const scene = overrides.scene ?? new THREE.Scene() @@ -32,6 +35,10 @@ function createManager( const getActiveCamera = () => camera const setupCamera = vi.fn() const setupGizmo = vi.fn() + const capabilities: ModelAdapterCapabilities = { + ...DEFAULT_MODEL_CAPABILITIES, + ...overrides.capabilities + } const manager = new SceneModelManager( scene, @@ -39,7 +46,8 @@ function createManager( eventManager, getActiveCamera, setupCamera, - setupGizmo + setupGizmo, + () => capabilities ) return { @@ -53,6 +61,37 @@ function createManager( } } +function createManagerWithPose(opts: { + capabilities?: Partial<ModelAdapterCapabilities> + pose: { size: THREE.Vector3; center: THREE.Vector3 } | null +}) { + const scene = new THREE.Scene() + const renderer = createMockRenderer() + const eventManager = createMockEventManager() + const camera = new THREE.PerspectiveCamera() + const setupCamera = vi.fn() + const setupGizmo = vi.fn() + const capabilities: ModelAdapterCapabilities = { + ...DEFAULT_MODEL_CAPABILITIES, + ...opts.capabilities + } + + const manager = new SceneModelManager( + scene, + renderer, + eventManager, + () => camera, + setupCamera, + setupGizmo, + () => capabilities, + () => null, + () => {}, + () => opts.pose + ) + + return { manager, scene, setupCamera, setupGizmo } +} + function createMeshModel(name = 'TestModel'): THREE.Group { const geometry = new THREE.BoxGeometry(1, 1, 1) const material = new THREE.MeshStandardMaterial({ color: 0xff0000 }) @@ -63,6 +102,16 @@ function createMeshModel(name = 'TestModel'): THREE.Group { return group } +function createPointsModel(name = 'TestModel'): THREE.Group { + const geometry = new THREE.BufferGeometry() + const material = new THREE.PointsMaterial({ color: 0xff0000 }) + const points = new THREE.Points(geometry, material) + const group = new THREE.Group() + group.name = name + group.add(points) + return group +} + describe('SceneModelManager', () => { describe('constructor', () => { it('initializes default state', () => { @@ -190,6 +239,47 @@ describe('SceneModelManager', () => { '+z' ) }) + + it('uses the adapter default pose when fitToViewer is disabled and a pose is provided', async () => { + const pose = { + size: new THREE.Vector3(5, 5, 5), + center: new THREE.Vector3(0, 2.5, 0) + } + const { manager, scene, setupCamera, setupGizmo } = createManagerWithPose( + { + capabilities: { fitToViewer: false }, + pose + } + ) + const model = createMeshModel() + + await manager.setupModel(model) + + expect(scene.children).toContain(model) + expect(setupCamera).toHaveBeenCalledWith(pose.size, pose.center) + + expect(setupGizmo).not.toHaveBeenCalled() + }) + + it('falls back to the full setup path when fitToViewer is disabled but no default pose is provided', async () => { + const { manager, scene, setupCamera, setupGizmo } = createManagerWithPose( + { + capabilities: { fitToViewer: false }, + pose: null + } + ) + const model = createMeshModel() + + await manager.setupModel(model) + + expect(scene.children).toContain(model) + + expect(setupCamera).toHaveBeenCalled() + const callArgs = setupCamera.mock.calls[0] + expect(callArgs[0]).toBeInstanceOf(THREE.Vector3) + expect(callArgs[1]).toBeInstanceOf(THREE.Vector3) + expect(setupGizmo).toHaveBeenCalledWith(model) + }) }) describe('setOriginalModel', () => { @@ -231,6 +321,20 @@ describe('SceneModelManager', () => { expect(geoDispose).toHaveBeenCalled() expect(matDispose).toHaveBeenCalled() }) + + it('disposes points geometry and materials', async () => { + const { manager } = createManager() + const model = createPointsModel() + const points = model.children[0] as THREE.Points + const geoDispose = vi.spyOn(points.geometry, 'dispose') + const matDispose = vi.spyOn(points.material as THREE.Material, 'dispose') + + await manager.setupModel(model) + manager.clearModel() + + expect(geoDispose).toHaveBeenCalled() + expect(matDispose).toHaveBeenCalled() + }) }) describe('reset', () => { @@ -575,29 +679,11 @@ describe('SceneModelManager', () => { }) }) - describe('containsSplatMesh', () => { - it('returns false when no model', () => { - const { manager } = createManager() - expect(manager.containsSplatMesh()).toBe(false) - }) - - it('returns false for regular model', async () => { - const { manager } = createManager() - const model = createMeshModel() - await manager.setupModel(model) - - expect(manager.containsSplatMesh()).toBe(false) - }) - - it('returns false for explicit null argument', () => { - const { manager } = createManager() - expect(manager.containsSplatMesh(null)).toBe(false) - }) - }) - describe('PLY mode switching', () => { function createPLYManager() { - const ctx = createManager() + const ctx = createManager({ + capabilities: { requiresMaterialRebuild: true } + }) const geometry = new THREE.BufferGeometry() geometry.setAttribute( 'position', @@ -655,7 +741,9 @@ describe('SceneModelManager', () => { }) it('uses vertex colors when available', () => { - const { manager, scene } = createManager() + const { manager, scene } = createManager({ + capabilities: { requiresMaterialRebuild: true } + }) const geometry = new THREE.BufferGeometry() geometry.setAttribute( 'position', diff --git a/src/extensions/core/load3d/SceneModelManager.ts b/src/extensions/core/load3d/SceneModelManager.ts index a896fa7741..5743a4e609 100644 --- a/src/extensions/core/load3d/SceneModelManager.ts +++ b/src/extensions/core/load3d/SceneModelManager.ts @@ -1,12 +1,14 @@ -import { SplatMesh } from '@sparkjsdev/spark' import * as THREE from 'three' -import { type GLTF } from 'three/examples/jsm/loaders/GLTFLoader' +import type { GLTF } from 'three/examples/jsm/loaders/GLTFLoader' -import { - type EventManagerInterface, - type MaterialMode, - type ModelManagerInterface, - type UpDirection +import { DEFAULT_MODEL_CAPABILITIES } from './ModelAdapter' +import type { ModelAdapterCapabilities } from './ModelAdapter' +import { buildPointCloudForMaterialMode } from './PointCloudModelAdapter' +import type { + EventManagerInterface, + MaterialMode, + ModelManagerInterface, + UpDirection } from './interfaces' export class SceneModelManager implements ModelManagerInterface { @@ -39,6 +41,13 @@ export class SceneModelManager implements ModelManagerInterface { private activeCamera: THREE.Camera private setupCamera: (size: THREE.Vector3, center: THREE.Vector3) => void private setupGizmo: (model: THREE.Object3D) => void + private getCurrentCapabilities: () => ModelAdapterCapabilities + private getBoundsFromAdapter: (model: THREE.Object3D) => THREE.Box3 | null + private disposeModelViaAdapter: (model: THREE.Object3D) => void + private getDefaultCameraPose: () => { + size: THREE.Vector3 + center: THREE.Vector3 + } | null constructor( scene: THREE.Scene, @@ -46,7 +55,16 @@ export class SceneModelManager implements ModelManagerInterface { eventManager: EventManagerInterface, getActiveCamera: () => THREE.Camera, setupCamera: (size: THREE.Vector3, center: THREE.Vector3) => void, - setupGizmo: (model: THREE.Object3D) => void + setupGizmo: (model: THREE.Object3D) => void, + getCurrentCapabilities: () => ModelAdapterCapabilities = () => + DEFAULT_MODEL_CAPABILITIES, + getBoundsFromAdapter: (model: THREE.Object3D) => THREE.Box3 | null = () => + null, + disposeModelViaAdapter: (model: THREE.Object3D) => void = () => {}, + getDefaultCameraPose: () => { + size: THREE.Vector3 + center: THREE.Vector3 + } | null = () => null ) { this.scene = scene this.renderer = renderer @@ -55,6 +73,10 @@ export class SceneModelManager implements ModelManagerInterface { this.setupCamera = setupCamera this.textureLoader = new THREE.TextureLoader() this.setupGizmo = setupGizmo + this.getCurrentCapabilities = getCurrentCapabilities + this.getBoundsFromAdapter = getBoundsFromAdapter + this.disposeModelViaAdapter = disposeModelViaAdapter + this.getDefaultCameraPose = getDefaultCameraPose this.normalMaterial = new THREE.MeshNormalMaterial({ flatShading: false, @@ -104,23 +126,11 @@ export class SceneModelManager implements ModelManagerInterface { }) } - private handlePLYModeSwitch(mode: MaterialMode): void { - if (!(this.originalModel instanceof THREE.BufferGeometry)) { - return - } - - const plyGeometry = this.originalModel.clone() - const hasVertexColors = plyGeometry.attributes.color !== undefined - - // Find and remove ALL MainModel instances by name to ensure deletion + private removeAllMainModelsFromScene(): void { const oldMainModels: THREE.Object3D[] = [] this.scene.traverse((obj) => { - if (obj.name === 'MainModel') { - oldMainModels.push(obj) - } + if (obj.name === 'MainModel') oldMainModels.push(obj) }) - - // Remove and dispose all found MainModels oldMainModels.forEach((oldModel) => { oldModel.traverse((child) => { if (child instanceof THREE.Mesh || child instanceof THREE.Points) { @@ -132,103 +142,31 @@ export class SceneModelManager implements ModelManagerInterface { } } }) + this.disposeModelViaAdapter(oldModel) this.scene.remove(oldModel) }) + } + private rebuildForMaterialMode(mode: MaterialMode): void { + if (!(this.originalModel instanceof THREE.BufferGeometry)) return + + this.removeAllMainModelsFromScene() this.currentModel = null - let newModel: THREE.Object3D - - if (mode === 'pointCloud') { - // Use Points rendering for point cloud mode - plyGeometry.computeBoundingSphere() - if (plyGeometry.boundingSphere) { - const center = plyGeometry.boundingSphere.center - const radius = plyGeometry.boundingSphere.radius - - plyGeometry.translate(-center.x, -center.y, -center.z) - - if (radius > 0) { - const scale = 1.0 / radius - plyGeometry.scale(scale, scale, scale) - } - } - - const pointMaterial = hasVertexColors - ? new THREE.PointsMaterial({ - size: 0.005, - vertexColors: true, - sizeAttenuation: true - }) - : new THREE.PointsMaterial({ - size: 0.005, - color: 0xcccccc, - sizeAttenuation: true - }) - - const points = new THREE.Points(plyGeometry, pointMaterial) - newModel = new THREE.Group() - newModel.add(points) - } else { - // Use Mesh rendering for other modes - let meshMaterial: THREE.Material = hasVertexColors - ? new THREE.MeshStandardMaterial({ - vertexColors: true, - metalness: 0.0, - roughness: 0.5, - side: THREE.DoubleSide - }) - : this.standardMaterial.clone() - - if ( - !hasVertexColors && - meshMaterial instanceof THREE.MeshStandardMaterial - ) { - meshMaterial.side = THREE.DoubleSide - } - - const mesh = new THREE.Mesh(plyGeometry, meshMaterial) - this.originalMaterials.set(mesh, meshMaterial) - - newModel = new THREE.Group() - newModel.add(mesh) - - // Apply the requested material mode - if (mode === 'normal') { - mesh.material = new THREE.MeshNormalMaterial({ - flatShading: false, - side: THREE.DoubleSide - }) - } else if (mode === 'wireframe') { - mesh.material = new THREE.MeshBasicMaterial({ - color: 0xffffff, - wireframe: true - }) - } - } - - // Double check: remove any remaining MainModel before adding new one - const remainingMainModels: THREE.Object3D[] = [] - this.scene.traverse((obj) => { - if (obj.name === 'MainModel') { - remainingMainModels.push(obj) - } - }) - remainingMainModels.forEach((obj) => this.scene.remove(obj)) - - this.currentModel = newModel + const newModel = buildPointCloudForMaterialMode( + this.originalModel, + mode, + this.standardMaterial, + this.originalMaterials + ) newModel.name = 'MainModel' - // Setup the new model - if (mode === 'pointCloud') { - this.scene.add(newModel) - } else { + if (mode !== 'pointCloud') { const box = new THREE.Box3().setFromObject(newModel) const size = box.getSize(new THREE.Vector3()) const center = box.getCenter(new THREE.Vector3()) - const maxDim = Math.max(size.x, size.y, size.z) - const targetSize = 5 + const targetSize = this.getCurrentCapabilities().fitTargetSize const scale = targetSize / maxDim newModel.scale.multiplyScalar(scale) @@ -237,9 +175,10 @@ export class SceneModelManager implements ModelManagerInterface { box.getSize(size) newModel.position.set(-center.x, -box.min.y, -center.z) - this.scene.add(newModel) } + this.scene.add(newModel) + this.currentModel = newModel this.eventManager.emitEvent('materialModeChange', mode) } @@ -250,9 +189,8 @@ export class SceneModelManager implements ModelManagerInterface { this.materialMode = mode - // Handle PLY files specially - they need to be recreated for mode switch - if (this.originalModel instanceof THREE.BufferGeometry) { - this.handlePLYModeSwitch(mode) + if (this.getCurrentCapabilities().requiresMaterialRebuild) { + this.rebuildForMaterialMode(mode) return } @@ -390,7 +328,7 @@ export class SceneModelManager implements ModelManagerInterface { this.scene.remove(obj) obj.traverse((child) => { - if (child instanceof THREE.Mesh) { + if (child instanceof THREE.Mesh || child instanceof THREE.Points) { child.geometry?.dispose() if (Array.isArray(child.material)) { child.material.forEach((material) => material.dispose()) @@ -399,6 +337,7 @@ export class SceneModelManager implements ModelManagerInterface { } } }) + this.disposeModelViaAdapter(obj) }) this.reset() @@ -488,19 +427,23 @@ export class SceneModelManager implements ModelManagerInterface { this.scene.add(this.currentModel) } + private computeWorldBounds(model: THREE.Object3D): THREE.Box3 { + return ( + this.getBoundsFromAdapter(model) ?? new THREE.Box3().setFromObject(model) + ) + } + async setupModel(model: THREE.Object3D): Promise<void> { this.currentModel = model model.name = 'MainModel' - // Check if model is or contains a SplatMesh (3D Gaussian Splatting) - const isSplatModel = this.containsSplatMesh(model) - - if (isSplatModel) { - // SplatMesh handles its own rendering, just add to scene - this.scene.add(model) - // Set a default camera distance for splat models - this.setupCamera(new THREE.Vector3(5, 5, 5), new THREE.Vector3(0, 2.5, 0)) - return + if (!this.getCurrentCapabilities().fitToViewer) { + const pose = this.getDefaultCameraPose() + if (pose) { + this.scene.add(model) + this.setupCamera(pose.size, pose.center) + return + } } this.scene.add(model) @@ -514,7 +457,7 @@ export class SceneModelManager implements ModelManagerInterface { } this.setupModelMaterials(model) - const box = new THREE.Box3().setFromObject(model) + const box = this.computeWorldBounds(model) const size = box.getSize(new THREE.Vector3()) const center = box.getCenter(new THREE.Vector3()) @@ -524,32 +467,31 @@ export class SceneModelManager implements ModelManagerInterface { } fitToViewer(): void { - if (!this.currentModel || this.containsSplatMesh()) return + if (!this.currentModel || !this.getCurrentCapabilities().fitToViewer) return const model = this.currentModel - // Reset transform to compute from raw geometry (idempotent) model.scale.set(1, 1, 1) model.position.set(0, 0, 0) model.rotation.set(0, 0, 0) - const box = new THREE.Box3().setFromObject(model) + const box = this.computeWorldBounds(model) const size = box.getSize(new THREE.Vector3()) const center = box.getCenter(new THREE.Vector3()) const maxDim = Math.max(size.x, size.y, size.z) if (maxDim === 0) return - const targetSize = 5 + const targetSize = this.getCurrentCapabilities().fitTargetSize const scale = targetSize / maxDim model.scale.set(scale, scale, scale) - box.setFromObject(model) - box.getCenter(center) - box.getSize(size) + const scaledBox = this.computeWorldBounds(model) + scaledBox.getCenter(center) + scaledBox.getSize(size) - model.position.set(-center.x, -box.min.y, -center.z) + model.position.set(-center.x, -scaledBox.min.y, -center.z) - const newBox = new THREE.Box3().setFromObject(model) + const newBox = this.computeWorldBounds(model) const newSize = newBox.getSize(new THREE.Vector3()) const newCenter = newBox.getCenter(new THREE.Vector3()) @@ -557,17 +499,6 @@ export class SceneModelManager implements ModelManagerInterface { this.setupGizmo(model) } - containsSplatMesh(model?: THREE.Object3D | null): boolean { - const target = model ?? this.currentModel - if (!target) return false - if (target instanceof SplatMesh) return true - let found = false - target.traverse((child) => { - if (child instanceof SplatMesh) found = true - }) - return found - } - setOriginalModel(model: THREE.Object3D | THREE.BufferGeometry | GLTF): void { this.originalModel = model } diff --git a/src/extensions/core/load3d/SplatModelAdapter.test.ts b/src/extensions/core/load3d/SplatModelAdapter.test.ts index 4eddc60652..bd02bc11e5 100644 --- a/src/extensions/core/load3d/SplatModelAdapter.test.ts +++ b/src/extensions/core/load3d/SplatModelAdapter.test.ts @@ -1,21 +1,39 @@ import * as THREE from 'three' import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { ModelLoadContext } from './ModelAdapter' import * as ModelAdapterModule from './ModelAdapter' +import type { ModelLoadContext } from './ModelAdapter' import { SplatModelAdapter } from './SplatModelAdapter' -const { splatMeshCtor } = vi.hoisted(() => ({ - splatMeshCtor: vi.fn<(opts: { fileBytes: ArrayBuffer }) => void>() -})) +const splatMeshSpies = { + ctor: vi.fn<(opts: { fileBytes: ArrayBuffer }) => void>(), + dispose: vi.fn(), + getBoundingBox: vi.fn( + () => + new THREE.Box3(new THREE.Vector3(-1, -1, -1), new THREE.Vector3(1, 1, 1)) + ), + updateWorldMatrix: vi.fn() +} vi.mock('@sparkjsdev/spark', async () => { const three = await import('three') return { SplatMesh: class extends three.Object3D { + initialized = Promise.resolve() + dispose = splatMeshSpies.dispose + getBoundingBox = splatMeshSpies.getBoundingBox + constructor(opts: { fileBytes: ArrayBuffer }) { super() - splatMeshCtor(opts) + splatMeshSpies.ctor(opts) + } + + override updateWorldMatrix( + force: boolean, + updateChildren: boolean + ): void { + splatMeshSpies.updateWorldMatrix(force, updateChildren) + super.updateWorldMatrix(force, updateChildren) } } } @@ -32,7 +50,13 @@ function makeContext(): ModelLoadContext { describe('SplatModelAdapter', () => { beforeEach(() => { - splatMeshCtor.mockReset() + splatMeshSpies.ctor.mockClear() + splatMeshSpies.dispose.mockClear() + splatMeshSpies.getBoundingBox.mockClear() + splatMeshSpies.updateWorldMatrix.mockClear() + vi.spyOn(ModelAdapterModule, 'fetchModelData').mockResolvedValue( + new ArrayBuffer(8) + ) }) it('exposes splat capabilities on the adapter', () => { @@ -61,7 +85,7 @@ describe('SplatModelAdapter', () => { '/api/view?', 'scene.splat' ) - expect(splatMeshCtor).toHaveBeenCalledWith({ fileBytes: buf }) + expect(splatMeshSpies.ctor).toHaveBeenCalledWith({ fileBytes: buf }) expect(result).toBeInstanceOf(THREE.Group) expect(result.children).toHaveLength(1) @@ -69,6 +93,20 @@ describe('SplatModelAdapter', () => { expect(ctx.setOriginalModel).toHaveBeenCalledWith(result.children[0]) }) + it('rotates the splat 180° around X (OpenCV → three.js convention)', async () => { + const result = await new SplatModelAdapter().load( + makeContext(), + '/api/view?', + 'scene.splat' + ) + + const splat = result.children[0] + expect(splat.quaternion.x).toBe(1) + expect(splat.quaternion.y).toBe(0) + expect(splat.quaternion.z).toBe(0) + expect(splat.quaternion.w).toBe(0) + }) + it('propagates fetch errors', async () => { vi.spyOn(ModelAdapterModule, 'fetchModelData').mockRejectedValue( new Error('Failed to fetch model: 500') @@ -79,4 +117,72 @@ describe('SplatModelAdapter', () => { adapter.load(makeContext(), '/api/view?', 'scene.splat') ).rejects.toThrow('Failed to fetch model: 500') }) + + describe('computeBounds', () => { + it('returns the SplatMesh bounding box transformed to world space', async () => { + const adapter = new SplatModelAdapter() + const group = await adapter.load( + makeContext(), + '/api/view?', + 'scene.splat' + ) + const splat = group.children[0] + splat.position.set(10, 0, 0) + + const bounds = adapter.computeBounds(group) + + expect(bounds).toBeInstanceOf(THREE.Box3) + expect(splatMeshSpies.getBoundingBox).toHaveBeenCalledWith(false) + expect(splatMeshSpies.updateWorldMatrix).toHaveBeenCalledWith(true, false) + // Local bbox was [-1,-1,-1]→[1,1,1]; world matrix translates by +10 X + // (with the splat's quaternion applied to the inner mesh). + expect(bounds!.min.x).toBeCloseTo(9) + expect(bounds!.max.x).toBeCloseTo(11) + }) + + it('returns null when the first child is not a SplatMesh', () => { + const adapter = new SplatModelAdapter() + const group = new THREE.Group() + group.add(new THREE.Mesh()) + + expect(adapter.computeBounds(group)).toBeNull() + }) + }) + + describe('disposeModel', () => { + it('calls dispose on every SplatMesh in the model tree', async () => { + const adapter = new SplatModelAdapter() + const group = await adapter.load( + makeContext(), + '/api/view?', + 'scene.splat' + ) + + adapter.disposeModel(group) + + expect(splatMeshSpies.dispose).toHaveBeenCalledOnce() + }) + + it('is a no-op when the tree has no SplatMesh', () => { + const adapter = new SplatModelAdapter() + const group = new THREE.Group() + group.add(new THREE.Mesh()) + + expect(() => adapter.disposeModel(group)).not.toThrow() + }) + }) + + describe('defaultCameraPose', () => { + it('returns the (5,5,5) / (0,2.5,0) seat for self-sized splats', () => { + const adapter = new SplatModelAdapter() + const pose = adapter.defaultCameraPose() + + expect(pose.size.x).toBe(5) + expect(pose.size.y).toBe(5) + expect(pose.size.z).toBe(5) + expect(pose.center.x).toBe(0) + expect(pose.center.y).toBe(2.5) + expect(pose.center.z).toBe(0) + }) + }) }) diff --git a/src/extensions/core/load3d/SplatModelAdapter.ts b/src/extensions/core/load3d/SplatModelAdapter.ts index 0ae84eed95..719dce362c 100644 --- a/src/extensions/core/load3d/SplatModelAdapter.ts +++ b/src/extensions/core/load3d/SplatModelAdapter.ts @@ -12,13 +12,13 @@ export class SplatModelAdapter implements ModelAdapter { readonly kind = 'splat' as const readonly extensions = ['spz', 'splat', 'ksplat'] as const readonly capabilities: ModelAdapterCapabilities = { - fitToViewer: false, + fitToViewer: true, requiresMaterialRebuild: false, - gizmoTransform: false, + gizmoTransform: true, lighting: false, exportable: false, materialModes: [], - fitTargetSize: 5 + fitTargetSize: 20 } async load( @@ -29,10 +29,34 @@ export class SplatModelAdapter implements ModelAdapter { const arrayBuffer = await fetchModelData(path, filename) const splatMesh = new SplatMesh({ fileBytes: arrayBuffer }) + await splatMesh.initialized + splatMesh.quaternion.set(1, 0, 0, 0) ctx.setOriginalModel(splatMesh) const splatGroup = new THREE.Group() splatGroup.add(splatMesh) return splatGroup } + + computeBounds(model: THREE.Object3D): THREE.Box3 | null { + const splat = model.children[0] + if (!(splat instanceof SplatMesh)) return null + splat.updateWorldMatrix(true, false) + return splat.getBoundingBox(false).clone().applyMatrix4(splat.matrixWorld) + } + + disposeModel(model: THREE.Object3D): void { + model.traverse((child) => { + if (child instanceof SplatMesh) { + child.dispose() + } + }) + } + + defaultCameraPose(): { size: THREE.Vector3; center: THREE.Vector3 } { + return { + size: new THREE.Vector3(5, 5, 5), + center: new THREE.Vector3(0, 2.5, 0) + } + } } diff --git a/src/extensions/core/load3d/ViewHelperManager.test.ts b/src/extensions/core/load3d/ViewHelperManager.test.ts new file mode 100644 index 0000000000..4ea798d069 --- /dev/null +++ b/src/extensions/core/load3d/ViewHelperManager.test.ts @@ -0,0 +1,258 @@ +import * as THREE from 'three' +import type { OrbitControls } from 'three/examples/jsm/controls/OrbitControls' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import type { EventManagerInterface } from './interfaces' +import { ViewHelperManager } from './ViewHelperManager' + +interface MockViewHelperInstance { + camera: THREE.Camera + domElement: HTMLElement + animating: boolean + visible: boolean + center: THREE.Vector3 | null + update: ReturnType<typeof vi.fn> + dispose: ReturnType<typeof vi.fn> + handleClick: ReturnType<typeof vi.fn> +} + +const { viewHelperInstances, mockHandleClick } = vi.hoisted(() => ({ + viewHelperInstances: [] as MockViewHelperInstance[], + mockHandleClick: vi.fn() +})) + +vi.mock('three/examples/jsm/helpers/ViewHelper', () => { + class ViewHelper { + animating = false + visible = true + center: THREE.Vector3 | null = null + update = vi.fn() + dispose = vi.fn() + handleClick = mockHandleClick + constructor( + public camera: THREE.Camera, + public domElement: HTMLElement + ) { + viewHelperInstances.push(this as unknown as MockViewHelperInstance) + } + } + return { ViewHelper } +}) + +function makeMockEventManager() { + return { + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + emitEvent: vi.fn() + } satisfies EventManagerInterface +} + +function makeOrbitControls(target = new THREE.Vector3()) { + return { target } as unknown as OrbitControls +} + +describe('ViewHelperManager', () => { + let events: ReturnType<typeof makeMockEventManager> + let camera: THREE.PerspectiveCamera + let controls: OrbitControls + let manager: ViewHelperManager + + beforeEach(() => { + vi.clearAllMocks() + viewHelperInstances.length = 0 + events = makeMockEventManager() + camera = new THREE.PerspectiveCamera() + controls = makeOrbitControls() + manager = new ViewHelperManager( + {} as THREE.WebGLRenderer, + () => camera, + () => controls, + events + ) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + describe('createViewHelper', () => { + it('appends a 128x128 absolutely-positioned container to the parent element', () => { + const parent = document.createElement('div') + + manager.createViewHelper(parent) + + expect(manager.viewHelperContainer.parentNode).toBe(parent) + expect(manager.viewHelperContainer.style.width).toBe('128px') + expect(manager.viewHelperContainer.style.height).toBe('128px') + expect(manager.viewHelperContainer.style.position).toBe('absolute') + }) + + it('instantiates ViewHelper with the active camera and binds its center to the controls target', () => { + const target = new THREE.Vector3(1, 2, 3) + controls = makeOrbitControls(target) + manager = new ViewHelperManager( + {} as THREE.WebGLRenderer, + () => camera, + () => controls, + events + ) + + manager.createViewHelper(document.createElement('div')) + + expect(viewHelperInstances).toHaveLength(1) + expect(viewHelperInstances[0].camera).toBe(camera) + expect(manager.viewHelper.center).toBe(target) + }) + + it('routes pointerup events to ViewHelper.handleClick and stops propagation', () => { + const parent = document.createElement('div') + const propagated = vi.fn() + parent.addEventListener('pointerup', propagated) + manager.createViewHelper(parent) + + const event = new PointerEvent('pointerup', { bubbles: true }) + manager.viewHelperContainer.dispatchEvent(event) + + expect(mockHandleClick).toHaveBeenCalledWith(event) + expect(propagated).not.toHaveBeenCalled() + }) + + it('stops propagation of pointerdown events without forwarding them to ViewHelper', () => { + const parent = document.createElement('div') + const propagated = vi.fn() + parent.addEventListener('pointerdown', propagated) + manager.createViewHelper(parent) + + manager.viewHelperContainer.dispatchEvent( + new PointerEvent('pointerdown', { bubbles: true }) + ) + + expect(propagated).not.toHaveBeenCalled() + expect(mockHandleClick).not.toHaveBeenCalled() + }) + }) + + describe('update', () => { + it('does nothing when ViewHelper is not animating', () => { + manager.createViewHelper(document.createElement('div')) + manager.viewHelper.animating = false + + manager.update(0.5) + + expect(manager.viewHelper.update).not.toHaveBeenCalled() + expect(events.emitEvent).not.toHaveBeenCalled() + }) + + it('drives the animation while it is in progress without emitting yet', () => { + manager.createViewHelper(document.createElement('div')) + manager.viewHelper.animating = true + + manager.update(0.25) + + expect(manager.viewHelper.update).toHaveBeenCalledWith(0.25) + expect(events.emitEvent).not.toHaveBeenCalled() + }) + + it('emits cameraChanged with a perspective state when the animation just finished', () => { + manager.createViewHelper(document.createElement('div')) + camera.position.set(1, 2, 3) + camera.zoom = 1.5 + controls.target.set(4, 5, 6) + manager.viewHelper.animating = true + ;( + manager.viewHelper.update as unknown as { + mockImplementation(fn: () => void): void + } + ).mockImplementation(() => { + manager.viewHelper.animating = false + }) + + manager.update(0) + + expect(events.emitEvent).toHaveBeenCalledWith('cameraChanged', { + position: expect.objectContaining({ x: 1, y: 2, z: 3 }), + target: expect.objectContaining({ x: 4, y: 5, z: 6 }), + zoom: 1.5, + cameraType: 'perspective' + }) + }) + + it('reports orthographic when the active camera is an OrthographicCamera', () => { + const ortho = new THREE.OrthographicCamera() + ortho.zoom = 0.5 + manager = new ViewHelperManager( + {} as THREE.WebGLRenderer, + () => ortho, + () => controls, + events + ) + manager.createViewHelper(document.createElement('div')) + manager.viewHelper.animating = true + ;( + manager.viewHelper.update as unknown as { + mockImplementation(fn: () => void): void + } + ).mockImplementation(() => { + manager.viewHelper.animating = false + }) + + manager.update(0) + + expect(events.emitEvent).toHaveBeenCalledWith( + 'cameraChanged', + expect.objectContaining({ cameraType: 'orthographic', zoom: 0.5 }) + ) + }) + }) + + describe('visibleViewHelper', () => { + it('shows the helper and unhides the container when called with true', () => { + manager.createViewHelper(document.createElement('div')) + manager.viewHelper.visible = false + manager.viewHelperContainer.style.display = 'none' + + manager.visibleViewHelper(true) + + expect(manager.viewHelper.visible).toBe(true) + expect(manager.viewHelperContainer.style.display).toBe('block') + }) + + it('hides the helper and the container when called with false', () => { + manager.createViewHelper(document.createElement('div')) + + manager.visibleViewHelper(false) + + expect(manager.viewHelper.visible).toBe(false) + expect(manager.viewHelperContainer.style.display).toBe('none') + }) + }) + + describe('recreateViewHelper', () => { + it('disposes the old helper and constructs a new one bound to the controls target', () => { + manager.createViewHelper(document.createElement('div')) + const oldHelper = manager.viewHelper + const newTarget = new THREE.Vector3(9, 9, 9) + controls.target.copy(newTarget) + + manager.recreateViewHelper() + + expect(oldHelper.dispose).toHaveBeenCalled() + expect(manager.viewHelper).not.toBe(oldHelper) + expect(viewHelperInstances).toHaveLength(2) + expect(manager.viewHelper.center).toBe(controls.target) + }) + }) + + describe('dispose', () => { + it('disposes the helper and removes the container from its parent', () => { + const parent = document.createElement('div') + manager.createViewHelper(parent) + const helper = manager.viewHelper + + manager.dispose() + + expect(helper.dispose).toHaveBeenCalled() + expect(manager.viewHelperContainer.parentNode).toBeNull() + }) + }) +}) diff --git a/src/extensions/core/load3d/cameraFromMatrices.test.ts b/src/extensions/core/load3d/cameraFromMatrices.test.ts new file mode 100644 index 0000000000..0b71520b5a --- /dev/null +++ b/src/extensions/core/load3d/cameraFromMatrices.test.ts @@ -0,0 +1,150 @@ +import { describe, expect, it } from 'vitest' + +import { computeCameraFromMatrices } from './cameraFromMatrices' + +const IDENTITY_R = [ + [1, 0, 0], + [0, 1, 0], + [0, 0, 1] +] as const + +function extrinsics( + r: readonly (readonly number[])[], + t: readonly number[] +): number[][] { + return [ + [r[0][0], r[0][1], r[0][2], t[0]], + [r[1][0], r[1][1], r[1][2], t[1]], + [r[2][0], r[2][1], r[2][2], t[2]], + [0, 0, 0, 1] + ] +} + +function intrinsics( + fx: number, + fy: number, + cx: number, + cy: number +): number[][] { + return [ + [fx, 0, cx], + [0, fy, cy], + [0, 0, 1] + ] +} + +function closeTo(received: readonly number[], expected: readonly number[]) { + expect(received.length).toBe(expected.length) + for (let i = 0; i < expected.length; i++) { + expect(received[i]).toBeCloseTo(expected[i], 6) + } +} + +describe('computeCameraFromMatrices', () => { + it('places camera at origin when extrinsics are identity', () => { + const result = computeCameraFromMatrices( + extrinsics(IDENTITY_R, [0, 0, 0]), + intrinsics(500, 500, 320, 240) + ) + + closeTo(result.position, [0, 0, 0]) + // Identity forward (0,0,1) in OpenCV world -> after 180° rotation about X + // becomes (0,0,-1) in three.js world (camera looks toward -Z, same as + // three.js PerspectiveCamera default). + closeTo(result.target, [0, 0, -1]) + }) + + it('computes position as -R^T * t for a pure-translation extrinsic (Z flipped to three.js)', () => { + // World-to-camera t = (0, 0, -5) means world origin is 5 units behind + // camera in OpenCV frame. -R^T * t = (0, 0, 5) in OpenCV world. + // After world-rotation 180° about X: three.js position = (0, 0, -5). + const result = computeCameraFromMatrices( + extrinsics(IDENTITY_R, [0, 0, -5]), + intrinsics(500, 500, 320, 240) + ) + + closeTo(result.position, [0, 0, -5]) + // Target is one step along camera +Z in OpenCV = (0, 0, 6), then Z-flip + // gives three.js target = (0, 0, -6). + closeTo(result.target, [0, 0, -6]) + }) + + it('rotates forward direction using the third row of R', () => { + // R whose third row = (1, 0, 0): camera +Z axis points along world +X + // in OpenCV. X is not flipped by the OpenCV->three.js rotation, so the + // forward ray stays along +X in three.js world. + const r = [ + [0, 0, -1], + [0, 1, 0], + [1, 0, 0] + ] + + const result = computeCameraFromMatrices( + extrinsics(r, [0, 0, 0]), + intrinsics(500, 500, 320, 240) + ) + + closeTo(result.position, [0, 0, 0]) + closeTo(result.target, [1, 0, 0]) + }) + + it('applies Y-flip to convert OpenCV Y-down to three.js Y-up', () => { + // Camera at OpenCV world Y = 3 (below origin in Y-down world). + // After 180° rotation about X: three.js Y = -3 (below in Y-up world). + const result = computeCameraFromMatrices( + extrinsics(IDENTITY_R, [0, -3, 0]), + intrinsics(500, 500, 320, 240) + ) + + closeTo(result.position, [0, -3, 0]) + }) + + it('computes vertical FOV from fy and cy', () => { + // fy = 500, cy = 250 → fov_y = 2 * atan(0.5) ≈ 53.13° + const result = computeCameraFromMatrices( + extrinsics(IDENTITY_R, [0, 0, 0]), + intrinsics(500, 500, 320, 250) + ) + + expect(result.fovYDegrees).toBeCloseTo(53.1301023542, 6) + }) + + it('throws when extrinsics is not 4x4', () => { + expect(() => + computeCameraFromMatrices( + [ + [1, 0, 0], + [0, 1, 0], + [0, 0, 1] + ], + intrinsics(500, 500, 320, 240) + ) + ).toThrow(/extrinsics/) + }) + + it('throws when intrinsics is not 3x3', () => { + expect(() => + computeCameraFromMatrices(extrinsics(IDENTITY_R, [0, 0, 0]), [ + [500, 0, 320, 0], + [0, 500, 240, 0], + [0, 0, 1, 0] + ]) + ).toThrow(/intrinsics/) + }) + + it.each([ + ['zero', 0], + ['NaN', Number.NaN], + ['Infinity', Number.POSITIVE_INFINITY] + ])( + 'throws when fy is %s rather than producing a NaN/Infinite FOV', + (_label, fy) => { + expect(() => + computeCameraFromMatrices( + extrinsics(IDENTITY_R, [0, 0, 0]), + intrinsics(500, fy, 320, 240) + ) + ).toThrow(/fy/) + } + ) +}) diff --git a/src/extensions/core/load3d/cameraFromMatrices.ts b/src/extensions/core/load3d/cameraFromMatrices.ts new file mode 100644 index 0000000000..26fcdc3475 --- /dev/null +++ b/src/extensions/core/load3d/cameraFromMatrices.ts @@ -0,0 +1,94 @@ +/** + * Compute a three.js camera pose (position, target, vertical FOV) from a + * pair of OpenCV-convention camera matrices as produced by SHARP / COLMAP / + * other SfM pipelines. + * + * Extrinsics: 4x4 world-to-camera matrix E = [R | t; 0 0 0 1] + * - R is the 3x3 rotation block + * - t is the 3x1 translation block (rightmost column, top three rows) + * Intrinsics: 3x3 camera matrix K = [[fx, 0, cx], [0, fy, cy], [0, 0, 1]] + * + * OpenCV convention: X right, Y down, Z forward. + * three.js convention: X right, Y up, Z backward. + * + * Camera position in world space = -R^T * t + * Forward ray in world space = third row of R (camera's +Z axis) + * Vertical FOV (radians) = 2 * atan(cy / fy) + * + * The whole world is rotated 180° around X to align OpenCV Y-down/Z-forward + * with three.js Y-up/Z-back (same rotation applied to splats at load time + * via SplatMesh.quaternion.set(1, 0, 0, 0)). That rotation flips both Y and Z. + */ +type Vec3 = [number, number, number] + +interface CameraFromMatricesResult { + position: Vec3 + target: Vec3 + fovYDegrees: number +} + +export function computeCameraFromMatrices( + extrinsics: readonly (readonly number[])[], + intrinsics: readonly (readonly number[])[] +): CameraFromMatricesResult { + assertMatrixShape(extrinsics, 4, 4, 'extrinsics') + assertMatrixShape(intrinsics, 3, 3, 'intrinsics') + + const r00 = extrinsics[0][0] + const r01 = extrinsics[0][1] + const r02 = extrinsics[0][2] + const r10 = extrinsics[1][0] + const r11 = extrinsics[1][1] + const r12 = extrinsics[1][2] + const r20 = extrinsics[2][0] + const r21 = extrinsics[2][1] + const r22 = extrinsics[2][2] + + const tx = extrinsics[0][3] + const ty = extrinsics[1][3] + const tz = extrinsics[2][3] + + const posX = -(r00 * tx + r10 * ty + r20 * tz) + const posY = -(r01 * tx + r11 * ty + r21 * tz) + const posZ = -(r02 * tx + r12 * ty + r22 * tz) + + const targetX = posX + r20 + const targetY = posY + r21 + const targetZ = posZ + r22 + + const fy = intrinsics[1][1] + const cy = intrinsics[1][2] + if (!Number.isFinite(fy) || fy === 0) { + throw new Error( + `intrinsics[1][1] (fy) must be a non-zero finite number, got ${fy}` + ) + } + const fovYRad = 2 * Math.atan(cy / fy) + const fovYDegrees = (fovYRad * 180) / Math.PI + + return { + position: [posX, -posY, -posZ], + target: [targetX, -targetY, -targetZ], + fovYDegrees + } +} + +function assertMatrixShape( + matrix: readonly (readonly number[])[], + rows: number, + cols: number, + name: string +): void { + if (matrix.length !== rows) { + throw new Error( + `${name} must be ${rows}x${cols}, got ${matrix.length} rows` + ) + } + for (let i = 0; i < rows; i++) { + if (matrix[i].length !== cols) { + throw new Error( + `${name} row ${i} must have ${cols} columns, got ${matrix[i].length}` + ) + } + } +} diff --git a/src/extensions/core/load3d/createLoad3d.test.ts b/src/extensions/core/load3d/createLoad3d.test.ts new file mode 100644 index 0000000000..ee6bd00c4e --- /dev/null +++ b/src/extensions/core/load3d/createLoad3d.test.ts @@ -0,0 +1,278 @@ +import { describe, expect, it, vi } from 'vitest' + +import { DEFAULT_MODEL_CAPABILITIES } from './ModelAdapter' +import type { ModelAdapter, ModelAdapterCapabilities } from './ModelAdapter' +import { createLoad3d } from './createLoad3d' + +const { rendererCtor } = vi.hoisted(() => ({ + rendererCtor: vi.fn() +})) + +vi.mock('three', async () => { + const actual = await vi.importActual<typeof import('three')>('three') + return { + ...actual, + WebGLRenderer: class { + domElement = document.createElement('canvas') + autoClear = false + outputColorSpace = '' + constructor(opts: unknown) { + rendererCtor(opts) + } + setSize() {} + setClearColor() {} + } + } +}) + +vi.mock('./SceneManager', () => ({ + SceneManager: class { + scene = { __scene: true } + } +})) + +vi.mock('./CameraManager', () => ({ + CameraManager: class { + activeCamera = { __camera: true } + setControls = vi.fn() + setupForModel = vi.fn() + } +})) + +vi.mock('./ControlsManager', () => ({ + ControlsManager: class { + controls = { __controls: true } + } +})) + +vi.mock('./LightingManager', () => ({ + LightingManager: class {} +})) + +vi.mock('./HDRIManager', () => ({ + HDRIManager: class {} +})) + +vi.mock('./ViewHelperManager', () => ({ + ViewHelperManager: class {} +})) + +vi.mock('./SceneModelManager', () => ({ + SceneModelManager: class { + getCurrentCapabilities: () => unknown + getBoundsFromAdapter: (model: unknown) => unknown + disposeModelViaAdapter: (model: unknown) => unknown + getDefaultCameraPose: () => unknown + constructor( + _scene: unknown, + _renderer: unknown, + _eventManager: unknown, + _getActiveCamera: unknown, + _setupCamera: unknown, + _setupGizmo: unknown, + getCurrentCapabilities: () => unknown, + getBoundsFromAdapter: (model: unknown) => unknown, + disposeModelViaAdapter: (model: unknown) => unknown, + getDefaultCameraPose: () => unknown + ) { + this.getCurrentCapabilities = getCurrentCapabilities + this.getBoundsFromAdapter = getBoundsFromAdapter + this.disposeModelViaAdapter = disposeModelViaAdapter + this.getDefaultCameraPose = getDefaultCameraPose + } + } +})) + +vi.mock('./LoaderManager', () => ({ + LoaderManager: class { + adapterRefArg: unknown + constructor( + _modelManager: unknown, + _eventManager: unknown, + _adapters: unknown, + adapterRef: unknown + ) { + this.adapterRefArg = adapterRef + } + } +})) + +vi.mock('./RecordingManager', () => ({ + RecordingManager: class {} +})) + +vi.mock('./AnimationManager', () => ({ + AnimationManager: class {} +})) + +vi.mock('./GizmoManager', () => ({ + GizmoManager: class { + setupForModel = vi.fn() + getTransform = vi.fn(() => ({})) + isEnabled = vi.fn(() => false) + getMode = vi.fn(() => 'translate') + } +})) + +vi.mock('./Load3d', () => ({ + default: class { + deps: unknown + options: unknown + constructor(_container: unknown, deps: unknown, options: unknown) { + this.deps = deps + this.options = options + } + } +})) + +type FakeLoaderManager = { adapterRefArg: { current: ModelAdapter | null } } +type FakeSceneModelManager = { + getCurrentCapabilities: () => unknown + getBoundsFromAdapter: (model: unknown) => unknown + disposeModelViaAdapter: (model: unknown) => void + getDefaultCameraPose: () => unknown +} +type FakeLoad3d = { + deps: { + adapterRef: { current: ModelAdapter | null } + loaderManager: FakeLoaderManager + modelManager: FakeSceneModelManager + } + options: unknown +} + +function createContainer(): HTMLElement { + const container = document.createElement('div') + // Stub appendChild — we only care that one was called, not what was attached. + container.appendChild = vi.fn().mockReturnValue(container) + return container +} + +function makeAdapter(overrides: Partial<ModelAdapter> = {}): ModelAdapter { + return { + kind: 'mesh', + extensions: [], + capabilities: DEFAULT_MODEL_CAPABILITIES, + load: vi.fn().mockResolvedValue(null), + ...overrides + } satisfies ModelAdapter +} + +describe('createLoad3d', () => { + it('constructs the renderer with alpha + antialias and appends it to the container', () => { + rendererCtor.mockClear() + const container = createContainer() + + createLoad3d(container) + + expect(rendererCtor).toHaveBeenCalledWith({ alpha: true, antialias: true }) + expect(container.appendChild).toHaveBeenCalledOnce() + }) + + it('forwards Load3DOptions to the Load3d constructor', () => { + const container = createContainer() + const options = { width: 640, height: 480, isViewerMode: true } + + const instance = createLoad3d(container, options) as unknown as FakeLoad3d + + expect(instance.options).toEqual(options) + }) + + it('shares one AdapterRef between LoaderManager and SceneModelManager lambdas', () => { + const container = createContainer() + const instance = createLoad3d(container) as unknown as FakeLoad3d + + const adapterRef = instance.deps.adapterRef + expect(adapterRef.current).toBeNull() + + const loaderRef = instance.deps.loaderManager.adapterRefArg + expect(loaderRef).toBe(adapterRef) + }) + + describe('SceneModelManager capability lambdas (default — no adapter loaded)', () => { + it('getCurrentCapabilities falls back to DEFAULT_MODEL_CAPABILITIES', () => { + const instance = createLoad3d(createContainer()) as unknown as FakeLoad3d + + expect(instance.deps.modelManager.getCurrentCapabilities()).toEqual( + DEFAULT_MODEL_CAPABILITIES + ) + }) + + it('getBoundsFromAdapter returns null', () => { + const instance = createLoad3d(createContainer()) as unknown as FakeLoad3d + expect( + instance.deps.modelManager.getBoundsFromAdapter({} as never) + ).toBeNull() + }) + + it('disposeModelViaAdapter is a no-op', () => { + const instance = createLoad3d(createContainer()) as unknown as FakeLoad3d + expect(() => + instance.deps.modelManager.disposeModelViaAdapter({} as never) + ).not.toThrow() + }) + + it('getDefaultCameraPose returns null', () => { + const instance = createLoad3d(createContainer()) as unknown as FakeLoad3d + expect(instance.deps.modelManager.getDefaultCameraPose()).toBeNull() + }) + }) + + describe('SceneModelManager capability lambdas (after adapter is published)', () => { + function withAdapter(adapter: ModelAdapter) { + const instance = createLoad3d(createContainer()) as unknown as FakeLoad3d + instance.deps.adapterRef.current = adapter + return instance + } + + it('getCurrentCapabilities reads the published adapter capabilities', () => { + const caps: ModelAdapterCapabilities = { + ...DEFAULT_MODEL_CAPABILITIES, + gizmoTransform: false, + materialModes: [] + } + const instance = withAdapter(makeAdapter({ capabilities: caps })) + + expect(instance.deps.modelManager.getCurrentCapabilities()).toBe(caps) + }) + + it('getBoundsFromAdapter delegates to adapter.computeBounds', () => { + const computeBounds = vi.fn().mockReturnValue('bbox-result') + const instance = withAdapter(makeAdapter({ computeBounds })) + const model = { fake: 'model' } + + const result = instance.deps.modelManager.getBoundsFromAdapter( + model as never + ) + + expect(computeBounds).toHaveBeenCalledWith(model) + expect(result).toBe('bbox-result') + }) + + it('getBoundsFromAdapter returns null when adapter has no computeBounds', () => { + const instance = withAdapter(makeAdapter()) + expect( + instance.deps.modelManager.getBoundsFromAdapter({} as never) + ).toBeNull() + }) + + it('disposeModelViaAdapter delegates to adapter.disposeModel', () => { + const disposeModel = vi.fn() + const instance = withAdapter(makeAdapter({ disposeModel })) + const model = { fake: 'model' } + + instance.deps.modelManager.disposeModelViaAdapter(model as never) + + expect(disposeModel).toHaveBeenCalledWith(model) + }) + + it('getDefaultCameraPose delegates to adapter.defaultCameraPose', () => { + const pose = { size: { x: 5 }, center: { x: 0 } } + const defaultCameraPose = vi.fn().mockReturnValue(pose) + const instance = withAdapter(makeAdapter({ defaultCameraPose })) + + expect(instance.deps.modelManager.getDefaultCameraPose()).toBe(pose) + expect(defaultCameraPose).toHaveBeenCalledOnce() + }) + }) +}) diff --git a/src/extensions/core/load3d/createLoad3d.ts b/src/extensions/core/load3d/createLoad3d.ts new file mode 100644 index 0000000000..df1a122180 --- /dev/null +++ b/src/extensions/core/load3d/createLoad3d.ts @@ -0,0 +1,144 @@ +import * as THREE from 'three' + +import { AnimationManager } from './AnimationManager' +import { CameraManager } from './CameraManager' +import { ControlsManager } from './ControlsManager' +import { EventManager } from './EventManager' +import { GizmoManager } from './GizmoManager' +import { HDRIManager } from './HDRIManager' +import { LightingManager } from './LightingManager' +import Load3d from './Load3d' +import type { Load3dDeps } from './Load3d' +import { LoaderManager } from './LoaderManager' +import { createAdapterRef, DEFAULT_MODEL_CAPABILITIES } from './ModelAdapter' +import { RecordingManager } from './RecordingManager' +import { SceneManager } from './SceneManager' +import { SceneModelManager } from './SceneModelManager' +import { ViewHelperManager } from './ViewHelperManager' +import type { Load3DOptions } from './interfaces' + +function createRenderer(container: Element | HTMLElement): THREE.WebGLRenderer { + const renderer = new THREE.WebGLRenderer({ alpha: true, antialias: true }) + renderer.setSize(300, 300) + renderer.setClearColor(0x282828) + renderer.autoClear = false + renderer.outputColorSpace = THREE.SRGBColorSpace + renderer.domElement.classList.add( + 'absolute', + 'inset-0', + 'h-full', + 'w-full', + 'outline-none' + ) + container.appendChild(renderer.domElement) + return renderer +} + +function buildLoad3dDeps(container: Element | HTMLElement): Load3dDeps { + const renderer = createRenderer(container) + const eventManager = new EventManager() + // Shared mutable handle: LoaderManager writes the active adapter on each + // load; SceneModelManager reads it for capability/bounds/dispose lookups + // without depending on construction order. + const adapterRef = createAdapterRef() + + let cameraManager: CameraManager + let controlsManager: ControlsManager + let gizmoManager: GizmoManager + + const getActiveCamera = (): THREE.Camera => cameraManager.activeCamera + const getControls = () => controlsManager.controls + + const sceneManager = new SceneManager( + renderer, + getActiveCamera, + getControls, + eventManager + ) + + cameraManager = new CameraManager(renderer, eventManager) + controlsManager = new ControlsManager( + renderer, + cameraManager.activeCamera, + eventManager + ) + cameraManager.setControls(controlsManager.controls) + + const lightingManager = new LightingManager(sceneManager.scene, eventManager) + const hdriManager = new HDRIManager( + sceneManager.scene, + renderer, + eventManager + ) + const viewHelperManager = new ViewHelperManager( + renderer, + getActiveCamera, + getControls, + eventManager + ) + + const modelManager = new SceneModelManager( + sceneManager.scene, + renderer, + eventManager, + getActiveCamera, + (size, center) => cameraManager.setupForModel(size, center), + (model) => gizmoManager.setupForModel(model), + () => adapterRef.current?.capabilities ?? DEFAULT_MODEL_CAPABILITIES, + (model) => adapterRef.current?.computeBounds?.(model) ?? null, + (model) => adapterRef.current?.disposeModel?.(model), + () => adapterRef.current?.defaultCameraPose?.() ?? null + ) + + const loaderManager = new LoaderManager( + modelManager, + eventManager, + undefined, + adapterRef + ) + const recordingManager = new RecordingManager( + sceneManager.scene, + renderer, + eventManager + ) + const animationManager = new AnimationManager(eventManager) + + gizmoManager = new GizmoManager( + sceneManager.scene, + renderer, + controlsManager.controls, + getActiveCamera, + () => { + const transform = gizmoManager.getTransform() + eventManager.emitEvent('gizmoTransformChange', { + ...transform, + enabled: gizmoManager.isEnabled(), + mode: gizmoManager.getMode() + }) + } + ) + + return { + renderer, + eventManager, + sceneManager, + cameraManager, + controlsManager, + lightingManager, + hdriManager, + viewHelperManager, + loaderManager, + modelManager, + recordingManager, + animationManager, + gizmoManager, + adapterRef + } +} + +export function createLoad3d( + container: Element | HTMLElement, + options?: Load3DOptions +): Load3d { + return new Load3d(container, buildLoad3dDeps(container), options) +} diff --git a/src/extensions/core/load3d/exportMenuHelper.test.ts b/src/extensions/core/load3d/exportMenuHelper.test.ts new file mode 100644 index 0000000000..8d40e7c4bf --- /dev/null +++ b/src/extensions/core/load3d/exportMenuHelper.test.ts @@ -0,0 +1,162 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import type Load3d from './Load3d' +import { createExportMenuItems } from './exportMenuHelper' + +const { contextMenuMock, addToastMock, addAlertMock } = vi.hoisted(() => ({ + contextMenuMock: vi.fn(), + addToastMock: vi.fn(), + addAlertMock: vi.fn() +})) + +vi.mock('@/i18n', () => ({ + t: (key: string, vars?: Record<string, unknown>) => + vars ? `${key}:${JSON.stringify(vars)}` : key +})) + +vi.mock('@/platform/updates/common/toastStore', () => ({ + useToastStore: () => ({ add: addToastMock, addAlert: addAlertMock }) +})) + +vi.mock(import('@/lib/litegraph/src/litegraph'), async (importOriginal) => { + const actual = await importOriginal() + class MockContextMenu { + constructor(...args: unknown[]) { + contextMenuMock(...args) + } + } + // Replace ContextMenu in-place on the real LiteGraph singleton so consumers + // that import other members keep getting the real implementations. + ;(actual.LiteGraph as unknown as { ContextMenu: unknown }).ContextMenu = + MockContextMenu + return actual +}) + +function makeLoad3d( + exportImpl: (format: string) => Promise<void> = vi + .fn() + .mockResolvedValue(undefined) +): Load3d { + return { exportModel: exportImpl } as unknown as Load3d +} + +describe('createExportMenuItems', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('returns a separator followed by a Save submenu', () => { + const items = createExportMenuItems(makeLoad3d()) + + expect(items).toHaveLength(2) + expect(items[0]).toBeNull() + expect(items[1]).toMatchObject({ + content: 'Save', + has_submenu: true + }) + }) + + it('opens a submenu with GLB, OBJ, STL when the Save item is invoked', () => { + const items = createExportMenuItems(makeLoad3d()) + const saveItem = items[1]! + + ;(saveItem.callback as (...args: unknown[]) => void)( + undefined, + {}, + undefined, + undefined + ) + + expect(contextMenuMock).toHaveBeenCalledOnce() + const submenuOptions = contextMenuMock.mock.calls[0][0] + expect(submenuOptions.map((o: { content: string }) => o.content)).toEqual([ + 'GLB', + 'OBJ', + 'STL' + ]) + }) + + it('forwards the parent menu and event when opening the submenu', () => { + const items = createExportMenuItems(makeLoad3d()) + const event = { x: 100 } as unknown as MouseEvent + const parentMenu = { id: 'prev' } + + ;(items[1]!.callback as (...args: unknown[]) => void)( + undefined, + {}, + event, + parentMenu + ) + + expect(contextMenuMock).toHaveBeenCalledWith( + expect.any(Array), + expect.objectContaining({ event, parentMenu }) + ) + }) + + it.each([ + ['GLB', 'glb'], + ['OBJ', 'obj'], + ['STL', 'stl'] + ])( + 'invokes load3d.exportModel(%s) and shows a success toast when the %s submenu item is clicked', + async (label, value) => { + const exportModel = vi.fn().mockResolvedValue(undefined) + const items = createExportMenuItems(makeLoad3d(exportModel)) + ;(items[1]!.callback as (...args: unknown[]) => void)( + undefined, + {}, + undefined, + undefined + ) + const submenuOptions = contextMenuMock.mock.calls[0][0] + const item = submenuOptions.find( + (o: { content: string }) => o.content === label + ) + + item.callback() + await vi.waitFor(() => expect(exportModel).toHaveBeenCalledWith(value)) + await vi.waitFor(() => + expect(addToastMock).toHaveBeenCalledWith( + expect.objectContaining({ + severity: 'success', + summary: `toastMessages.exportSuccess:${JSON.stringify({ format: label })}` + }) + ) + ) + expect(addAlertMock).not.toHaveBeenCalled() + } + ) + + it('shows an alert toast and logs when exportModel rejects', async () => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + const exportModel = vi.fn().mockRejectedValue(new Error('boom')) + const items = createExportMenuItems(makeLoad3d(exportModel)) + ;(items[1]!.callback as (...args: unknown[]) => void)( + undefined, + {}, + undefined, + undefined + ) + const glb = contextMenuMock.mock.calls[0][0].find( + (o: { content: string }) => o.content === 'GLB' + ) + + glb.callback() + + await vi.waitFor(() => + expect(addAlertMock).toHaveBeenCalledWith( + `toastMessages.failedToExportModel:${JSON.stringify({ format: 'GLB' })}` + ) + ) + expect(consoleError).toHaveBeenCalledWith( + 'Export failed:', + expect.any(Error) + ) + expect(addToastMock).not.toHaveBeenCalled() + }) +}) diff --git a/src/extensions/core/load3d/interfaces.ts b/src/extensions/core/load3d/interfaces.ts index 750273a023..a5dc7c3b08 100644 --- a/src/extensions/core/load3d/interfaces.ts +++ b/src/extensions/core/load3d/interfaces.ts @@ -198,8 +198,23 @@ export interface ModelManagerInterface { setupModelMaterials(model: THREE.Object3D): void } +export interface LoadModelOptions { + /** + * When true, suppress the user-facing toast for file-not-found + * (HTTP 404) errors. Other errors (parse failures, network drops) + * still surface a toast. Use for "preview" surfaces whose model + * file is server-produced and may legitimately be absent locally + * (e.g. shared workflows on a fresh machine). + */ + silentOnNotFound?: boolean +} + export interface LoaderManagerInterface { init(): void dispose(): void - loadModel(url: string, originalFileName?: string): Promise<void> + loadModel( + url: string, + originalFileName?: string, + options?: LoadModelOptions + ): Promise<void> } diff --git a/src/extensions/core/load3dLazy.test.ts b/src/extensions/core/load3dLazy.test.ts new file mode 100644 index 0000000000..289b38b9e9 --- /dev/null +++ b/src/extensions/core/load3dLazy.test.ts @@ -0,0 +1,180 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode' +import type { ComfyNodeDef } from '@/schemas/nodeDefSchema' +import type { ComfyExtension } from '@/types/comfy' + +const { registerExtensionMock, enabledExtensionsGetter } = vi.hoisted(() => ({ + registerExtensionMock: vi.fn(), + enabledExtensionsGetter: vi.fn(() => [] as ComfyExtension[]) +})) + +vi.mock('@/services/extensionService', () => ({ + useExtensionService: () => ({ registerExtension: registerExtensionMock }) +})) + +vi.mock('@/stores/extensionStore', () => ({ + useExtensionStore: () => ({ + get enabledExtensions() { + return enabledExtensionsGetter() + } + }) +})) + +vi.mock('@/scripts/app', () => ({ + app: { __mockApp: true } +})) + +vi.mock('@/extensions/core/load3d', () => ({})) +vi.mock('@/extensions/core/saveMesh', () => ({})) + +type Hook = ( + nodeType: typeof LGraphNode, + nodeData: ComfyNodeDef, + app?: unknown +) => Promise<void> | void + +async function loadLazyExtensionFresh(): Promise<{ hook: Hook }> { + vi.resetModules() + registerExtensionMock.mockClear() + enabledExtensionsGetter.mockReset().mockReturnValue([]) + await import('@/extensions/core/load3dLazy') + const ext = registerExtensionMock.mock.calls[0][0] as ComfyExtension + return { hook: ext.beforeRegisterNodeDef as Hook } +} + +function makeNodeDef( + name: string, + extra: Partial<ComfyNodeDef> = {} +): ComfyNodeDef { + return { + name, + display_name: name, + category: '', + output: [], + output_is_list: [], + output_name: [], + python_module: '', + description: '', + ...extra + } as ComfyNodeDef +} + +describe('load3dLazy', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('registers a single Comfy.Load3DLazy extension on import', async () => { + await loadLazyExtensionFresh() + + expect(registerExtensionMock).toHaveBeenCalledOnce() + const ext = registerExtensionMock.mock.calls[0][0] as ComfyExtension + expect(ext.name).toBe('Comfy.Load3DLazy') + expect(typeof ext.beforeRegisterNodeDef).toBe('function') + }) + + it('skips loading and mutation for non-3D node defs', async () => { + const { hook } = await loadLazyExtensionFresh() + + await hook({} as typeof LGraphNode, makeNodeDef('PlainNode')) + + // No diff was ever computed because the early-return branch was taken. + expect(enabledExtensionsGetter).not.toHaveBeenCalled() + }) + + it.each(['Load3D', 'Preview3D', 'SaveGLB'])( + 'recognizes %s as a 3D node type and triggers the lazy-load path', + async (nodeType) => { + const { hook } = await loadLazyExtensionFresh() + + await hook({} as typeof LGraphNode, makeNodeDef(nodeType)) + + // The lazy-load path always reads enabledExtensions once for the diff. + expect(enabledExtensionsGetter).toHaveBeenCalled() + } + ) + + it('injects mesh_upload spec flags into the model_file widget for Load3D nodes', async () => { + const { hook } = await loadLazyExtensionFresh() + const nodeData = makeNodeDef('Load3D', { + input: { + required: { model_file: ['STRING', {}] } + } + } as Partial<ComfyNodeDef>) + + await hook({} as typeof LGraphNode, nodeData) + + const spec = ( + nodeData.input!.required!.model_file as [string, Record<string, unknown>] + )[1] + expect(spec.mesh_upload).toBe(true) + expect(spec.upload_subfolder).toBe('3d') + }) + + it('does not throw when a Load3D node has no model_file widget spec', async () => { + const { hook } = await loadLazyExtensionFresh() + const nodeData = makeNodeDef('Load3D', { + input: { required: {} } + } as Partial<ComfyNodeDef>) + + await expect( + hook({} as typeof LGraphNode, nodeData) + ).resolves.toBeUndefined() + }) + + it('does not mutate model_file for non-Load3D 3D node types', async () => { + const { hook } = await loadLazyExtensionFresh() + const nodeData = makeNodeDef('Preview3D', { + input: { + required: { model_file: ['STRING', { existing: true }] } + } + } as Partial<ComfyNodeDef>) + + await hook({} as typeof LGraphNode, nodeData) + + const spec = ( + nodeData.input!.required!.model_file as [string, Record<string, unknown>] + )[1] + expect(spec.mesh_upload).toBeUndefined() + }) + + it('replays beforeRegisterNodeDef of newly registered extensions, passing the app reference', async () => { + const newExtension: ComfyExtension = { + name: 'Inner', + beforeRegisterNodeDef: vi.fn() + } + // First call (snapshotting `before`) sees an empty list; second call + // (computing the diff after dynamic imports) sees the new extension. + enabledExtensionsGetter + .mockReturnValueOnce([]) + .mockReturnValueOnce([newExtension]) + const { hook } = await loadLazyExtensionFresh() + enabledExtensionsGetter + .mockReturnValueOnce([]) + .mockReturnValueOnce([newExtension]) + + const nodeData = makeNodeDef('Preview3D') + await hook({ id: 1 } as unknown as typeof LGraphNode, nodeData) + + expect(newExtension.beforeRegisterNodeDef).toHaveBeenCalledWith( + { id: 1 }, + nodeData, + expect.objectContaining({ __mockApp: true }) + ) + }) + + it('does not replay extensions that were already registered before lazy loading', async () => { + const preexisting: ComfyExtension = { + name: 'PreExisting', + beforeRegisterNodeDef: vi.fn() + } + enabledExtensionsGetter.mockReturnValue([preexisting]) + const { hook } = await loadLazyExtensionFresh() + enabledExtensionsGetter.mockReturnValue([preexisting]) + + await hook({} as typeof LGraphNode, makeNodeDef('Load3D')) + + expect(preexisting.beforeRegisterNodeDef).not.toHaveBeenCalled() + }) +}) diff --git a/src/extensions/core/saveMesh.ts b/src/extensions/core/saveMesh.ts index cf7828e12f..09b1e9a80b 100644 --- a/src/extensions/core/saveMesh.ts +++ b/src/extensions/core/saveMesh.ts @@ -103,19 +103,19 @@ useExtensionService().registerExtension({ const loadFolder = fileInfo.type as 'input' | 'output' - config.configureForSaveMesh(loadFolder, filePath) + config.configureForSaveMesh(loadFolder, filePath, { + silentOnNotFound: true + }) if (isAssetPreviewSupported()) { const filename = fileInfo.filename ?? '' - const onModelLoaded = () => { - load3d.removeEventListener('modelLoadingEnd', onModelLoaded) - load3d - .captureThumbnail(256, 256) - .then((dataUrl) => fetch(dataUrl).then((r) => r.blob())) - .then((blob) => persistThumbnail(filename, blob)) - .catch(() => {}) - } - load3d.addEventListener('modelLoadingEnd', onModelLoaded) + + void load3d + .whenLoadIdle() + .then(() => load3d.captureThumbnail(256, 256)) + .then((dataUrl) => fetch(dataUrl).then((r) => r.blob())) + .then((blob) => persistThumbnail(filename, blob)) + .catch(() => {}) } } }) diff --git a/src/lib/litegraph/src/LGraph.ts b/src/lib/litegraph/src/LGraph.ts index 9699e5f453..51439f41e9 100644 --- a/src/lib/litegraph/src/LGraph.ts +++ b/src/lib/litegraph/src/LGraph.ts @@ -27,6 +27,7 @@ import { import type { DragAndScaleState } from './DragAndScale' import { LGraphCanvas } from './LGraphCanvas' import { LGraphGroup } from './LGraphGroup' +import type { GroupId } from './LGraphGroup' import { LGraphNode } from './LGraphNode' import type { NodeId } from './LGraphNode' import { LLink } from './LLink' @@ -101,11 +102,17 @@ export type { export type RendererType = 'LG' | 'Vue' | 'Vue-corrected' +/** + * Unique identifier for a subgraph definition. Structurally a {@link UUID}; + * provided as a domain-specific alias for clarity at adoption sites. + */ +export type SubgraphId = UUID + export interface LGraphState { - lastGroupId: number + lastGroupId: GroupId lastNodeId: number - lastLinkId: number - lastRerouteId: number + lastLinkId: LinkId + lastRerouteId: RerouteId } type ParamsArray<T, K extends MethodNames<T>> = Parameters< @@ -154,7 +161,7 @@ export interface GroupNodeWorkflowData { export interface LGraphExtra extends Dictionary<unknown> { reroutes?: SerialisableReroute[] - linkExtensions?: { id: number; parentId: number | undefined }[] + linkExtensions?: { id: LinkId; parentId: RerouteId | undefined }[] ds?: DragAndScaleState workflowRendererVersion?: RendererType groupNodes?: Record<string, GroupNodeWorkflowData> @@ -235,7 +242,7 @@ export class LGraph } readonly events = new CustomEventTarget<LGraphEventMap>() - readonly _subgraphs: Map<UUID, Subgraph> = new Map() + readonly _subgraphs: Map<SubgraphId, Subgraph> = new Map() _nodes: (LGraphNode | SubgraphNode)[] = [] _nodes_by_id: Record<NodeId, LGraphNode> = {} @@ -445,7 +452,7 @@ export class LGraph this.canvasAction((c) => c.clear()) } - get subgraphs(): Map<UUID, Subgraph> { + get subgraphs(): Map<SubgraphId, Subgraph> { return this.rootGraph._subgraphs } @@ -844,6 +851,15 @@ export class LGraph return this.elapsed_time } + /** + * Increments the internal version counter. + * Currently only read for debug display in {@link LGraphCanvas.renderInfo}. + * Centralized so a future VersionSystem can intercept, batch, or replace it. + */ + incrementVersion(): void { + this._version++ + } + /** * @deprecated Will be removed in 0.9 * Sends an event to all the nodes, useful to trigger stuff @@ -957,7 +973,7 @@ export class LGraph this.setDirtyCanvas(true) this.change() node.graph = this - this._version++ + this.incrementVersion() return } @@ -990,7 +1006,7 @@ export class LGraph } node.graph = this - this._version++ + this.incrementVersion() // Register all widgets with the WidgetValueStore now that node has a // valid ID and graph reference. @@ -1043,7 +1059,7 @@ export class LGraph this._groups.splice(index, 1) } node.graph = undefined - this._version++ + this.incrementVersion() this.setDirtyCanvas(true, true) this.change() return @@ -1110,7 +1126,7 @@ export class LGraph node.onRemoved?.() node.graph = null - this._version++ + this.incrementVersion() // remove from canvas render const { list_of_graphcanvas } = this @@ -2720,7 +2736,7 @@ export class LGraph this.updateExecutionOrder() this.onConfigure?.(data) - this._version++ + this.incrementVersion() // Ensure the primary canvas is set to the correct graph const { primaryCanvas } = this diff --git a/src/lib/litegraph/src/LGraphCanvas.ghost.test.ts b/src/lib/litegraph/src/LGraphCanvas.ghost.test.ts new file mode 100644 index 0000000000..d56e3fe2b9 --- /dev/null +++ b/src/lib/litegraph/src/LGraphCanvas.ghost.test.ts @@ -0,0 +1,240 @@ +import userEvent from '@testing-library/user-event' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { LGraph, LGraphCanvas, LGraphNode } from '@/lib/litegraph/src/litegraph' +import { createMockCanvasRenderingContext2D } from '@/utils/__tests__/litegraphTestUtils' + +vi.mock('@/renderer/core/layout/store/layoutStore', () => ({ + layoutStore: { + querySlotAtPoint: vi.fn(), + queryRerouteAtPoint: vi.fn(), + getNodeLayoutRef: vi.fn(() => ({ value: null })), + getSlotLayout: vi.fn(), + setSource: vi.fn(), + setActor: vi.fn() + } +})) + +function createGhostTestHarness() { + const canvasElement = document.createElement('canvas') + canvasElement.width = 800 + canvasElement.height = 600 + canvasElement.getContext = vi + .fn() + .mockReturnValue(createMockCanvasRenderingContext2D()) + document.body.appendChild(canvasElement) + canvasElement.getBoundingClientRect = vi.fn().mockReturnValue({ + left: 0, + top: 0, + right: 800, + bottom: 600, + width: 800, + height: 600, + x: 0, + y: 0, + toJSON: () => {} + }) + + const graph = new LGraph() + const canvas = new LGraphCanvas(canvasElement, graph, { + skip_render: true, + skip_events: true + }) + + const node = new LGraphNode('test') + node.size = [200, 100] + graph.add(node) + + return { canvas, canvasElement, graph, node } +} + +describe('LGraphCanvas ghost placement auto-pan', () => { + let canvas: LGraphCanvas + let canvasElement: HTMLCanvasElement + let node: LGraphNode + + beforeEach(() => { + vi.useFakeTimers() + ;({ canvas, canvasElement, node } = createGhostTestHarness()) + // Near left edge so autopan fires by default + canvas.mouse[0] = 5 + canvas.mouse[1] = 300 + }) + + afterEach(() => { + if (canvas.state.ghostNodeId != null) canvas.finalizeGhostPlacement(false) + canvasElement.remove() + vi.useRealTimers() + }) + + it('moves the ghost node when pointer is near edge', () => { + canvas.startGhostPlacement(node) + + const posXBefore = node.pos[0] + vi.advanceTimersByTime(16) + + expect(node.pos[0]).not.toBe(posXBefore) + }) + + it('does not pan when pointer is in the center', () => { + canvas.mouse[0] = 400 + canvas.startGhostPlacement(node) + + const offsetBefore = [...canvas.ds.offset] + vi.advanceTimersByTime(16) + + expect(canvas.ds.offset[0]).toBe(offsetBefore[0]) + expect(canvas.ds.offset[1]).toBe(offsetBefore[1]) + }) + + it('cleans up autopan and stops responding to document pointermove on finalize', () => { + const processMoveSpy = vi.spyOn(canvas, 'processMouseMove') + canvas.startGhostPlacement(node) + expect(canvas['_autoPan']).not.toBeNull() + + document.dispatchEvent(new MouseEvent('pointermove')) + expect(processMoveSpy).toHaveBeenCalled() + + processMoveSpy.mockClear() + canvas.finalizeGhostPlacement(false) + + expect(canvas['_autoPan']).toBeNull() + + document.dispatchEvent(new MouseEvent('pointermove')) + expect(processMoveSpy).not.toHaveBeenCalled() + }) + + it('survives linkConnector reset during ghost placement', () => { + canvas.startGhostPlacement(node) + + canvas.linkConnector.reset() + + expect(canvas['_autoPan']).not.toBeNull() + vi.advanceTimersByTime(16) + expect(canvas.ds.offset[0]).not.toBe(0) + }) +}) + +describe('LGraphCanvas ghost placement cancellation via document keydown', () => { + let canvas: LGraphCanvas + let canvasElement: HTMLCanvasElement + let graph: LGraph + let node: LGraphNode + + beforeEach(() => { + ;({ canvas, canvasElement, graph, node } = createGhostTestHarness()) + }) + + afterEach(() => { + if (canvas.state.ghostNodeId != null) canvas.finalizeGhostPlacement(false) + canvasElement.remove() + }) + + it('Escape on document removes the ghost node and clears ghost state', async () => { + canvas.startGhostPlacement(node) + expect(canvas.state.ghostNodeId).toBe(node.id) + + await userEvent.keyboard('{Escape}') + + expect(canvas.state.ghostNodeId).toBeNull() + expect(graph.getNodeById(node.id)).toBeFalsy() + }) + + it('Escape on document stops propagation so window-level keybindings do not fire', async () => { + const windowSpy = vi.fn() + window.addEventListener('keydown', windowSpy) + try { + canvas.startGhostPlacement(node) + await userEvent.keyboard('{Escape}') + expect(windowSpy).not.toHaveBeenCalled() + } finally { + window.removeEventListener('keydown', windowSpy) + } + }) + + it('Delete and Backspace also cancel ghost placement', async () => { + canvas.startGhostPlacement(node) + await userEvent.keyboard('{Delete}') + expect(canvas.state.ghostNodeId).toBeNull() + expect(graph.getNodeById(node.id)).toBeFalsy() + + const node2 = new LGraphNode('test-2') + node2.size = [200, 100] + graph.add(node2) + canvas.startGhostPlacement(node2) + await userEvent.keyboard('{Backspace}') + expect(canvas.state.ghostNodeId).toBeNull() + expect(graph.getNodeById(node2.id)).toBeFalsy() + }) + + it('non-cancel keys do not finalize ghost placement', async () => { + canvas.startGhostPlacement(node) + const windowSpy = vi.fn() + window.addEventListener('keydown', windowSpy) + try { + await userEvent.keyboard('a') + expect(canvas.state.ghostNodeId).toBe(node.id) + expect(windowSpy).toHaveBeenCalledTimes(1) + } finally { + window.removeEventListener('keydown', windowSpy) + } + }) + + it('keydown listener is removed when ghost placement finalizes', async () => { + canvas.startGhostPlacement(node) + canvas.finalizeGhostPlacement(false) + + const windowSpy = vi.fn() + window.addEventListener('keydown', windowSpy) + try { + await userEvent.keyboard('{Escape}') + expect(windowSpy).toHaveBeenCalledTimes(1) + } finally { + window.removeEventListener('keydown', windowSpy) + } + }) + + it('switching the active graph cancels any in-flight ghost', async () => { + canvas.startGhostPlacement(node) + expect(canvas.state.ghostNodeId).toBe(node.id) + + canvas.setGraph(new LGraph()) + + expect(canvas.state.ghostNodeId).toBeNull() + expect(graph.getNodeById(node.id)).toBeFalsy() + + // Listener should also be gone — Escape should reach the window now + const windowSpy = vi.fn() + window.addEventListener('keydown', windowSpy) + try { + await userEvent.keyboard('{Escape}') + expect(windowSpy).toHaveBeenCalledTimes(1) + } finally { + window.removeEventListener('keydown', windowSpy) + } + }) + + it('calling startGhostPlacement again cancels the previous ghost without leaking listeners', async () => { + canvas.startGhostPlacement(node) + + const node2 = new LGraphNode('test-2') + node2.size = [200, 100] + graph.add(node2) + canvas.startGhostPlacement(node2) + + expect(graph.getNodeById(node.id)).toBeFalsy() + expect(canvas.state.ghostNodeId).toBe(node2.id) + + canvas.finalizeGhostPlacement(true) + + const windowSpy = vi.fn() + window.addEventListener('keydown', windowSpy) + try { + await userEvent.keyboard('{Escape}') + // If a stale listener leaked, it would have stopPropagation'd this Escape. + expect(windowSpy).toHaveBeenCalledTimes(1) + } finally { + window.removeEventListener('keydown', windowSpy) + } + }) +}) diff --git a/src/lib/litegraph/src/LGraphCanvas.ghostAutoPan.test.ts b/src/lib/litegraph/src/LGraphCanvas.ghostAutoPan.test.ts deleted file mode 100644 index d2f1a787b2..0000000000 --- a/src/lib/litegraph/src/LGraphCanvas.ghostAutoPan.test.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -import { LGraph, LGraphCanvas, LGraphNode } from '@/lib/litegraph/src/litegraph' -import { createMockCanvasRenderingContext2D } from '@/utils/__tests__/litegraphTestUtils' - -vi.mock('@/renderer/core/layout/store/layoutStore', () => ({ - layoutStore: { - querySlotAtPoint: vi.fn(), - queryRerouteAtPoint: vi.fn(), - getNodeLayoutRef: vi.fn(() => ({ value: null })), - getSlotLayout: vi.fn(), - setSource: vi.fn(), - setActor: vi.fn() - } -})) - -describe('LGraphCanvas ghost placement auto-pan', () => { - let canvas: LGraphCanvas - let canvasElement: HTMLCanvasElement - let graph: LGraph - let node: LGraphNode - - beforeEach(() => { - vi.useFakeTimers() - - canvasElement = document.createElement('canvas') - canvasElement.width = 800 - canvasElement.height = 600 - - canvasElement.getContext = vi - .fn() - .mockReturnValue(createMockCanvasRenderingContext2D()) - document.body.appendChild(canvasElement) - canvasElement.getBoundingClientRect = vi.fn().mockReturnValue({ - left: 0, - top: 0, - right: 800, - bottom: 600, - width: 800, - height: 600, - x: 0, - y: 0, - toJSON: () => {} - }) - - graph = new LGraph() - canvas = new LGraphCanvas(canvasElement, graph, { - skip_render: true, - skip_events: true - }) - - node = new LGraphNode('test') - node.size = [200, 100] - graph.add(node) - - // Near left edge so autopan fires by default - canvas.mouse[0] = 5 - canvas.mouse[1] = 300 - }) - - afterEach(() => { - canvasElement.remove() - vi.useRealTimers() - }) - - it('moves the ghost node when pointer is near edge', () => { - canvas.startGhostPlacement(node) - - const posXBefore = node.pos[0] - vi.advanceTimersByTime(16) - - expect(node.pos[0]).not.toBe(posXBefore) - }) - - it('does not pan when pointer is in the center', () => { - canvas.mouse[0] = 400 - canvas.startGhostPlacement(node) - - const offsetBefore = [...canvas.ds.offset] - vi.advanceTimersByTime(16) - - expect(canvas.ds.offset[0]).toBe(offsetBefore[0]) - expect(canvas.ds.offset[1]).toBe(offsetBefore[1]) - }) - - it('cleans up autopan and document listener on finalize', () => { - const removeSpy = vi.spyOn(document, 'removeEventListener') - canvas.startGhostPlacement(node) - expect(canvas['_autoPan']).not.toBeNull() - - canvas.finalizeGhostPlacement(false) - - expect(canvas['_autoPan']).toBeNull() - expect(removeSpy).toHaveBeenCalledWith('pointermove', expect.any(Function)) - removeSpy.mockRestore() - }) - - it('survives linkConnector reset during ghost placement', () => { - canvas.startGhostPlacement(node) - - canvas.linkConnector.reset() - - expect(canvas['_autoPan']).not.toBeNull() - vi.advanceTimersByTime(16) - expect(canvas.ds.offset[0]).not.toBe(0) - }) -}) diff --git a/src/lib/litegraph/src/LGraphCanvas.ts b/src/lib/litegraph/src/LGraphCanvas.ts index 00ade6e5e8..26accd0e8d 100644 --- a/src/lib/litegraph/src/LGraphCanvas.ts +++ b/src/lib/litegraph/src/LGraphCanvas.ts @@ -17,7 +17,7 @@ import type { ContextMenu } from './ContextMenu' import { createCursorCache } from './cursorCache' import { DragAndScale } from './DragAndScale' import type { AnimationOptions } from './DragAndScale' -import type { LGraph } from './LGraph' +import type { LGraph, SubgraphId } from './LGraph' import { LGraphGroup } from './LGraphGroup' import { LGraphNode } from './LGraphNode' import type { NodeId, NodeProperty } from './LGraphNode' @@ -112,7 +112,6 @@ import { alignNodes, distributeNodes, getBoundaryNodes } from './utils/arrange' import { findFirstNode, getAllNestedItems } from './utils/collections' import { resolveConnectingLinkColor } from './utils/linkColors' import { createUuidv4 } from './utils/uuid' -import type { UUID } from './utils/uuid' import { BaseWidget } from './widgets/BaseWidget' import { toConcreteWidget } from './widgets/widgetMap' @@ -228,7 +227,7 @@ interface ClipboardPasteResult { /** Map: original reroute IDs to newly created reroutes */ reroutes: Map<RerouteId, Reroute> /** Map: original subgraph IDs to newly created subgraphs */ - subgraphs: Map<UUID, Subgraph> + subgraphs: Map<SubgraphId, Subgraph> } /** Options for {@link LGraphCanvas.pasteFromClipboard}. */ @@ -684,6 +683,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap> private _visibleReroutes: Set<Reroute> = new Set() private _autoPan: AutoPanController | null = null private _ghostPointerHandler: ((e: PointerEvent) => void) | null = null + private _ghostKeyHandler: ((e: KeyboardEvent) => void) | null = null dirty_canvas: boolean = true dirty_bgcanvas: boolean = true @@ -1860,6 +1860,9 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap> const { graph } = this if (newGraph === graph) return + // Drop any in-flight ghost so listeners don't outlive the graph it belongs to + if (this.state.ghostNodeId != null) this.finalizeGhostPlacement(true) + this.clear() newGraph.attachCanvas(this) @@ -3082,7 +3085,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap> if (oldValue != widget.value) { node.onWidgetChanged?.(widget.name, widget.value, oldValue, widget) if (!node.graph) throw new NullGraphError() - node.graph._version++ + node.graph.incrementVersion() } // Clean up state var @@ -3663,6 +3666,9 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap> * @param dragEvent Optional mouse event for positioning under cursor */ startGhostPlacement(node: LGraphNode, dragEvent?: MouseEvent): void { + // Cancel any in-flight ghost so we don't leak its listeners + if (this.state.ghostNodeId != null) this.finalizeGhostPlacement(true) + this.emitBeforeChange() this.graph?.beforeChange() @@ -3702,6 +3708,19 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap> 'pointerleave', this._ghostPointerHandler ) + + // Listen on document so cancellation works even when the canvas isnt focused + // e.g. the search dialog just closed. + // stopPropagation prevents window-level keybindings (like Comfy.Graph.ExitSubgraph on Escape) from firing alongside the cancel. + this._ghostKeyHandler = (e: KeyboardEvent) => { + if (e.key !== 'Escape' && e.key !== 'Delete' && e.key !== 'Backspace') { + return + } + this.finalizeGhostPlacement(true) + e.stopPropagation() + e.preventDefault() + } + document.addEventListener('keydown', this._ghostKeyHandler, true) } /** @@ -3730,6 +3749,11 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap> this._ghostPointerHandler = null } + if (this._ghostKeyHandler) { + document.removeEventListener('keydown', this._ghostKeyHandler, true) + this._ghostKeyHandler = null + } + const node = this.graph?.getNodeById(nodeId) if (!node) return @@ -3919,17 +3943,6 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap> const { graph } = this if (!graph) return - // Cancel ghost placement - if ( - (e.key === 'Escape' || e.key === 'Delete' || e.key === 'Backspace') && - this.state.ghostNodeId != null - ) { - this.finalizeGhostPlacement(true) - e.stopPropagation() - e.preventDefault() - return - } - let block_default = false // @ts-expect-error EventTarget.localName is not in standard types if (e.target.localName == 'input') return @@ -4139,7 +4152,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap> nodes: new Map<NodeId, LGraphNode>(), links: new Map<LinkId, LLink>(), reroutes: new Map<RerouteId, Reroute>(), - subgraphs: new Map<UUID, Subgraph>() + subgraphs: new Map<SubgraphId, Subgraph>() } const { created, nodes, links, reroutes } = results @@ -7897,7 +7910,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap> } node.properties[property] = value if (node.graph) { - node.graph._version++ + node.graph.incrementVersion() } node.onPropertyChanged?.(property, value) options.onclose?.() @@ -9075,7 +9088,7 @@ export function remapClipboardSubgraphNodeIds( return nextId } - const subgraphNodeIdMap = new Map<UUID, Map<NodeId, NodeId>>() + const subgraphNodeIdMap = new Map<SubgraphId, Map<NodeId, NodeId>>() for (const subgraphInfo of parsed.subgraphs ?? []) { const remappedIds = new Map<NodeId, NodeId>() const interiorNodes = subgraphInfo.nodes ?? [] diff --git a/src/lib/litegraph/src/LGraphGroup.ts b/src/lib/litegraph/src/LGraphGroup.ts index 52b61a93c8..08704bde7e 100644 --- a/src/lib/litegraph/src/LGraphGroup.ts +++ b/src/lib/litegraph/src/LGraphGroup.ts @@ -25,6 +25,8 @@ import { } from './measure' import type { ISerialisedGroup } from './types/serialisation' +export type GroupId = number + export interface IGraphGroupFlags extends Record<string, unknown> { pinned?: true } @@ -36,7 +38,7 @@ export class LGraphGroup implements Positionable, IPinnable, IColorable { static padding = 4 static defaultColour = '#335' - id: number + id: GroupId color?: string title: string font?: string @@ -52,7 +54,7 @@ export class LGraphGroup implements Positionable, IPinnable, IColorable { flags: IGraphGroupFlags = {} selected?: boolean - constructor(title?: string, id?: number) { + constructor(title?: string, id?: GroupId) { // TODO: Object instantiation pattern requires too much boilerplate and null checking. ID should be passed in via constructor. this.id = id ?? -1 this.title = title || 'Group' @@ -250,7 +252,7 @@ export class LGraphGroup implements Positionable, IPinnable, IColorable { */ recomputeInsideNodes( maxDepth: number = 100, - visited: Set<number> = new Set() + visited: Set<GroupId> = new Set() ): void { if (!this.graph) throw new NullGraphError() if (maxDepth <= 0 || visited.has(this.id)) return diff --git a/src/lib/litegraph/src/LGraphNode.ts b/src/lib/litegraph/src/LGraphNode.ts index 44c23838f9..934239cc7d 100644 --- a/src/lib/litegraph/src/LGraphNode.ts +++ b/src/lib/litegraph/src/LGraphNode.ts @@ -51,7 +51,8 @@ import type { Positionable, ReadOnlyRect, Rect, - Size + Size, + SlotIndex } from './interfaces' import { LiteGraph, Subgraph } from './litegraph' import type { LGraphNodeConstructor, SubgraphNode } from './litegraph' @@ -830,7 +831,7 @@ export class LGraphNode */ configure(info: ISerialisedNode): void { if (this.graph) { - this.graph._version++ + this.graph.incrementVersion() } if (info.id === -1) info.id = this.id for (const j in info) { @@ -1106,7 +1107,7 @@ export class LGraphNode /** * sets the output data type, useful when you want to be able to overwrite the data type */ - setOutputDataType(slot: number, type: ISlotType): void { + setOutputDataType(slot: SlotIndex, type: ISlotType): void { const { outputs } = this if (!outputs || slot == -1 || slot >= outputs.length) return @@ -1164,7 +1165,7 @@ export class LGraphNode * @param slot * @returns datatype in string format */ - getInputDataType(slot: number): ISlotType | null { + getInputDataType(slot: SlotIndex): ISlotType | null { if (!this.inputs) return null if (slot >= this.inputs.length || this.inputs[slot].link == null) return null @@ -2989,7 +2990,7 @@ export class LGraphNode } } } - graph._version++ + graph.incrementVersion() // link has been created now, so its updated this.onConnectionsChange?.( @@ -3138,7 +3139,7 @@ export class LGraphNode // remove the link from the links pool link_info.disconnect(graph, 'input') - graph._version++ + graph.incrementVersion() // link_info hasn't been modified so its ok target.onConnectionsChange?.( @@ -3176,7 +3177,7 @@ export class LGraphNode } const target = graph.getNodeById(link_info.target_id) - graph._version++ + graph.incrementVersion() if (target) { const input = target.inputs[link_info.target_slot] @@ -3304,7 +3305,7 @@ export class LGraphNode } link_info.disconnect(graph, keepReroutes ? 'output' : undefined) - if (graph) graph._version++ + if (graph) graph.incrementVersion() this.onConnectionsChange?.( NodeSlotType.INPUT, @@ -3448,7 +3449,7 @@ export class LGraphNode * @param outputSlotIndex Output slot index * @returns Position of the output slot */ - getOutputPos(outputSlotIndex: number): Point { + getOutputPos(outputSlotIndex: SlotIndex): Point { return getSlotPosition(this, outputSlotIndex, false) } @@ -3539,7 +3540,7 @@ export class LGraphNode collapse(force?: boolean): void { if (!this.collapsible && !force) return if (!this.graph) throw new NullGraphError() - this.graph._version++ + this.graph.incrementVersion() this.flags.collapsed = !this.flags.collapsed this.setDirtyCanvas(true, true) } @@ -3550,7 +3551,7 @@ export class LGraphNode toggleAdvanced() { if (!this.hasAdvancedWidgets()) return if (!this.graph) throw new NullGraphError() - this.graph._version++ + this.graph.incrementVersion() this.showAdvanced = !this.showAdvanced this.expandToFitContent() this.setDirtyCanvas(true, true) @@ -3567,7 +3568,7 @@ export class LGraphNode pin(v?: boolean): void { if (!this.graph) throw new NullGraphError() - this.graph._version++ + this.graph.incrementVersion() this.flags.pinned = v ?? !this.flags.pinned this.resizable = !this.pinned if (!this.pinned) this.flags.pinned = undefined diff --git a/src/lib/litegraph/src/canvas/FloatingRenderLink.ts b/src/lib/litegraph/src/canvas/FloatingRenderLink.ts index 9f4fc92f07..46e0d19b9a 100644 --- a/src/lib/litegraph/src/canvas/FloatingRenderLink.ts +++ b/src/lib/litegraph/src/canvas/FloatingRenderLink.ts @@ -11,7 +11,8 @@ import type { INodeInputSlot, INodeOutputSlot, LinkNetwork, - Point + Point, + SlotIndex } from '@/lib/litegraph/src/interfaces' import type { SubgraphInput } from '@/lib/litegraph/src/subgraph/SubgraphInput' import type { SubgraphOutput } from '@/lib/litegraph/src/subgraph/SubgraphOutput' @@ -34,7 +35,7 @@ export class FloatingRenderLink implements RenderLink { readonly fromSlot: INodeOutputSlot | INodeInputSlot readonly fromPos: Point readonly fromDirection: LinkDirection - readonly fromSlotIndex: number + readonly fromSlotIndex: SlotIndex readonly outputNodeId: NodeId = -1 readonly outputNode?: LGraphNode diff --git a/src/lib/litegraph/src/canvas/MovingInputLink.ts b/src/lib/litegraph/src/canvas/MovingInputLink.ts index 3e8ddce585..3665dc0fe6 100644 --- a/src/lib/litegraph/src/canvas/MovingInputLink.ts +++ b/src/lib/litegraph/src/canvas/MovingInputLink.ts @@ -8,7 +8,8 @@ import type { INodeInputSlot, INodeOutputSlot, LinkNetwork, - Point + Point, + SlotIndex } from '@/lib/litegraph/src/interfaces' import type { SubgraphOutput } from '@/lib/litegraph/src/subgraph/SubgraphOutput' import type { NodeLike } from '@/lib/litegraph/src/types/NodeLike' @@ -24,7 +25,7 @@ export class MovingInputLink extends MovingLinkBase { readonly fromSlot: INodeOutputSlot readonly fromPos: Point readonly fromDirection: LinkDirection - readonly fromSlotIndex: number + readonly fromSlotIndex: SlotIndex disconnectOnDrop: boolean readonly disconnectOrigin: Point diff --git a/src/lib/litegraph/src/canvas/MovingLinkBase.ts b/src/lib/litegraph/src/canvas/MovingLinkBase.ts index 825a268567..06d19a4639 100644 --- a/src/lib/litegraph/src/canvas/MovingLinkBase.ts +++ b/src/lib/litegraph/src/canvas/MovingLinkBase.ts @@ -7,7 +7,8 @@ import type { INodeInputSlot, INodeOutputSlot, LinkNetwork, - Point + Point, + SlotIndex } from '@/lib/litegraph/src/interfaces' import type { SubgraphInput } from '@/lib/litegraph/src/subgraph/SubgraphInput' import type { SubgraphOutput } from '@/lib/litegraph/src/subgraph/SubgraphOutput' @@ -33,7 +34,7 @@ export abstract class MovingLinkBase implements RenderLink { abstract readonly fromSlot: INodeOutputSlot | INodeInputSlot abstract readonly fromPos: Point abstract readonly fromDirection: LinkDirection - abstract readonly fromSlotIndex: number + abstract readonly fromSlotIndex: SlotIndex readonly outputNodeId: NodeId readonly outputNode: LGraphNode diff --git a/src/lib/litegraph/src/canvas/MovingOutputLink.ts b/src/lib/litegraph/src/canvas/MovingOutputLink.ts index 2a1890d737..147cf56ab7 100644 --- a/src/lib/litegraph/src/canvas/MovingOutputLink.ts +++ b/src/lib/litegraph/src/canvas/MovingOutputLink.ts @@ -7,7 +7,8 @@ import type { INodeInputSlot, INodeOutputSlot, LinkNetwork, - Point + Point, + SlotIndex } from '@/lib/litegraph/src/interfaces' import type { SubgraphInput } from '@/lib/litegraph/src/subgraph/SubgraphInput' import type { NodeLike } from '@/lib/litegraph/src/types/NodeLike' @@ -23,7 +24,7 @@ export class MovingOutputLink extends MovingLinkBase { readonly fromSlot: INodeInputSlot readonly fromPos: Point readonly fromDirection: LinkDirection - readonly fromSlotIndex: number + readonly fromSlotIndex: SlotIndex constructor( network: LinkNetwork, diff --git a/src/lib/litegraph/src/canvas/RenderLink.ts b/src/lib/litegraph/src/canvas/RenderLink.ts index 1c8f73bb71..c79c88abc0 100644 --- a/src/lib/litegraph/src/canvas/RenderLink.ts +++ b/src/lib/litegraph/src/canvas/RenderLink.ts @@ -1,7 +1,11 @@ import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode' import type { CustomEventTarget } from '@/lib/litegraph/src/infrastructure/CustomEventTarget' import type { LinkConnectorEventMap } from '@/lib/litegraph/src/infrastructure/LinkConnectorEventMap' -import type { LinkNetwork, Point } from '@/lib/litegraph/src/interfaces' +import type { + LinkNetwork, + Point, + SlotIndex +} from '@/lib/litegraph/src/interfaces' import type { INodeInputSlot, INodeOutputSlot, @@ -35,7 +39,7 @@ export interface RenderLink { | SubgraphInput | SubgraphOutput /** The index of the slot that the link is being connected from. */ - readonly fromSlotIndex: number + readonly fromSlotIndex: SlotIndex /** The reroute that the link is being connected from. */ readonly fromReroute?: Reroute diff --git a/src/lib/litegraph/src/canvas/ToInputFromIoNodeLink.ts b/src/lib/litegraph/src/canvas/ToInputFromIoNodeLink.ts index fc645530e9..057f1e6bc5 100644 --- a/src/lib/litegraph/src/canvas/ToInputFromIoNodeLink.ts +++ b/src/lib/litegraph/src/canvas/ToInputFromIoNodeLink.ts @@ -6,7 +6,8 @@ import type { LinkConnectorEventMap } from '@/lib/litegraph/src/infrastructure/L import type { INodeInputSlot, LinkNetwork, - Point + Point, + SlotIndex } from '@/lib/litegraph/src/interfaces' import type { SubgraphInput } from '@/lib/litegraph/src/subgraph/SubgraphInput' import type { SubgraphInputNode } from '@/lib/litegraph/src/subgraph/SubgraphInputNode' @@ -19,7 +20,7 @@ import type { RenderLink } from './RenderLink' export class ToInputFromIoNodeLink implements RenderLink { readonly toType = 'input' - readonly fromSlotIndex: number + readonly fromSlotIndex: SlotIndex readonly fromPos: Point fromDirection: LinkDirection = LinkDirection.RIGHT readonly existingLink?: LLink diff --git a/src/lib/litegraph/src/canvas/ToInputRenderLink.ts b/src/lib/litegraph/src/canvas/ToInputRenderLink.ts index 61d517ae5d..c2f0e613ef 100644 --- a/src/lib/litegraph/src/canvas/ToInputRenderLink.ts +++ b/src/lib/litegraph/src/canvas/ToInputRenderLink.ts @@ -7,7 +7,8 @@ import type { INodeInputSlot, INodeOutputSlot, LinkNetwork, - Point + Point, + SlotIndex } from '@/lib/litegraph/src/interfaces' import type { SubgraphOutput } from '@/lib/litegraph/src/subgraph/SubgraphOutput' import type { NodeLike } from '@/lib/litegraph/src/types/NodeLike' @@ -20,7 +21,7 @@ import type { RenderLink } from './RenderLink' export class ToInputRenderLink implements RenderLink { readonly toType = 'input' readonly fromPos: Point - readonly fromSlotIndex: number + readonly fromSlotIndex: SlotIndex fromDirection: LinkDirection = LinkDirection.RIGHT constructor( diff --git a/src/lib/litegraph/src/canvas/ToOutputFromIoNodeLink.ts b/src/lib/litegraph/src/canvas/ToOutputFromIoNodeLink.ts index d700801186..5c847b8743 100644 --- a/src/lib/litegraph/src/canvas/ToOutputFromIoNodeLink.ts +++ b/src/lib/litegraph/src/canvas/ToOutputFromIoNodeLink.ts @@ -5,7 +5,8 @@ import type { LinkConnectorEventMap } from '@/lib/litegraph/src/infrastructure/L import type { INodeOutputSlot, LinkNetwork, - Point + Point, + SlotIndex } from '@/lib/litegraph/src/interfaces' import type { SubgraphOutput } from '@/lib/litegraph/src/subgraph/SubgraphOutput' import type { SubgraphOutputNode } from '@/lib/litegraph/src/subgraph/SubgraphOutputNode' @@ -20,7 +21,7 @@ import type { RenderLink } from './RenderLink' export class ToOutputFromIoNodeLink implements RenderLink { readonly toType = 'output' readonly fromPos: Point - readonly fromSlotIndex: number + readonly fromSlotIndex: SlotIndex fromDirection: LinkDirection = LinkDirection.LEFT constructor( diff --git a/src/lib/litegraph/src/canvas/ToOutputRenderLink.ts b/src/lib/litegraph/src/canvas/ToOutputRenderLink.ts index 9f94b77968..d55cdec9c7 100644 --- a/src/lib/litegraph/src/canvas/ToOutputRenderLink.ts +++ b/src/lib/litegraph/src/canvas/ToOutputRenderLink.ts @@ -6,7 +6,8 @@ import type { INodeInputSlot, INodeOutputSlot, LinkNetwork, - Point + Point, + SlotIndex } from '@/lib/litegraph/src/interfaces' import type { SubgraphInput } from '@/lib/litegraph/src/subgraph/SubgraphInput' import type { NodeLike } from '@/lib/litegraph/src/types/NodeLike' @@ -20,7 +21,7 @@ import type { RenderLink } from './RenderLink' export class ToOutputRenderLink implements RenderLink { readonly toType = 'output' readonly fromPos: Point - readonly fromSlotIndex: number + readonly fromSlotIndex: SlotIndex fromDirection: LinkDirection = LinkDirection.LEFT constructor( diff --git a/src/lib/litegraph/src/interfaces.ts b/src/lib/litegraph/src/interfaces.ts index b0bbc884fc..d5208a1e31 100644 --- a/src/lib/litegraph/src/interfaces.ts +++ b/src/lib/litegraph/src/interfaces.ts @@ -178,7 +178,7 @@ export interface LinkNetwork extends ReadonlyLinkNetwork { readonly links: Map<LinkId, LLink> readonly reroutes: Map<RerouteId, Reroute> addFloatingLink(link: LLink): LLink - removeReroute(id: number): unknown + removeReroute(id: RerouteId): unknown removeFloatingLink(link: LLink): void } @@ -218,7 +218,7 @@ export interface LinkSegment { /** Output node ID */ readonly origin_id: NodeId | undefined /** Output slot index */ - readonly origin_slot: number | undefined + readonly origin_slot: SlotIndex | undefined } interface IInputOrOutput { @@ -230,11 +230,14 @@ interface IInputOrOutput { export interface IFoundSlot extends IInputOrOutput { // Slot index - slot: number + slot: SlotIndex // Centre point of the rendered slot connection link_pos: Point } +/** Index of an input or output slot on a node. */ +export type SlotIndex = number + /** A point represented as `[x, y]` co-ordinates */ export type Point = [x: number, y: number] @@ -374,7 +377,7 @@ export interface IWidgetInputSlot extends INodeInputSlot { export interface INodeOutputSlot extends INodeSlot { links: LinkId[] | null _data?: unknown - slot_index?: number + slot_index?: SlotIndex } /** Options for {@link LiteGraphGlobal.createNode}. Shallow-copied onto the new node. */ @@ -396,7 +399,7 @@ export interface CreateNodeOptions { /** Links */ export interface ConnectingLink extends IInputOrOutput { node: LGraphNode - slot: number + slot: SlotIndex pos: Point direction?: LinkDirection afterRerouteId?: RerouteId diff --git a/src/lib/litegraph/src/litegraph.ts b/src/lib/litegraph/src/litegraph.ts index 444e9903fb..201cac9613 100644 --- a/src/lib/litegraph/src/litegraph.ts +++ b/src/lib/litegraph/src/litegraph.ts @@ -101,7 +101,8 @@ export type { LinkNetwork, Point, Positionable, - Size + Size, + SlotIndex } from './interfaces' export { LGraph, @@ -109,12 +110,13 @@ export { type GroupNodeWorkflowData, type LGraphTriggerAction, type LGraphTriggerParam, - type GraphAddOptions + type GraphAddOptions, + type SubgraphId } from './LGraph' export type { LGraphTriggerEvent } from './types/graphTriggers' export { BadgePosition, LGraphBadge } from './LGraphBadge' export { LGraphCanvas } from './LGraphCanvas' -export { LGraphGroup } from './LGraphGroup' +export { LGraphGroup, type GroupId } from './LGraphGroup' export { LGraphNode, type NodeId } from './LGraphNode' export { LLink } from './LLink' export { createBounds } from './measure' diff --git a/src/lib/litegraph/src/subgraph/ExecutableNodeDTO.ts b/src/lib/litegraph/src/subgraph/ExecutableNodeDTO.ts index bacfd609e2..6ea8e0ca74 100644 --- a/src/lib/litegraph/src/subgraph/ExecutableNodeDTO.ts +++ b/src/lib/litegraph/src/subgraph/ExecutableNodeDTO.ts @@ -1,5 +1,6 @@ import type { LGraph } from '@/lib/litegraph/src/LGraph' import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode' +import type { LinkId } from '@/lib/litegraph/src/LLink' import { InvalidLinkError } from '@/lib/litegraph/src/infrastructure/InvalidLinkError' import { NullGraphError } from '@/lib/litegraph/src/infrastructure/NullGraphError' import { RecursionError } from '@/lib/litegraph/src/infrastructure/RecursionError' @@ -47,7 +48,7 @@ export class ExecutableNodeDTO implements ExecutableLGraphNode { /** The graph that this node is a part of. */ readonly graph: LGraph | Subgraph - inputs: { linkId: number | null; name: string; type: ISlotType }[] + inputs: { linkId: LinkId | null; name: string; type: ISlotType }[] /** Backing field for {@link id}. */ private _id: ExecutionId @@ -186,16 +187,11 @@ export class ExecutableNodeDTO implements ExecutableLGraphNode { if (!widget) return // Special case: SubgraphNode widget. - // Prefer serializeValue (per-instance) over the shared .value getter - // so multiple SubgraphNode instances return their own configured values. - const widgetValue = widget.serializeValue - ? widget.serializeValue(subgraphNode, -1) - : widget.value return { node: this, origin_id: this.id, origin_slot: -1, - widgetInfo: { value: widgetValue } + widgetInfo: { value: widget.value } } } @@ -209,7 +205,8 @@ export class ExecutableNodeDTO implements ExecutableLGraphNode { `No outer link found for slot [${link.origin_slot}] ${input.name}` ) - const subgraphNodeExecutionId = this.subgraphNodePath.join(':') + const subgraphNodeExecutionId: ExecutionId = + this.subgraphNodePath.join(':') const subgraphNodeDto = this.nodesByExecutionId.get( subgraphNodeExecutionId ) @@ -228,7 +225,7 @@ export class ExecutableNodeDTO implements ExecutableLGraphNode { `No input node found for id [${this.id}] slot [${slot}] ${input.name}` ) - const outputNodeExecutionId = [ + const outputNodeExecutionId: ExecutionId = [ ...this.subgraphNodePath, outputNode.id ].join(':') @@ -318,7 +315,7 @@ export class ExecutableNodeDTO implements ExecutableLGraphNode { `Virtual node failed to resolve parent [${this.id}] slot [${slot}]` ) - const inputNodeExecutionId = [ + const inputNodeExecutionId: ExecutionId = [ ...this.subgraphNodePath, inputNode.id ].join(':') @@ -410,7 +407,7 @@ export class ExecutableNodeDTO implements ExecutableLGraphNode { ) // Recurse into the subgraph - const innerNodeExecutionId = [ + const innerNodeExecutionId: ExecutionId = [ ...this.subgraphNodePath, node.id, innerNode.id diff --git a/src/lib/litegraph/src/subgraph/SubgraphInput.ts b/src/lib/litegraph/src/subgraph/SubgraphInput.ts index a513b0d83d..c94a078202 100644 --- a/src/lib/litegraph/src/subgraph/SubgraphInput.ts +++ b/src/lib/litegraph/src/subgraph/SubgraphInput.ts @@ -134,7 +134,7 @@ export class SubgraphInput extends SubgraphSlot { } } } - subgraph._version++ + subgraph.incrementVersion() node.onConnectionsChange?.(NodeSlotType.INPUT, inputIndex, true, link, slot) diff --git a/src/lib/litegraph/src/subgraph/SubgraphInputNode.ts b/src/lib/litegraph/src/subgraph/SubgraphInputNode.ts index 3687fd32e2..7a23809d23 100644 --- a/src/lib/litegraph/src/subgraph/SubgraphInputNode.ts +++ b/src/lib/litegraph/src/subgraph/SubgraphInputNode.ts @@ -187,7 +187,7 @@ export class SubgraphInputNode const subgraphInputIndex = link.origin_slot link.disconnect(subgraph, 'output') - subgraph._version++ + subgraph.incrementVersion() const subgraphInput = this.slots.at(subgraphInputIndex) if (!subgraphInput) { diff --git a/src/lib/litegraph/src/subgraph/SubgraphNode.multiInstance.test.ts b/src/lib/litegraph/src/subgraph/SubgraphNode.multiInstance.test.ts deleted file mode 100644 index 66f2817cc3..0000000000 --- a/src/lib/litegraph/src/subgraph/SubgraphNode.multiInstance.test.ts +++ /dev/null @@ -1,261 +0,0 @@ -import { createTestingPinia } from '@pinia/testing' -import { setActivePinia } from 'pinia' -import { beforeEach, describe, expect, it } from 'vitest' - -import type { ISlotType } from '@/lib/litegraph/src/litegraph' -import { LGraphNode } from '@/lib/litegraph/src/litegraph' - -import { - createTestSubgraph, - createTestSubgraphNode, - resetSubgraphFixtureState -} from './__fixtures__/subgraphHelpers' - -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 } -} - -beforeEach(() => { - setActivePinia(createTestingPinia({ stubActions: false })) - resetSubgraphFixtureState() -}) - -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('keeps fresh sibling instances isolated before save or reload', () => { - 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 - - expect(widget1?.value).toBe(10) - expect(widget2?.value).toBe(7) - expect(widget1?.serializeValue?.(instance1, 0)).toBe(10) - expect(widget2?.serializeValue?.(instance2, 0)).toBe(7) - }) - - it('syncs restored promoted widgets 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 - - expect(restoredInstance.widgets?.[0].value).toBe(45) - expect( - restoredInstance.widgets?.[0].serializeValue?.(restoredInstance, 0) - ).toBe(45) - }) - - it('clears stale 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 - }) - - expect(instance.widgets?.[0].value).toBe(17) - expect(instance.widgets?.[0].serializeValue?.(instance, 0)).toBe(17) - }) - - 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() - }) -}) diff --git a/src/lib/litegraph/src/subgraph/SubgraphNode.ts b/src/lib/litegraph/src/subgraph/SubgraphNode.ts index 880314249e..ea2bf3af93 100644 --- a/src/lib/litegraph/src/subgraph/SubgraphNode.ts +++ b/src/lib/litegraph/src/subgraph/SubgraphNode.ts @@ -1,4 +1,4 @@ -import type { BaseLGraph, LGraph } from '@/lib/litegraph/src/LGraph' +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' import { LGraphNode } from '@/lib/litegraph/src/LGraphNode' @@ -29,7 +29,6 @@ import type { } 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 { UUID } from '@/lib/litegraph/src/utils/uuid' import { createPromotedWidgetView, isPromotedWidgetView @@ -77,7 +76,7 @@ const workflowBitmapCache = createBitmapCache(workflowSvg, 32) export class SubgraphNode extends LGraphNode implements BaseLGraph { declare inputs: (INodeInputSlot & Partial<ISubgraphInput>)[] - override readonly type: UUID + override readonly type: SubgraphId override readonly isVirtualNode = true as const override graph: GraphOrSubgraph | null @@ -993,21 +992,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph { } } - /** Temporarily stored during configure for use by _internalConfigureAfterSlots */ - private _pendingWidgetsValues?: unknown[] - - /** - * Per-instance promoted widget values. - * Multiple SubgraphNode instances share the same inner nodes, so - * promoted widget values must be stored per-instance to avoid collisions. - * Key: `${sourceNodeId}:${sourceWidgetName}` - */ - readonly _instanceWidgetValues = new Map<string, unknown>() - override configure(info: ExportedSubgraphInstance): void { - this._instanceWidgetValues.clear() - this._pendingWidgetsValues = info.widgets_values - for (const input of this.inputs) { if ( input._listenerController && @@ -1138,21 +1123,6 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph { if (store.isPromoted(this.rootGraph.id, this.id, source)) continue store.promote(this.rootGraph.id, this.id, source) } - - // Hydrate per-instance promoted widget values from serialized data. - // LGraphNode.configure skips promoted widgets (serialize === false on - // the view), so they must be applied here after promoted views exist. - // Only iterate serializable views to match what serialize() wrote. - if (this._pendingWidgetsValues) { - const views = this._getPromotedViews() - let i = 0 - for (const view of views) { - if (!view.sourceSerialize) continue - if (i >= this._pendingWidgetsValues.length) break - view.value = this._pendingWidgetsValues[i++] as typeof view.value - } - this._pendingWidgetsValues = undefined - } } /** @@ -1547,7 +1517,6 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph { override onRemoved(): void { this._eventAbortController.abort() this._invalidatePromotedViewsCache() - this._instanceWidgetValues.clear() for (const widget of this.widgets) { if (isPromotedWidgetView(widget)) { @@ -1603,7 +1572,28 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph { ctx.restore() } + /** + * 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. + */ 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, @@ -1611,22 +1601,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph { ) this.properties.proxyWidgets = this._serializeEntries(entries) - const serialized = super.serialize() - const views = this._getPromotedViews() - - const serializableViews = views.filter((view) => view.sourceSerialize) - if (serializableViews.length > 0) { - serialized.widgets_values = serializableViews.map((view) => { - const value = view.serializeValue - ? view.serializeValue(this, -1) - : view.value - return value != null && typeof value === 'object' - ? JSON.parse(JSON.stringify(value)) - : (value ?? null) - }) - } - - return serialized + return super.serialize() } override clone() { const clone = super.clone() diff --git a/src/lib/litegraph/src/subgraph/SubgraphOutput.ts b/src/lib/litegraph/src/subgraph/SubgraphOutput.ts index 460e49ff58..8d6f9847b4 100644 --- a/src/lib/litegraph/src/subgraph/SubgraphOutput.ts +++ b/src/lib/litegraph/src/subgraph/SubgraphOutput.ts @@ -99,7 +99,7 @@ export class SubgraphOutput extends SubgraphSlot { } } } - subgraph._version++ + subgraph.incrementVersion() node.onConnectionsChange?.( NodeSlotType.OUTPUT, diff --git a/src/lib/litegraph/src/subgraph/subgraphUtils.ts b/src/lib/litegraph/src/subgraph/subgraphUtils.ts index 518869503d..e8119f64ea 100644 --- a/src/lib/litegraph/src/subgraph/subgraphUtils.ts +++ b/src/lib/litegraph/src/subgraph/subgraphUtils.ts @@ -1,4 +1,4 @@ -import type { LGraph } from '@/lib/litegraph/src/LGraph' +import type { LGraph, SubgraphId } from '@/lib/litegraph/src/LGraph' import { LGraphGroup } from '@/lib/litegraph/src/LGraphGroup' import { LGraphNode } from '@/lib/litegraph/src/LGraphNode' import { LLink } from '@/lib/litegraph/src/LLink' @@ -21,7 +21,6 @@ import type { SerialisableLLink, SubgraphIO } from '@/lib/litegraph/src/types/serialisation' -import type { UUID } from '@/lib/litegraph/src/utils/uuid' import type { GraphOrSubgraph } from './Subgraph' import type { SubgraphInput } from './SubgraphInput' @@ -438,8 +437,8 @@ export function mapSubgraphOutputsAndLinks( * @param graph The graph to check for subgraph nodes * @returns Set of subgraph IDs used in this graph */ -export function getDirectSubgraphIds(graph: GraphOrSubgraph): Set<UUID> { - const subgraphIds = new Set<UUID>() +export function getDirectSubgraphIds(graph: GraphOrSubgraph): Set<SubgraphId> { + const subgraphIds = new Set<SubgraphId>() for (const node of graph._nodes) { if (node.isSubgraphNode()) { @@ -458,9 +457,9 @@ export function getDirectSubgraphIds(graph: GraphOrSubgraph): Set<UUID> { */ export function findUsedSubgraphIds( rootGraph: GraphOrSubgraph, - subgraphRegistry: Map<UUID, GraphOrSubgraph> -): Set<UUID> { - const usedSubgraphIds = new Set<UUID>() + subgraphRegistry: Map<SubgraphId, GraphOrSubgraph> +): Set<SubgraphId> { + const usedSubgraphIds = new Set<SubgraphId>() const toVisit: GraphOrSubgraph[] = [rootGraph] while (toVisit.length > 0) { diff --git a/src/lib/litegraph/src/types/serialisation.ts b/src/lib/litegraph/src/types/serialisation.ts index ad7b76b84f..5d075bdccd 100644 --- a/src/lib/litegraph/src/types/serialisation.ts +++ b/src/lib/litegraph/src/types/serialisation.ts @@ -1,7 +1,12 @@ import type { UUID } from '@/lib/litegraph/src/utils/uuid' -import type { LGraphConfig, LGraphExtra, LGraphState } from '../LGraph' -import type { IGraphGroupFlags } from '../LGraphGroup' +import type { + LGraphConfig, + LGraphExtra, + LGraphState, + SubgraphId +} from '../LGraph' +import type { GroupId, IGraphGroupFlags } from '../LGraphGroup' import type { NodeId, NodeProperty } from '../LGraphNode' import type { LinkId, SerialisedLLinkArray } from '../LLink' import type { FloatingRerouteSlot, RerouteId } from '../Reroute' @@ -110,7 +115,7 @@ export interface ExportedSubgraphInstance extends NodeSubgraphSharedProps { * The ID of the actual subgraph definition. * @see {@link ExportedSubgraph.subgraphs} */ - type: UUID + type: SubgraphId /** Custom properties for this subgraph instance */ properties?: Dictionary<NodeProperty | undefined> } @@ -121,7 +126,7 @@ export interface ExportedSubgraphInstance extends NodeSubgraphSharedProps { */ export interface ISerialisedGraph extends BaseExportedGraph { last_node_id: NodeId - last_link_id: number + last_link_id: LinkId nodes: ISerialisedNode[] links: SerialisedLLinkArray[] floatingLinks?: SerialisableLLink[] @@ -177,7 +182,7 @@ export interface ExposedWidget { /** Serialised LGraphGroup */ export interface ISerialisedGroup { - id: number + id: GroupId title: string bounding: number[] color?: string diff --git a/src/lib/litegraph/src/widgets/BaseWidget.ts b/src/lib/litegraph/src/widgets/BaseWidget.ts index 312c0f6fcc..a2ad4d8f35 100644 --- a/src/lib/litegraph/src/widgets/BaseWidget.ts +++ b/src/lib/litegraph/src/widgets/BaseWidget.ts @@ -435,7 +435,7 @@ export abstract class BaseWidget<TWidget extends IBaseWidget = IBaseWidget> this.callback?.(this.value, canvas, node, pos, e) node.onWidgetChanged?.(this.name ?? '', v, oldValue, this) - if (node.graph) node.graph._version++ + if (node.graph) node.graph.incrementVersion() } /** diff --git a/src/locales/ar/main.json b/src/locales/ar/main.json index c8d74c71d0..098c7ef76d 100644 --- a/src/locales/ar/main.json +++ b/src/locales/ar/main.json @@ -1008,13 +1008,16 @@ "back": "رجوع", "batchRename": "إعادة تسمية جماعية", "beta": "نسخة تجريبية", + "blueprints": "المخططات", "bookmark": "حفظ في المكتبة", + "bookmarked": "تمت الإضافة إلى العلامات", "browserReservedKeybinding": "هذا الاختصار محجوز من قبل بعض المتصفحات وقد يؤدي إلى نتائج غير متوقعة.", "browserReservedKeybindingTooltip": "هذا الاختصار يتعارض مع اختصارات المتصفح المحجوزة", "calculatingDimensions": "جارٍ حساب الأبعاد", "cancel": "إلغاء", "cancelled": "أُلغي", "capture": "التقاط", + "categories": "الفئات", "category": "الفئة", "changeKeybinding": "تغيير اختصار المفتاح", "chart": "مخطط", @@ -1235,6 +1238,7 @@ "output": "إخراج", "overwrite": "الكتابة فوق", "partner": "شريك", + "partnerNodes": "العُقد الشريكة", "pause": "إيقاف مؤقت", "play": "تشغيل", "playPause": "تشغيل/إيقاف مؤقت", @@ -2607,6 +2611,9 @@ "metadataFetchFailed": "فشل في جلب البيانات الوصفية. يرجى التحقق من الرابط والمحاولة مرة أخرى.", "missingModelsTitle": "النماذج المفقودة", "or": "أو", + "refresh": "تحديث", + "refreshFailed": "فشل في تحديث النماذج المفقودة. يرجى المحاولة مرة أخرى.", + "refreshing": "يتم تحديث النماذج المفقودة.", "typeMismatch": "يبدو أن هذا النموذج هو \"{detectedType}\". هل أنت متأكد؟", "unknownCategory": "غير معروف", "unsupportedUrl": "يتم دعم روابط Civitai و Hugging Face فقط.", @@ -3500,6 +3507,7 @@ "no3dScene": "لا يوجد مشهد ثلاثي الأبعاد لتطبيق الخامة", "no3dSceneToExport": "لا يوجد مشهد ثلاثي الأبعاد للتصدير", "noTemplatesToExport": "لا توجد قوالب للتصدير", + "nodeDefinitionsUpdateFailed": "فشل في تحديث تعريفات العقد", "nodeDefinitionsUpdated": "تم تحديث تعريفات العقد", "nothingSelected": "لم يتم تحديد شيء", "nothingToGroup": "لا يوجد شيء لتجميعه", diff --git a/src/locales/ar/nodeDefs.json b/src/locales/ar/nodeDefs.json index f3547236e6..5fdca65655 100644 --- a/src/locales/ar/nodeDefs.json +++ b/src/locales/ar/nodeDefs.json @@ -4534,6 +4534,156 @@ } } }, + "HappyHorseImageToVideoApi": { + "description": "إنشاء فيديو من صورة الإطار الأول باستخدام نموذج HappyHorse.", + "display_name": "HappyHorse تحويل صورة إلى فيديو", + "inputs": { + "control_after_generate": { + "name": "التحكم بعد التوليد" + }, + "first_frame": { + "name": "الإطار الأول", + "tooltip": "صورة الإطار الأول. يتم اشتقاق نسبة العرض إلى الارتفاع من هذه الصورة." + }, + "model": { + "name": "النموذج" + }, + "model_duration": { + "name": "المدة" + }, + "model_prompt": { + "name": "الموجه" + }, + "model_resolution": { + "name": "الدقة" + }, + "seed": { + "name": "البذرة", + "tooltip": "البذرة المستخدمة في التوليد." + }, + "watermark": { + "name": "علامة مائية", + "tooltip": "هل ترغب في إضافة علامة مائية مولدة بالذكاء الاصطناعي إلى النتيجة؟" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "HappyHorseReferenceVideoApi": { + "description": "إنشاء فيديو يعرض شخصًا أو كائنًا من مواد مرجعية باستخدام نموذج HappyHorse. يدعم أداء شخصية واحدة وتفاعل عدة شخصيات.", + "display_name": "HappyHorse تحويل مرجع إلى فيديو", + "inputs": { + "control_after_generate": { + "name": "التحكم بعد التوليد" + }, + "model": { + "name": "النموذج" + }, + "model_duration": { + "name": "المدة" + }, + "model_prompt": { + "name": "الموجه" + }, + "model_ratio": { + "name": "النسبة" + }, + "model_resolution": { + "name": "الدقة" + }, + "seed": { + "name": "البذرة", + "tooltip": "البذرة المستخدمة في التوليد." + }, + "watermark": { + "name": "علامة مائية", + "tooltip": "هل ترغب في إضافة علامة مائية مولدة بالذكاء الاصطناعي إلى النتيجة؟" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "HappyHorseTextToVideoApi": { + "description": "إنشاء فيديو بناءً على موجه نصي باستخدام نموذج HappyHorse.", + "display_name": "HappyHorse تحويل نص إلى فيديو", + "inputs": { + "control_after_generate": { + "name": "التحكم بعد التوليد" + }, + "model": { + "name": "النموذج" + }, + "model_duration": { + "name": "المدة" + }, + "model_prompt": { + "name": "الموجه" + }, + "model_ratio": { + "name": "النسبة" + }, + "model_resolution": { + "name": "الدقة" + }, + "seed": { + "name": "البذرة", + "tooltip": "البذرة المستخدمة في التوليد." + }, + "watermark": { + "name": "علامة مائية", + "tooltip": "هل ترغب في إضافة علامة مائية مولدة بالذكاء الاصطناعي إلى النتيجة؟" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "HappyHorseVideoEditApi": { + "description": "تحرير فيديو باستخدام تعليمات نصية أو صور مرجعية مع نموذج HappyHorse. مدة الإخراج من ٣ إلى ١٥ ثانية وتطابق الفيديو المدخل؛ يتم اقتطاع المدخلات التي تتجاوز ١٥ ثانية.", + "display_name": "HappyHorse تحرير فيديو", + "inputs": { + "control_after_generate": { + "name": "التحكم بعد التوليد" + }, + "model": { + "name": "النموذج" + }, + "model_prompt": { + "name": "الموجه" + }, + "model_ratio": { + "name": "النسبة" + }, + "model_resolution": { + "name": "الدقة" + }, + "seed": { + "name": "البذرة", + "tooltip": "البذرة المستخدمة في التوليد." + }, + "video": { + "name": "الفيديو", + "tooltip": "الفيديو المراد تحريره." + }, + "watermark": { + "name": "علامة مائية", + "tooltip": "هل ترغب في إضافة علامة مائية مولدة بالذكاء الاصطناعي إلى النتيجة؟" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "HitPawGeneralImageEnhance": { "description": "تكبير الصور منخفضة الدقة إلى دقة فائقة، إزالة الشوائب والضوضاء. الحد الأقصى للإخراج: ٣٢ ميغابيكسل.", "display_name": "تحسين الصورة العام من HitPaw", @@ -11685,6 +11835,14 @@ "control_after_generate": { "name": "التحكم بعد الإنشاء" }, + "custom_height": { + "name": "custom_height", + "tooltip": "يُستخدم فقط عندما تكون القيمة 'مخصص'. يجب أن يكون من مضاعفات ١٦ (GPT Image 2 فقط)." + }, + "custom_width": { + "name": "custom_width", + "tooltip": "يُستخدم فقط عندما تكون القيمة 'مخصص'. يجب أن يكون من مضاعفات ١٦ (GPT Image 2 فقط)." + }, "image": { "name": "الصورة", "tooltip": "صورة مرجعية اختيارية لتحرير الصور." diff --git a/src/locales/ar/settings.json b/src/locales/ar/settings.json index 495cb3e12e..f619ff63f1 100644 --- a/src/locales/ar/settings.json +++ b/src/locales/ar/settings.json @@ -272,6 +272,10 @@ "v1 (legacy)": "الإصدار 1 (قديم)" } }, + "Comfy_NodeSearchBoxImpl_FollowCursor": { + "name": "العُقَد المضافة تتبع المؤشر", + "tooltip": "عند التفعيل، تتبع العُقَد المضافة من مربع البحث المؤشر حتى يتم النقر لوضعها. ينطبق فقط على التنفيذ الافتراضي." + }, "Comfy_NodeSearchBoxImpl_NodePreview": { "name": "معاينة العقدة", "tooltip": "ينطبق فقط على التنفيذ الافتراضي" diff --git a/src/locales/en/main.json b/src/locales/en/main.json index c82dbda7e3..882b61245c 100644 --- a/src/locales/en/main.json +++ b/src/locales/en/main.json @@ -230,6 +230,7 @@ "warning": "Warning", "name": "Name", "category": "Category", + "categories": "Categories", "sort": "Sort", "source": "Source", "filter": "Filter", @@ -1706,6 +1707,7 @@ "gligen": "gligen", "shader": "shader", "Grok": "Grok", + "Wan": "Wan", "HitPaw": "HitPaw", "sd": "sd", "Ideogram": "Ideogram", @@ -1755,7 +1757,6 @@ "Tripo": "Tripo", "Veo": "Veo", "Vidu": "Vidu", - "Wan": "Wan", "camera": "camera", "WaveSpeed": "WaveSpeed", "zimage": "zimage" @@ -2794,51 +2795,55 @@ "survey": { "title": "Cloud Survey", "placeholder": "Survey questions placeholder", - "steps": { - "familiarity": "How familiar are you with ComfyUI?", - "purpose": "What will you primarily use ComfyUI for?", - "industry": "What's your primary industry?", - "making": "What do you plan on making?" + "intro": "Help us tailor your ComfyUI experience.", + "errors": { + "chooseAnOption": "Please choose an option.", + "selectAtLeastOne": "Please select at least one option.", + "describeAnswer": "Please describe your answer." }, - "questions": { + "steps": { + "usage": "How do you plan to use ComfyUI?", "familiarity": "How familiar are you with ComfyUI?", - "purpose": "What will you primarily use ComfyUI for?", - "industry": "What's your primary industry?", - "making": "What do you plan on making?" + "intent": "What do you want to create with ComfyUI?", + "source": "Where did you hear about ComfyUI?" }, "options": { + "usage": { + "personal": "Personal use", + "work": "Work", + "education": "Education (student or educator)" + }, "familiarity": { - "new": "New to ComfyUI (never used it before)", - "starting": "Just getting started (following tutorials)", - "basics": "Comfortable with basics", - "advanced": "Advanced user (custom workflows)", - "expert": "Expert (help others)" + "new": "New — never used it", + "starting": "Beginner — following tutorials", + "basics": "Intermediate — comfortable with basics", + "advanced": "Advanced — build and edit workflows", + "expert": "Expert — I help others" }, - "purpose": { - "personal": "Personal projects / hobby", - "community": "Community contributions (nodes, workflows, etc.)", - "client": "Client work (freelance)", - "inhouse": "My own workplace (in-house)", - "research": "Academic research" - }, - "industry": { - "film_tv_animation": "Film, TV, & animation", - "gaming": "Gaming", - "marketing": "Marketing & advertising", - "architecture": "Architecture", - "product_design": "Product & graphic design", - "fine_art": "Fine art & illustration", - "software": "Software & technology", - "education": "Education", - "other": "Other", - "otherPlaceholder": "Please specify" - }, - "making": { + "intent": { + "workflows": "Custom workflows or pipelines", + "custom_nodes": "Custom nodes", + "videos": "Videos", "images": "Images", - "video": "Video & animation", - "3d": "3D assets", + "3d_game": "3D assets / game assets", "audio": "Audio / music", - "custom_nodes": "Custom nodes & workflows" + "apps": "Simplified Apps from workflows", + "api": "API endpoints to run workflows", + "not_sure": "Not sure" + }, + "source": { + "youtube": "YouTube", + "reddit": "Reddit", + "twitter": "Twitter / X", + "instagram": "Instagram", + "linkedin": "LinkedIn", + "friend": "Friend or colleague", + "search": "Google / search", + "newsletter": "Newsletter or blog", + "conference": "Conference or event", + "discord": "Discord / community", + "github": "GitHub", + "other": "Other" } } }, @@ -2908,10 +2913,10 @@ "cloudForgotPassword_emailRequired": "Email is required", "cloudForgotPassword_passwordResetSent": "Password reset sent", "cloudForgotPassword_passwordResetError": "Failed to send password reset email", + "cloudSurvey_steps_usage": "How do you plan to use ComfyUI?", "cloudSurvey_steps_familiarity": "How familiar are you with ComfyUI?", - "cloudSurvey_steps_purpose": "What will you primarily use ComfyUI for?", - "cloudSurvey_steps_industry": "What's your primary industry?", - "cloudSurvey_steps_making": "What do you plan on making?", + "cloudSurvey_steps_intent": "What do you want to create with ComfyUI?", + "cloudSurvey_steps_source": "Where did you hear about ComfyUI?", "assetBrowser": { "allCategory": "All {category}", "allModels": "All Models", diff --git a/src/locales/en/nodeDefs.json b/src/locales/en/nodeDefs.json index f0bc5d72ad..9efaf1cd17 100644 --- a/src/locales/en/nodeDefs.json +++ b/src/locales/en/nodeDefs.json @@ -4534,6 +4534,156 @@ } } }, + "HappyHorseImageToVideoApi": { + "display_name": "HappyHorse Image to Video", + "description": "Generate a video from a first-frame image using the HappyHorse model.", + "inputs": { + "model": { + "name": "model" + }, + "first_frame": { + "name": "first_frame", + "tooltip": "First frame image. The output aspect ratio is derived from this image." + }, + "seed": { + "name": "seed", + "tooltip": "Seed to use for generation." + }, + "watermark": { + "name": "watermark", + "tooltip": "Whether to add an AI-generated watermark to the result." + }, + "control_after_generate": { + "name": "control after generate" + }, + "model_duration": { + "name": "duration" + }, + "model_prompt": { + "name": "prompt" + }, + "model_resolution": { + "name": "resolution" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "HappyHorseReferenceVideoApi": { + "display_name": "HappyHorse Reference to Video", + "description": "Generate a video featuring a person or object from reference materials with the HappyHorse model. Supports single-character performances and multi-character interactions.", + "inputs": { + "model": { + "name": "model" + }, + "seed": { + "name": "seed", + "tooltip": "Seed to use for generation." + }, + "watermark": { + "name": "watermark", + "tooltip": "Whether to add an AI-generated watermark to the result." + }, + "control_after_generate": { + "name": "control after generate" + }, + "model_duration": { + "name": "duration" + }, + "model_prompt": { + "name": "prompt" + }, + "model_ratio": { + "name": "ratio" + }, + "model_resolution": { + "name": "resolution" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "HappyHorseTextToVideoApi": { + "display_name": "HappyHorse Text to Video", + "description": "Generates a video based on a text prompt using the HappyHorse model.", + "inputs": { + "model": { + "name": "model" + }, + "seed": { + "name": "seed", + "tooltip": "Seed to use for generation." + }, + "watermark": { + "name": "watermark", + "tooltip": "Whether to add an AI-generated watermark to the result." + }, + "control_after_generate": { + "name": "control after generate" + }, + "model_duration": { + "name": "duration" + }, + "model_prompt": { + "name": "prompt" + }, + "model_ratio": { + "name": "ratio" + }, + "model_resolution": { + "name": "resolution" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "HappyHorseVideoEditApi": { + "display_name": "HappyHorse Video Edit", + "description": "Edit a video using text instructions or reference images with the HappyHorse model. Output duration is 3-15s and matches the input video; inputs longer than 15s are truncated.", + "inputs": { + "model": { + "name": "model" + }, + "video": { + "name": "video", + "tooltip": "The video to edit." + }, + "seed": { + "name": "seed", + "tooltip": "Seed to use for generation." + }, + "watermark": { + "name": "watermark", + "tooltip": "Whether to add an AI-generated watermark to the result." + }, + "control_after_generate": { + "name": "control after generate" + }, + "model_prompt": { + "name": "prompt" + }, + "model_ratio": { + "name": "ratio" + }, + "model_resolution": { + "name": "resolution" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "HitPawGeneralImageEnhance": { "display_name": "HitPaw General Image Enhance", "description": "Upscale low-resolution images to super-resolution, eliminate artifacts and noise. Maximum output: 32 megapixels.", @@ -11696,7 +11846,7 @@ }, "size": { "name": "size", - "tooltip": "Image size" + "tooltip": "Image size. Select 'Custom' to use the custom width and height (GPT Image 2 only)." }, "n": { "name": "n", @@ -11713,6 +11863,14 @@ "model": { "name": "model" }, + "custom_width": { + "name": "custom_width", + "tooltip": "Used only when `size` is 'Custom'. Must be a multiple of 16 (GPT Image 2 only)." + }, + "custom_height": { + "name": "custom_height", + "tooltip": "Used only when `size` is 'Custom'. Must be a multiple of 16 (GPT Image 2 only)." + }, "control_after_generate": { "name": "control after generate" } diff --git a/src/locales/en/settings.json b/src/locales/en/settings.json index a2c0ee2cc5..398f7b6888 100644 --- a/src/locales/en/settings.json +++ b/src/locales/en/settings.json @@ -308,12 +308,17 @@ "litegraph (legacy)": "litegraph (legacy)" } }, + "Comfy_NodeSearchBoxImpl_FollowCursor": { + "name": "Added nodes follow the cursor", + "tooltip": "When enabled, nodes added from the search box follow the cursor until clicked to place. Only applies to the default implementation." + }, "Comfy_NodeSearchBoxImpl_NodePreview": { "name": "Node preview", "tooltip": "Only applies to the default implementation" }, "Comfy_NodeSearchBoxImpl_ShowCategory": { - "name": "Show node category in search results" + "name": "Show node category in search results", + "tooltip": "Only applies to v1 (legacy)" }, "Comfy_NodeSearchBoxImpl_ShowIdName": { "name": "Show node id name in search results", diff --git a/src/locales/es/main.json b/src/locales/es/main.json index 66226d7faa..2a09f0b637 100644 --- a/src/locales/es/main.json +++ b/src/locales/es/main.json @@ -1008,13 +1008,16 @@ "back": "Atrás", "batchRename": "Renombrar en lote", "beta": "BETA", + "blueprints": "Planos", "bookmark": "Guardar en Biblioteca", + "bookmarked": "Marcado como favorito", "browserReservedKeybinding": "Este atajo está reservado por algunos navegadores y puede tener resultados inesperados.", "browserReservedKeybindingTooltip": "Este atajo entra en conflicto con los atajos reservados del navegador", "calculatingDimensions": "Calculando dimensiones", "cancel": "Cancelar", "cancelled": "Cancelado", "capture": "captura", + "categories": "Categorías", "category": "Categoría", "changeKeybinding": "Cambiar combinación de teclas", "chart": "Gráfico", @@ -1235,6 +1238,7 @@ "output": "Salida", "overwrite": "Sobrescribir", "partner": "Socio", + "partnerNodes": "Nodos asociados", "pause": "Pausar", "play": "Reproducir", "playPause": "Reproducir/Pausar", @@ -2607,6 +2611,9 @@ "metadataFetchFailed": "No se pudo obtener los metadatos. Por favor, revisa el enlace e inténtalo de nuevo.", "missingModelsTitle": "Modelos faltantes", "or": "O", + "refresh": "Actualizar", + "refreshFailed": "No se pudo actualizar los modelos faltantes. Por favor, inténtalo de nuevo.", + "refreshing": "Actualizando modelos faltantes.", "typeMismatch": "Este modelo parece ser un \"{detectedType}\". ¿Estás seguro?", "unknownCategory": "Desconocido", "unsupportedUrl": "Solo se admiten URLs de Civitai y Hugging Face.", @@ -3500,6 +3507,7 @@ "no3dScene": "No hay escena 3D para aplicar textura", "no3dSceneToExport": "No hay escena 3D para exportar", "noTemplatesToExport": "No hay plantillas para exportar", + "nodeDefinitionsUpdateFailed": "No se pudo actualizar las definiciones de nodos", "nodeDefinitionsUpdated": "Definiciones de nodos actualizadas", "nothingSelected": "Nada seleccionado", "nothingToGroup": "Nada para agrupar", diff --git a/src/locales/es/nodeDefs.json b/src/locales/es/nodeDefs.json index 0248af4fe6..5049ead7be 100644 --- a/src/locales/es/nodeDefs.json +++ b/src/locales/es/nodeDefs.json @@ -4534,6 +4534,156 @@ } } }, + "HappyHorseImageToVideoApi": { + "description": "Genera un video a partir de una imagen del primer fotograma usando el modelo HappyHorse.", + "display_name": "HappyHorse Imagen a Video", + "inputs": { + "control_after_generate": { + "name": "controlar después de generar" + }, + "first_frame": { + "name": "primer fotograma", + "tooltip": "Imagen del primer fotograma. La relación de aspecto de salida se deriva de esta imagen." + }, + "model": { + "name": "modelo" + }, + "model_duration": { + "name": "duración" + }, + "model_prompt": { + "name": "prompt" + }, + "model_resolution": { + "name": "resolución" + }, + "seed": { + "name": "semilla", + "tooltip": "Semilla para usar en la generación." + }, + "watermark": { + "name": "marca de agua", + "tooltip": "Indica si se añade una marca de agua generada por IA al resultado." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "HappyHorseReferenceVideoApi": { + "description": "Genera un video con una persona u objeto a partir de materiales de referencia usando el modelo HappyHorse. Soporta actuaciones de un solo personaje e interacciones entre varios personajes.", + "display_name": "HappyHorse Referencia a Video", + "inputs": { + "control_after_generate": { + "name": "controlar después de generar" + }, + "model": { + "name": "modelo" + }, + "model_duration": { + "name": "duración" + }, + "model_prompt": { + "name": "prompt" + }, + "model_ratio": { + "name": "relación" + }, + "model_resolution": { + "name": "resolución" + }, + "seed": { + "name": "semilla", + "tooltip": "Semilla para usar en la generación." + }, + "watermark": { + "name": "marca de agua", + "tooltip": "Indica si se añade una marca de agua generada por IA al resultado." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "HappyHorseTextToVideoApi": { + "description": "Genera un video basado en un prompt de texto usando el modelo HappyHorse.", + "display_name": "HappyHorse Texto a Video", + "inputs": { + "control_after_generate": { + "name": "controlar después de generar" + }, + "model": { + "name": "modelo" + }, + "model_duration": { + "name": "duración" + }, + "model_prompt": { + "name": "prompt" + }, + "model_ratio": { + "name": "relación" + }, + "model_resolution": { + "name": "resolución" + }, + "seed": { + "name": "semilla", + "tooltip": "Semilla para usar en la generación." + }, + "watermark": { + "name": "marca de agua", + "tooltip": "Indica si se añade una marca de agua generada por IA al resultado." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "HappyHorseVideoEditApi": { + "description": "Edita un video usando instrucciones de texto o imágenes de referencia con el modelo HappyHorse. La duración de salida es de 3 a 15 segundos y coincide con el video de entrada; las entradas de más de 15 segundos se recortan.", + "display_name": "HappyHorse Edición de Video", + "inputs": { + "control_after_generate": { + "name": "controlar después de generar" + }, + "model": { + "name": "modelo" + }, + "model_prompt": { + "name": "prompt" + }, + "model_ratio": { + "name": "relación" + }, + "model_resolution": { + "name": "resolución" + }, + "seed": { + "name": "semilla", + "tooltip": "Semilla para usar en la generación." + }, + "video": { + "name": "video", + "tooltip": "El video a editar." + }, + "watermark": { + "name": "marca de agua", + "tooltip": "Indica si se añade una marca de agua generada por IA al resultado." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "HitPawGeneralImageEnhance": { "description": "Aumenta la resolución de imágenes de baja calidad a superresolución, elimina artefactos y ruido. Salida máxima: 32 megapíxeles.", "display_name": "HitPaw General Image Enhance", @@ -11685,6 +11835,14 @@ "control_after_generate": { "name": "control after generate" }, + "custom_height": { + "name": "custom_height", + "tooltip": "Se utiliza solo cuando el valor de `size` es 'Custom'. Debe ser un múltiplo de 16 (solo para GPT Image 2)." + }, + "custom_width": { + "name": "custom_width", + "tooltip": "Se utiliza solo cuando el valor de `size` es 'Custom'. Debe ser un múltiplo de 16 (solo para GPT Image 2)." + }, "image": { "name": "image", "tooltip": "Imagen de referencia opcional para edición de imagen." diff --git a/src/locales/es/settings.json b/src/locales/es/settings.json index ea53b9073a..4ebb2597d6 100644 --- a/src/locales/es/settings.json +++ b/src/locales/es/settings.json @@ -272,6 +272,10 @@ "v1 (legacy)": "v1 (heredado)" } }, + "Comfy_NodeSearchBoxImpl_FollowCursor": { + "name": "Los nodos añadidos siguen el cursor", + "tooltip": "Cuando está activado, los nodos añadidos desde la caja de búsqueda siguen el cursor hasta que se hace clic para colocarlos. Solo se aplica a la implementación predeterminada." + }, "Comfy_NodeSearchBoxImpl_NodePreview": { "name": "Vista previa del nodo", "tooltip": "Solo se aplica a la implementación predeterminada" diff --git a/src/locales/fa/main.json b/src/locales/fa/main.json index a458eacfe0..38d85f5162 100644 --- a/src/locales/fa/main.json +++ b/src/locales/fa/main.json @@ -1008,13 +1008,16 @@ "back": "بازگشت", "batchRename": "تغییر نام گروهی", "beta": "آزمایشی", + "blueprints": "نقشه‌ها", "bookmark": "ذخیره در کتابخانه", + "bookmarked": "نشان‌شده", "browserReservedKeybinding": "این میانبر توسط برخی مرورگرها رزرو شده و ممکن است نتایج غیرمنتظره‌ای داشته باشد.", "browserReservedKeybindingTooltip": "این میانبر با میانبرهای رزرو شده مرورگر تداخل دارد", "calculatingDimensions": "در حال محاسبه ابعاد", "cancel": "لغو", "cancelled": "لغو شده", "capture": "گرفتن", + "categories": "دسته‌بندی‌ها", "category": "دسته‌بندی", "changeKeybinding": "تغییر کلید میانبر", "chart": "نمودار", @@ -1235,6 +1238,7 @@ "output": "خروجی", "overwrite": "جایگزینی", "partner": "همکار", + "partnerNodes": "گره‌های شریک", "pause": "توقف", "play": "پخش", "playPause": "پخش/توقف", @@ -2607,6 +2611,9 @@ "metadataFetchFailed": "دریافت اطلاعات مدل انجام نشد. لطفاً لینک را بررسی و دوباره تلاش کنید.", "missingModelsTitle": "مدل‌های مفقود شده", "or": "یا", + "refresh": "بازآوری", + "refreshFailed": "بازآوری مدل‌های مفقود انجام نشد. لطفاً دوباره تلاش کنید.", + "refreshing": "در حال بازآوری مدل‌های مفقود.", "typeMismatch": "به نظر می‌رسد این مدل از نوع \"{detectedType}\" باشد. مطمئن هستید؟", "unknownCategory": "نامشخص", "unsupportedUrl": "فقط آدرس‌های Civitai و Hugging Face پشتیبانی می‌شوند.", @@ -3512,6 +3519,7 @@ "no3dScene": "صحنه سه‌بعدی برای اعمال بافت وجود ندارد", "no3dSceneToExport": "صحنه سه‌بعدی برای صادرات وجود ندارد", "noTemplatesToExport": "قالبی برای صادرات وجود ندارد", + "nodeDefinitionsUpdateFailed": "به‌روزرسانی تعاریف node انجام نشد", "nodeDefinitionsUpdated": "تعاریف node به‌روزرسانی شد", "nothingSelected": "موردی انتخاب نشده است", "nothingToGroup": "موردی برای گروه‌بندی وجود ندارد", diff --git a/src/locales/fa/nodeDefs.json b/src/locales/fa/nodeDefs.json index 97ee0a1ccb..ca0356a046 100644 --- a/src/locales/fa/nodeDefs.json +++ b/src/locales/fa/nodeDefs.json @@ -4534,6 +4534,156 @@ } } }, + "HappyHorseImageToVideoApi": { + "description": "تولید ویدیو از یک تصویر فریم اول با استفاده از مدل HappyHorse.", + "display_name": "HappyHorse تبدیل تصویر به ویدیو", + "inputs": { + "control_after_generate": { + "name": "کنترل پس از تولید" + }, + "first_frame": { + "name": "فریم اول", + "tooltip": "تصویر فریم اول. نسبت ابعاد خروجی از این تصویر استخراج می‌شود." + }, + "model": { + "name": "مدل" + }, + "model_duration": { + "name": "مدت زمان" + }, + "model_prompt": { + "name": "پرامپت" + }, + "model_resolution": { + "name": "رزولوشن" + }, + "seed": { + "name": "seed", + "tooltip": "seed مورد استفاده برای تولید." + }, + "watermark": { + "name": "واترمارک", + "tooltip": "آیا واترمارک تولیدشده توسط هوش مصنوعی به نتیجه اضافه شود یا خیر." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "HappyHorseReferenceVideoApi": { + "description": "تولید ویدیو با حضور یک شخص یا شیء از مواد مرجع با مدل HappyHorse. پشتیبانی از اجراهای تک‌نفره و تعامل چندنفره.", + "display_name": "HappyHorse تبدیل مرجع به ویدیو", + "inputs": { + "control_after_generate": { + "name": "کنترل پس از تولید" + }, + "model": { + "name": "مدل" + }, + "model_duration": { + "name": "مدت زمان" + }, + "model_prompt": { + "name": "پرامپت" + }, + "model_ratio": { + "name": "نسبت تصویر" + }, + "model_resolution": { + "name": "رزولوشن" + }, + "seed": { + "name": "seed", + "tooltip": "seed مورد استفاده برای تولید." + }, + "watermark": { + "name": "واترمارک", + "tooltip": "آیا واترمارک تولیدشده توسط هوش مصنوعی به نتیجه اضافه شود یا خیر." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "HappyHorseTextToVideoApi": { + "description": "تولید ویدیو بر اساس پرامپت متنی با استفاده از مدل HappyHorse.", + "display_name": "HappyHorse تبدیل متن به ویدیو", + "inputs": { + "control_after_generate": { + "name": "کنترل پس از تولید" + }, + "model": { + "name": "مدل" + }, + "model_duration": { + "name": "مدت زمان" + }, + "model_prompt": { + "name": "پرامپت" + }, + "model_ratio": { + "name": "نسبت تصویر" + }, + "model_resolution": { + "name": "رزولوشن" + }, + "seed": { + "name": "seed", + "tooltip": "seed مورد استفاده برای تولید." + }, + "watermark": { + "name": "واترمارک", + "tooltip": "آیا واترمارک تولیدشده توسط هوش مصنوعی به نتیجه اضافه شود یا خیر." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "HappyHorseVideoEditApi": { + "description": "ویرایش ویدیو با دستور متنی یا تصاویر مرجع با مدل HappyHorse. مدت زمان خروجی بین ۳ تا ۱۵ ثانیه و مطابق با ویدیوی ورودی است؛ ورودی‌های بیش از ۱۵ ثانیه کوتاه می‌شوند.", + "display_name": "HappyHorse ویرایش ویدیو", + "inputs": { + "control_after_generate": { + "name": "کنترل پس از تولید" + }, + "model": { + "name": "مدل" + }, + "model_prompt": { + "name": "پرامپت" + }, + "model_ratio": { + "name": "نسبت تصویر" + }, + "model_resolution": { + "name": "رزولوشن" + }, + "seed": { + "name": "seed", + "tooltip": "seed مورد استفاده برای تولید." + }, + "video": { + "name": "ویدیو", + "tooltip": "ویدیویی که باید ویرایش شود." + }, + "watermark": { + "name": "واترمارک", + "tooltip": "آیا واترمارک تولیدشده توسط هوش مصنوعی به نتیجه اضافه شود یا خیر." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "HitPawGeneralImageEnhance": { "description": "ارتقاء تصاویر با وضوح پایین به وضوح بسیار بالا، حذف نویز و آرتیفکت‌ها. حداکثر خروجی: ۳۲ مگاپیکسل.", "display_name": "افزایش کیفیت تصویر عمومی HitPaw", @@ -11685,6 +11835,14 @@ "control_after_generate": { "name": "control after generate" }, + "custom_height": { + "name": "custom_height", + "tooltip": "فقط زمانی استفاده می‌شود که «اندازه» روی «سفارشی» باشد. باید مضربی از ۱۶ باشد (فقط برای GPT Image 2)." + }, + "custom_width": { + "name": "custom_width", + "tooltip": "فقط زمانی استفاده می‌شود که «اندازه» روی «سفارشی» باشد. باید مضربی از ۱۶ باشد (فقط برای GPT Image 2)." + }, "image": { "name": "image", "tooltip": "تصویر مرجع اختیاری برای ویرایش تصویر." diff --git a/src/locales/fa/settings.json b/src/locales/fa/settings.json index 56f02b6d17..5615e3ac3b 100644 --- a/src/locales/fa/settings.json +++ b/src/locales/fa/settings.json @@ -272,6 +272,10 @@ "v1 (legacy)": "v۱ (قدیمی)" } }, + "Comfy_NodeSearchBoxImpl_FollowCursor": { + "name": "افزودن نودها دنبال‌رو نشانگر ماوس", + "tooltip": "در صورت فعال بودن، نودهایی که از جعبه جستجو اضافه می‌شوند تا زمان کلیک برای قرار دادن، نشانگر ماوس را دنبال می‌کنند. این گزینه فقط برای پیاده‌سازی پیش‌فرض اعمال می‌شود." + }, "Comfy_NodeSearchBoxImpl_NodePreview": { "name": "پیش‌نمایش نود", "tooltip": "فقط برای پیاده‌سازی پیش‌فرض اعمال می‌شود" diff --git a/src/locales/fr/main.json b/src/locales/fr/main.json index afac18aae7..8eca375116 100644 --- a/src/locales/fr/main.json +++ b/src/locales/fr/main.json @@ -1008,13 +1008,16 @@ "back": "Retour", "batchRename": "Renommer en lot", "beta": "BÊTA", + "blueprints": "Plans", "bookmark": "Enregistrer dans la bibliothèque", + "bookmarked": "Mis en favori", "browserReservedKeybinding": "Ce raccourci est réservé par certains navigateurs et peut entraîner des résultats inattendus.", "browserReservedKeybindingTooltip": "Ce raccourci est en conflit avec des raccourcis réservés du navigateur", "calculatingDimensions": "Calcul des dimensions", "cancel": "Annuler", "cancelled": "Annulé", "capture": "capture", + "categories": "Catégories", "category": "Catégorie", "changeKeybinding": "Modifier le raccourci clavier", "chart": "Graphique", @@ -1235,6 +1238,7 @@ "output": "Sortie", "overwrite": "Écraser", "partner": "Partenaire", + "partnerNodes": "Nœuds partenaires", "pause": "Pause", "play": "Lecture", "playPause": "Lecture/Pause", @@ -2607,6 +2611,9 @@ "metadataFetchFailed": "Échec de la récupération des métadonnées. Veuillez vérifier le lien et réessayer.", "missingModelsTitle": "Modèles manquants", "or": "OU", + "refresh": "Rafraîchir", + "refreshFailed": "Échec du rafraîchissement des modèles manquants. Veuillez réessayer.", + "refreshing": "Rafraîchissement des modèles manquants.", "typeMismatch": "Ce modèle semble être un(e) « {detectedType} ». Êtes-vous sûr ?", "unknownCategory": "Inconnu", "unsupportedUrl": "Seules les URL Civitai et Hugging Face sont prises en charge.", @@ -3500,6 +3507,7 @@ "no3dScene": "Aucune scène 3D pour appliquer la texture", "no3dSceneToExport": "Aucune scène 3D à exporter", "noTemplatesToExport": "Aucun modèle à exporter", + "nodeDefinitionsUpdateFailed": "Échec de la mise à jour des définitions de nœud", "nodeDefinitionsUpdated": "Définitions de nœuds mises à jour", "nothingSelected": "Aucune sélection", "nothingToGroup": "Rien à regrouper", diff --git a/src/locales/fr/nodeDefs.json b/src/locales/fr/nodeDefs.json index 78c8220bf8..52c097c8bd 100644 --- a/src/locales/fr/nodeDefs.json +++ b/src/locales/fr/nodeDefs.json @@ -4534,6 +4534,156 @@ } } }, + "HappyHorseImageToVideoApi": { + "description": "Générez une vidéo à partir d'une image de première image en utilisant le modèle HappyHorse.", + "display_name": "HappyHorse Image vers Vidéo", + "inputs": { + "control_after_generate": { + "name": "contrôle après génération" + }, + "first_frame": { + "name": "première image", + "tooltip": "Image de la première image. Le format de sortie est dérivé de cette image." + }, + "model": { + "name": "modèle" + }, + "model_duration": { + "name": "durée" + }, + "model_prompt": { + "name": "invite" + }, + "model_resolution": { + "name": "résolution" + }, + "seed": { + "name": "graine", + "tooltip": "Graine à utiliser pour la génération." + }, + "watermark": { + "name": "filigrane", + "tooltip": "Ajouter ou non un filigrane généré par IA au résultat." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "HappyHorseReferenceVideoApi": { + "description": "Générez une vidéo mettant en scène une personne ou un objet à partir de matériaux de référence avec le modèle HappyHorse. Prend en charge les performances à un seul personnage et les interactions multi-personnages.", + "display_name": "HappyHorse Référence vers Vidéo", + "inputs": { + "control_after_generate": { + "name": "contrôle après génération" + }, + "model": { + "name": "modèle" + }, + "model_duration": { + "name": "durée" + }, + "model_prompt": { + "name": "invite" + }, + "model_ratio": { + "name": "ratio" + }, + "model_resolution": { + "name": "résolution" + }, + "seed": { + "name": "graine", + "tooltip": "Graine à utiliser pour la génération." + }, + "watermark": { + "name": "filigrane", + "tooltip": "Ajouter ou non un filigrane généré par IA au résultat." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "HappyHorseTextToVideoApi": { + "description": "Génère une vidéo à partir d'une invite textuelle en utilisant le modèle HappyHorse.", + "display_name": "HappyHorse Texte vers Vidéo", + "inputs": { + "control_after_generate": { + "name": "contrôle après génération" + }, + "model": { + "name": "modèle" + }, + "model_duration": { + "name": "durée" + }, + "model_prompt": { + "name": "invite" + }, + "model_ratio": { + "name": "ratio" + }, + "model_resolution": { + "name": "résolution" + }, + "seed": { + "name": "graine", + "tooltip": "Graine à utiliser pour la génération." + }, + "watermark": { + "name": "filigrane", + "tooltip": "Ajouter ou non un filigrane généré par IA au résultat." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "HappyHorseVideoEditApi": { + "description": "Modifiez une vidéo à l'aide d'instructions textuelles ou d'images de référence avec le modèle HappyHorse. La durée de sortie est de 3 à 15s et correspond à la vidéo d'entrée ; les entrées de plus de 15s sont tronquées.", + "display_name": "HappyHorse Édition Vidéo", + "inputs": { + "control_after_generate": { + "name": "contrôle après génération" + }, + "model": { + "name": "modèle" + }, + "model_prompt": { + "name": "invite" + }, + "model_ratio": { + "name": "ratio" + }, + "model_resolution": { + "name": "résolution" + }, + "seed": { + "name": "graine", + "tooltip": "Graine à utiliser pour la génération." + }, + "video": { + "name": "vidéo", + "tooltip": "La vidéo à éditer." + }, + "watermark": { + "name": "filigrane", + "tooltip": "Ajouter ou non un filigrane généré par IA au résultat." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "HitPawGeneralImageEnhance": { "description": "Agrandissez les images basse résolution en super-résolution, éliminez les artefacts et le bruit. Sortie maximale : 32 mégapixels.", "display_name": "HitPaw Amélioration Générale d’Image", @@ -11685,6 +11835,14 @@ "control_after_generate": { "name": "contrôle après génération" }, + "custom_height": { + "name": "hauteur_personnalisée", + "tooltip": "Utilisé uniquement lorsque `size` est défini sur « Personnalisé ». Doit être un multiple de 16 (GPT Image 2 uniquement)." + }, + "custom_width": { + "name": "largeur_personnalisée", + "tooltip": "Utilisé uniquement lorsque `size` est défini sur « Personnalisé ». Doit être un multiple de 16 (GPT Image 2 uniquement)." + }, "image": { "name": "image", "tooltip": "Image de référence optionnelle pour l'édition d'image." diff --git a/src/locales/fr/settings.json b/src/locales/fr/settings.json index d4b511fbfa..1ac075514c 100644 --- a/src/locales/fr/settings.json +++ b/src/locales/fr/settings.json @@ -272,6 +272,10 @@ "v1 (legacy)": "v1 (héritage)" } }, + "Comfy_NodeSearchBoxImpl_FollowCursor": { + "name": "Les nœuds ajoutés suivent le curseur", + "tooltip": "Lorsque cette option est activée, les nœuds ajoutés depuis la boîte de recherche suivent le curseur jusqu'à ce qu'ils soient placés par un clic. S'applique uniquement à l'implémentation par défaut." + }, "Comfy_NodeSearchBoxImpl_NodePreview": { "name": "Aperçu du nœud", "tooltip": "S'applique uniquement à l'implémentation par défaut" diff --git a/src/locales/ja/main.json b/src/locales/ja/main.json index 823aa5cfe4..1488f73a35 100644 --- a/src/locales/ja/main.json +++ b/src/locales/ja/main.json @@ -1008,13 +1008,16 @@ "back": "戻る", "batchRename": "一括リネーム", "beta": "ベータ版", + "blueprints": "ブループリント", "bookmark": "ライブラリに保存", + "bookmarked": "ブックマーク済み", "browserReservedKeybinding": "このショートカットは一部のブラウザで予約されており、予期しない動作を引き起こす可能性があります。", "browserReservedKeybindingTooltip": "このショートカットはブラウザの予約済みショートカットと競合しています", "calculatingDimensions": "寸法を計算中", "cancel": "キャンセル", "cancelled": "キャンセル済み", "capture": "キャプチャ", + "categories": "カテゴリ", "category": "カテゴリ", "changeKeybinding": "キーバインドを変更", "chart": "チャート", @@ -1235,6 +1238,7 @@ "output": "出力", "overwrite": "上書き", "partner": "パートナー", + "partnerNodes": "パートナーノード", "pause": "一時停止", "play": "再生", "playPause": "再生/一時停止", @@ -2607,6 +2611,9 @@ "metadataFetchFailed": "メタデータの取得に失敗しました。リンクを確認して再試行してください。", "missingModelsTitle": "不足しているモデル", "or": "または", + "refresh": "更新", + "refreshFailed": "不足しているモデルの更新に失敗しました。もう一度お試しください。", + "refreshing": "不足しているモデルを更新中です。", "typeMismatch": "このモデルは「{detectedType}」のようです。本当に続行しますか?", "unknownCategory": "不明", "unsupportedUrl": "Civitai と Hugging Face のURLのみサポートされています。", @@ -3500,6 +3507,7 @@ "no3dScene": "テクスチャを適用する3Dシーンがありません", "no3dSceneToExport": "エクスポートする3Dシーンがありません", "noTemplatesToExport": "エクスポートするテンプレートがありません", + "nodeDefinitionsUpdateFailed": "ノード定義の更新に失敗しました", "nodeDefinitionsUpdated": "ノード定義が更新されました", "nothingSelected": "選択されていません", "nothingToGroup": "グループ化するものがありません", diff --git a/src/locales/ja/nodeDefs.json b/src/locales/ja/nodeDefs.json index 9d8fe49b80..6d1035ebc1 100644 --- a/src/locales/ja/nodeDefs.json +++ b/src/locales/ja/nodeDefs.json @@ -4534,6 +4534,156 @@ } } }, + "HappyHorseImageToVideoApi": { + "description": "HappyHorseモデルを使用して、最初のフレーム画像から動画を生成します。", + "display_name": "HappyHorse 画像から動画へ", + "inputs": { + "control_after_generate": { + "name": "生成後のコントロール" + }, + "first_frame": { + "name": "最初のフレーム", + "tooltip": "最初のフレーム画像。この画像から出力のアスペクト比が決まります。" + }, + "model": { + "name": "モデル" + }, + "model_duration": { + "name": "継続時間" + }, + "model_prompt": { + "name": "プロンプト" + }, + "model_resolution": { + "name": "解像度" + }, + "seed": { + "name": "シード", + "tooltip": "生成に使用するシード。" + }, + "watermark": { + "name": "ウォーターマーク", + "tooltip": "AI生成のウォーターマークを結果に追加するかどうか。" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "HappyHorseReferenceVideoApi": { + "description": "HappyHorseモデルで参照素材から人物や物体を含む動画を生成します。単一キャラクターの演技や複数キャラクターのインタラクションに対応しています。", + "display_name": "HappyHorse 参照から動画へ", + "inputs": { + "control_after_generate": { + "name": "生成後のコントロール" + }, + "model": { + "name": "モデル" + }, + "model_duration": { + "name": "継続時間" + }, + "model_prompt": { + "name": "プロンプト" + }, + "model_ratio": { + "name": "比率" + }, + "model_resolution": { + "name": "解像度" + }, + "seed": { + "name": "シード", + "tooltip": "生成に使用するシード。" + }, + "watermark": { + "name": "ウォーターマーク", + "tooltip": "AI生成のウォーターマークを結果に追加するかどうか。" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "HappyHorseTextToVideoApi": { + "description": "HappyHorseモデルを使用してテキストプロンプトから動画を生成します。", + "display_name": "HappyHorse テキストから動画へ", + "inputs": { + "control_after_generate": { + "name": "生成後のコントロール" + }, + "model": { + "name": "モデル" + }, + "model_duration": { + "name": "継続時間" + }, + "model_prompt": { + "name": "プロンプト" + }, + "model_ratio": { + "name": "比率" + }, + "model_resolution": { + "name": "解像度" + }, + "seed": { + "name": "シード", + "tooltip": "生成に使用するシード。" + }, + "watermark": { + "name": "ウォーターマーク", + "tooltip": "AI生成のウォーターマークを結果に追加するかどうか。" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "HappyHorseVideoEditApi": { + "description": "HappyHorseモデルを使い、テキスト指示や参照画像で動画を編集します。出力の継続時間は3~15秒で、入力動画と一致します。15秒を超える入力は切り捨てられます。", + "display_name": "HappyHorse 動画編集", + "inputs": { + "control_after_generate": { + "name": "生成後のコントロール" + }, + "model": { + "name": "モデル" + }, + "model_prompt": { + "name": "プロンプト" + }, + "model_ratio": { + "name": "比率" + }, + "model_resolution": { + "name": "解像度" + }, + "seed": { + "name": "シード", + "tooltip": "生成に使用するシード。" + }, + "video": { + "name": "動画", + "tooltip": "編集する動画。" + }, + "watermark": { + "name": "ウォーターマーク", + "tooltip": "AI生成のウォーターマークを結果に追加するかどうか。" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "HitPawGeneralImageEnhance": { "description": "低解像度画像を超解像度にアップスケールし、アーティファクトやノイズを除去します。最大出力:32メガピクセル。", "display_name": "HitPaw 一般画像強化", @@ -11685,6 +11835,14 @@ "control_after_generate": { "name": "生成後のコントロール" }, + "custom_height": { + "name": "custom_height", + "tooltip": "「サイズ」が「カスタム」の場合のみ使用されます。16の倍数である必要があります(GPT Image 2 のみ)。" + }, + "custom_width": { + "name": "custom_width", + "tooltip": "「サイズ」が「カスタム」の場合のみ使用されます。16の倍数である必要があります(GPT Image 2 のみ)。" + }, "image": { "name": "参照画像", "tooltip": "画像編集用のオプション参照画像。" diff --git a/src/locales/ja/settings.json b/src/locales/ja/settings.json index 017239152c..6d9648f41f 100644 --- a/src/locales/ja/settings.json +++ b/src/locales/ja/settings.json @@ -272,6 +272,10 @@ "v1 (legacy)": "v1(レガシー)" } }, + "Comfy_NodeSearchBoxImpl_FollowCursor": { + "name": "追加されたノードがカーソルに追従", + "tooltip": "有効にすると、検索ボックスから追加されたノードがクリックして配置するまでカーソルに追従します。デフォルトの実装にのみ適用されます。" + }, "Comfy_NodeSearchBoxImpl_NodePreview": { "name": "ノードプレビュー", "tooltip": "デフォルトの実装にのみ適用されます" diff --git a/src/locales/ko/main.json b/src/locales/ko/main.json index 6b0ace7685..76f76bbbab 100644 --- a/src/locales/ko/main.json +++ b/src/locales/ko/main.json @@ -1008,13 +1008,16 @@ "back": "뒤로", "batchRename": "일괄 이름 변경", "beta": "베타", + "blueprints": "블루프린트", "bookmark": "라이브러리에 저장", + "bookmarked": "북마크됨", "browserReservedKeybinding": "이 단축키는 일부 브라우저에서 예약되어 있어 예기치 않은 결과가 발생할 수 있습니다.", "browserReservedKeybindingTooltip": "이 단축키는 브라우저 예약 단축키와 충돌합니다", "calculatingDimensions": "크기 계산 중", "cancel": "취소", "cancelled": "취소됨", "capture": "캡처", + "categories": "카테고리", "category": "카테고리", "changeKeybinding": "키 바인딩 변경", "chart": "차트", @@ -1235,6 +1238,7 @@ "output": "출력", "overwrite": "덮어쓰기", "partner": "파트너", + "partnerNodes": "파트너 노드", "pause": "일시정지", "play": "재생", "playPause": "재생/일시정지", @@ -2607,6 +2611,9 @@ "metadataFetchFailed": "메타데이터를 가져오지 못했습니다. 링크를 확인하고 다시 시도하세요.", "missingModelsTitle": "누락된 모델", "or": "또는", + "refresh": "새로고침", + "refreshFailed": "누락된 모델을 새로고침하지 못했습니다. 다시 시도해 주세요.", + "refreshing": "누락된 모델을 새로고침하는 중입니다.", "typeMismatch": "이 모델은 \"{detectedType}\"로 보입니다. 계속하시겠습니까?", "unknownCategory": "알 수 없음", "unsupportedUrl": "Civitai와 Hugging Face URL만 지원됩니다.", @@ -3500,6 +3507,7 @@ "no3dScene": "텍스처를 적용할 3D 장면이 없습니다", "no3dSceneToExport": "내보낼 3D 장면이 없습니다", "noTemplatesToExport": "내보낼 템플릿이 없습니다", + "nodeDefinitionsUpdateFailed": "노드 정의 업데이트에 실패했습니다", "nodeDefinitionsUpdated": "노드 정의가 업데이트되었습니다", "nothingSelected": "선택된 항목이 없습니다", "nothingToGroup": "그룹화할 항목이 없습니다", diff --git a/src/locales/ko/nodeDefs.json b/src/locales/ko/nodeDefs.json index 876e33e458..636fe5e88f 100644 --- a/src/locales/ko/nodeDefs.json +++ b/src/locales/ko/nodeDefs.json @@ -4534,6 +4534,156 @@ } } }, + "HappyHorseImageToVideoApi": { + "description": "HappyHorse 모델을 사용하여 첫 프레임 이미지를 기반으로 비디오를 생성합니다.", + "display_name": "HappyHorse 이미지 → 비디오", + "inputs": { + "control_after_generate": { + "name": "생성 후 제어" + }, + "first_frame": { + "name": "first_frame", + "tooltip": "첫 프레임 이미지입니다. 출력 비율은 이 이미지에서 파생됩니다." + }, + "model": { + "name": "model" + }, + "model_duration": { + "name": "지속 시간" + }, + "model_prompt": { + "name": "프롬프트" + }, + "model_resolution": { + "name": "해상도" + }, + "seed": { + "name": "seed", + "tooltip": "생성에 사용할 시드입니다." + }, + "watermark": { + "name": "watermark", + "tooltip": "AI 생성 워터마크를 결과에 추가할지 여부입니다." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "HappyHorseReferenceVideoApi": { + "description": "HappyHorse 모델을 사용하여 참조 자료에서 인물 또는 객체가 등장하는 비디오를 생성합니다. 단일 캐릭터 연기와 다중 캐릭터 상호작용을 지원합니다.", + "display_name": "HappyHorse 참조 → 비디오", + "inputs": { + "control_after_generate": { + "name": "생성 후 제어" + }, + "model": { + "name": "model" + }, + "model_duration": { + "name": "지속 시간" + }, + "model_prompt": { + "name": "프롬프트" + }, + "model_ratio": { + "name": "비율" + }, + "model_resolution": { + "name": "해상도" + }, + "seed": { + "name": "seed", + "tooltip": "생성에 사용할 시드입니다." + }, + "watermark": { + "name": "watermark", + "tooltip": "AI 생성 워터마크를 결과에 추가할지 여부입니다." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "HappyHorseTextToVideoApi": { + "description": "HappyHorse 모델을 사용하여 텍스트 프롬프트를 기반으로 비디오를 생성합니다.", + "display_name": "HappyHorse 텍스트 → 비디오", + "inputs": { + "control_after_generate": { + "name": "생성 후 제어" + }, + "model": { + "name": "model" + }, + "model_duration": { + "name": "지속 시간" + }, + "model_prompt": { + "name": "프롬프트" + }, + "model_ratio": { + "name": "비율" + }, + "model_resolution": { + "name": "해상도" + }, + "seed": { + "name": "seed", + "tooltip": "생성에 사용할 시드입니다." + }, + "watermark": { + "name": "watermark", + "tooltip": "AI 생성 워터마크를 결과에 추가할지 여부입니다." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "HappyHorseVideoEditApi": { + "description": "HappyHorse 모델을 사용하여 텍스트 지시문 또는 참조 이미지를 통해 비디오를 편집합니다. 출력 지속 시간은 3~15초이며 입력 비디오와 일치합니다. 15초를 초과하는 입력은 잘립니다.", + "display_name": "HappyHorse 비디오 편집", + "inputs": { + "control_after_generate": { + "name": "생성 후 제어" + }, + "model": { + "name": "model" + }, + "model_prompt": { + "name": "프롬프트" + }, + "model_ratio": { + "name": "비율" + }, + "model_resolution": { + "name": "해상도" + }, + "seed": { + "name": "seed", + "tooltip": "생성에 사용할 시드입니다." + }, + "video": { + "name": "video", + "tooltip": "편집할 비디오입니다." + }, + "watermark": { + "name": "watermark", + "tooltip": "AI 생성 워터마크를 결과에 추가할지 여부입니다." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "HitPawGeneralImageEnhance": { "description": "저해상도 이미지를 초고해상도로 업스케일하고, 아티팩트와 노이즈를 제거합니다. 최대 출력: 32 메가픽셀.", "display_name": "HitPaw 일반 이미지 향상", @@ -11685,6 +11835,14 @@ "control_after_generate": { "name": "생성 후 제어" }, + "custom_height": { + "name": "custom_height", + "tooltip": "`size`가 'Custom'일 때만 사용됩니다. 16의 배수여야 합니다(GPT Image 2 전용)." + }, + "custom_width": { + "name": "custom_width", + "tooltip": "`size`가 'Custom'일 때만 사용됩니다. 16의 배수여야 합니다(GPT Image 2 전용)." + }, "image": { "name": "참조 이미지", "tooltip": "이미지 편집을 위한 선택적 참조 이미지." diff --git a/src/locales/ko/settings.json b/src/locales/ko/settings.json index 25a7afe5cc..6e342b2212 100644 --- a/src/locales/ko/settings.json +++ b/src/locales/ko/settings.json @@ -272,6 +272,10 @@ "v1 (legacy)": "v1 (legacy)" } }, + "Comfy_NodeSearchBoxImpl_FollowCursor": { + "name": "추가된 노드가 커서를 따라감", + "tooltip": "활성화하면, 검색 상자에서 추가된 노드가 클릭하여 배치할 때까지 커서를 따라갑니다. 기본 구현에만 적용됩니다." + }, "Comfy_NodeSearchBoxImpl_NodePreview": { "name": "노드 미리보기", "tooltip": "기본 구현에만 적용됩니다." diff --git a/src/locales/pt-BR/main.json b/src/locales/pt-BR/main.json index 2cc3dc5436..b95122665c 100644 --- a/src/locales/pt-BR/main.json +++ b/src/locales/pt-BR/main.json @@ -1008,13 +1008,16 @@ "back": "Voltar", "batchRename": "Renomear em lote", "beta": "BETA", + "blueprints": "Modelos", "bookmark": "Salvar na biblioteca", + "bookmarked": "Favoritos", "browserReservedKeybinding": "Este atalho é reservado por alguns navegadores e pode ter resultados inesperados.", "browserReservedKeybindingTooltip": "Este atalho entra em conflito com atalhos reservados do navegador", "calculatingDimensions": "Calculando dimensões", "cancel": "Cancelar", "cancelled": "Cancelado", "capture": "capturar", + "categories": "Categorias", "category": "Categoria", "changeKeybinding": "Alterar atalho", "chart": "Gráfico", @@ -1235,6 +1238,7 @@ "output": "Saída", "overwrite": "Sobrescrever", "partner": "Parceiro", + "partnerNodes": "Nós Parceiros", "pause": "Pausar", "play": "Reproduzir", "playPause": "Reproduzir/Pausar", @@ -2607,6 +2611,9 @@ "metadataFetchFailed": "Falha ao obter metadados. Verifique o link e tente novamente.", "missingModelsTitle": "Modelos Ausentes", "or": "OU", + "refresh": "Atualizar", + "refreshFailed": "Falha ao atualizar modelos ausentes. Por favor, tente novamente.", + "refreshing": "Atualizando modelos ausentes.", "typeMismatch": "Este modelo parece ser um \"{detectedType}\". Tem certeza?", "unknownCategory": "Desconhecido", "unsupportedUrl": "Apenas URLs do Civitai e Hugging Face são suportadas.", @@ -3512,6 +3519,7 @@ "no3dScene": "Nenhuma cena 3D para aplicar textura", "no3dSceneToExport": "Nenhuma cena 3D para exportar", "noTemplatesToExport": "Nenhum template para exportar", + "nodeDefinitionsUpdateFailed": "Falha ao atualizar definições de nós", "nodeDefinitionsUpdated": "Definições de nós atualizadas", "nothingSelected": "Nada selecionado", "nothingToGroup": "Nada para agrupar", diff --git a/src/locales/pt-BR/nodeDefs.json b/src/locales/pt-BR/nodeDefs.json index 48ad1d11f5..404bc672b3 100644 --- a/src/locales/pt-BR/nodeDefs.json +++ b/src/locales/pt-BR/nodeDefs.json @@ -4534,6 +4534,156 @@ } } }, + "HappyHorseImageToVideoApi": { + "description": "Gere um vídeo a partir de uma imagem do primeiro quadro usando o modelo HappyHorse.", + "display_name": "HappyHorse Imagem para Vídeo", + "inputs": { + "control_after_generate": { + "name": "control after generate" + }, + "first_frame": { + "name": "first_frame", + "tooltip": "Imagem do primeiro quadro. A proporção de aspecto de saída é derivada desta imagem." + }, + "model": { + "name": "model" + }, + "model_duration": { + "name": "duration" + }, + "model_prompt": { + "name": "prompt" + }, + "model_resolution": { + "name": "resolution" + }, + "seed": { + "name": "seed", + "tooltip": "Seed para usar na geração." + }, + "watermark": { + "name": "watermark", + "tooltip": "Adicionar ou não uma marca d'água gerada por IA ao resultado." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "HappyHorseReferenceVideoApi": { + "description": "Gere um vídeo apresentando uma pessoa ou objeto a partir de materiais de referência com o modelo HappyHorse. Suporta performances de personagem único e interações entre múltiplos personagens.", + "display_name": "HappyHorse Referência para Vídeo", + "inputs": { + "control_after_generate": { + "name": "control after generate" + }, + "model": { + "name": "model" + }, + "model_duration": { + "name": "duration" + }, + "model_prompt": { + "name": "prompt" + }, + "model_ratio": { + "name": "ratio" + }, + "model_resolution": { + "name": "resolution" + }, + "seed": { + "name": "seed", + "tooltip": "Seed para usar na geração." + }, + "watermark": { + "name": "watermark", + "tooltip": "Adicionar ou não uma marca d'água gerada por IA ao resultado." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "HappyHorseTextToVideoApi": { + "description": "Gera um vídeo com base em um prompt de texto usando o modelo HappyHorse.", + "display_name": "HappyHorse Texto para Vídeo", + "inputs": { + "control_after_generate": { + "name": "control after generate" + }, + "model": { + "name": "model" + }, + "model_duration": { + "name": "duration" + }, + "model_prompt": { + "name": "prompt" + }, + "model_ratio": { + "name": "ratio" + }, + "model_resolution": { + "name": "resolution" + }, + "seed": { + "name": "seed", + "tooltip": "Seed para usar na geração." + }, + "watermark": { + "name": "watermark", + "tooltip": "Adicionar ou não uma marca d'água gerada por IA ao resultado." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "HappyHorseVideoEditApi": { + "description": "Edite um vídeo usando instruções de texto ou imagens de referência com o modelo HappyHorse. A duração de saída é de 3 a 15s e corresponde ao vídeo de entrada; entradas com mais de 15s são cortadas.", + "display_name": "HappyHorse Edição de Vídeo", + "inputs": { + "control_after_generate": { + "name": "control after generate" + }, + "model": { + "name": "model" + }, + "model_prompt": { + "name": "prompt" + }, + "model_ratio": { + "name": "ratio" + }, + "model_resolution": { + "name": "resolution" + }, + "seed": { + "name": "seed", + "tooltip": "Seed para usar na geração." + }, + "video": { + "name": "video", + "tooltip": "O vídeo a ser editado." + }, + "watermark": { + "name": "watermark", + "tooltip": "Adicionar ou não uma marca d'água gerada por IA ao resultado." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "HitPawGeneralImageEnhance": { "description": "Aumente imagens de baixa resolução para super-resolução, eliminando artefatos e ruídos. Saída máxima: 32 megapixels.", "display_name": "HitPaw General Image Enhance", @@ -11685,6 +11835,14 @@ "control_after_generate": { "name": "controle após gerar" }, + "custom_height": { + "name": "custom_height", + "tooltip": "Usado apenas quando `size` é 'Custom'. Deve ser um múltiplo de 16 (apenas para GPT Image 2)." + }, + "custom_width": { + "name": "custom_width", + "tooltip": "Usado apenas quando `size` é 'Custom'. Deve ser um múltiplo de 16 (apenas para GPT Image 2)." + }, "image": { "name": "imagem", "tooltip": "Imagem de referência opcional para edição de imagem." diff --git a/src/locales/pt-BR/settings.json b/src/locales/pt-BR/settings.json index 41f060fdf7..2b0d388ccd 100644 --- a/src/locales/pt-BR/settings.json +++ b/src/locales/pt-BR/settings.json @@ -272,6 +272,10 @@ "v1 (legacy)": "v1 (legado)" } }, + "Comfy_NodeSearchBoxImpl_FollowCursor": { + "name": "Nós adicionados seguem o cursor", + "tooltip": "Quando ativado, os nós adicionados pela caixa de pesquisa seguem o cursor até serem clicados para posicionar. Aplica-se apenas à implementação padrão." + }, "Comfy_NodeSearchBoxImpl_NodePreview": { "name": "Pré-visualização do nó", "tooltip": "Aplica-se apenas à implementação padrão" diff --git a/src/locales/ru/main.json b/src/locales/ru/main.json index fba761b33e..b987dfe806 100644 --- a/src/locales/ru/main.json +++ b/src/locales/ru/main.json @@ -1008,13 +1008,16 @@ "back": "Назад", "batchRename": "Пакетное переименование", "beta": "БЕТА", + "blueprints": "Чертежи", "bookmark": "Сохранить в библиотеку", + "bookmarked": "В избранном", "browserReservedKeybinding": "Это сочетание клавиш зарезервировано некоторыми браузерами и может привести к неожиданным результатам.", "browserReservedKeybindingTooltip": "Это сочетание конфликтует с зарезервированными сочетаниями браузера", "calculatingDimensions": "Расчёт размеров", "cancel": "Отмена", "cancelled": "Отменено", "capture": "захват", + "categories": "Категории", "category": "Категория", "changeKeybinding": "Изменить сочетание клавиш", "chart": "Диаграмма", @@ -1235,6 +1238,7 @@ "output": "Выход", "overwrite": "Перезаписать", "partner": "Партнёр", + "partnerNodes": "Партнёрские узлы", "pause": "Пауза", "play": "Воспроизвести", "playPause": "Воспроизвести/Пауза", @@ -2607,6 +2611,9 @@ "metadataFetchFailed": "Не удалось получить метаданные. Проверьте ссылку и попробуйте снова.", "missingModelsTitle": "Отсутствующие модели", "or": "ИЛИ", + "refresh": "Обновить", + "refreshFailed": "Не удалось обновить отсутствующие модели. Пожалуйста, попробуйте ещё раз.", + "refreshing": "Обновление отсутствующих моделей.", "typeMismatch": "Похоже, эта модель — «{detectedType}». Вы уверены?", "unknownCategory": "Неизвестно", "unsupportedUrl": "Поддерживаются только URL Civitai и Hugging Face.", @@ -3500,6 +3507,7 @@ "no3dScene": "Нет 3D сцены для применения текстуры", "no3dSceneToExport": "Нет 3D сцены для экспорта", "noTemplatesToExport": "Нет шаблонов для экспорта", + "nodeDefinitionsUpdateFailed": "Не удалось обновить определения узлов", "nodeDefinitionsUpdated": "Определения узлов обновлены", "nothingSelected": "Ничего не выбрано", "nothingToGroup": "Нечего группировать", diff --git a/src/locales/ru/nodeDefs.json b/src/locales/ru/nodeDefs.json index bf4fa1d782..a1422f3430 100644 --- a/src/locales/ru/nodeDefs.json +++ b/src/locales/ru/nodeDefs.json @@ -4534,6 +4534,156 @@ } } }, + "HappyHorseImageToVideoApi": { + "description": "Создайте видео из изображения первого кадра с помощью модели HappyHorse.", + "display_name": "HappyHorse: изображение в видео", + "inputs": { + "control_after_generate": { + "name": "контроль после генерации" + }, + "first_frame": { + "name": "first_frame", + "tooltip": "Изображение первого кадра. Соотношение сторон результата определяется этим изображением." + }, + "model": { + "name": "model" + }, + "model_duration": { + "name": "длительность" + }, + "model_prompt": { + "name": "промпт" + }, + "model_resolution": { + "name": "разрешение" + }, + "seed": { + "name": "seed", + "tooltip": "Зерно для генерации." + }, + "watermark": { + "name": "watermark", + "tooltip": "Добавлять ли AI-водяной знак к результату." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "HappyHorseReferenceVideoApi": { + "description": "Создайте видео с человеком или объектом по референсным материалам с помощью модели HappyHorse. Поддерживаются одиночные персонажи и взаимодействие нескольких персонажей.", + "display_name": "HappyHorse: референс в видео", + "inputs": { + "control_after_generate": { + "name": "контроль после генерации" + }, + "model": { + "name": "model" + }, + "model_duration": { + "name": "длительность" + }, + "model_prompt": { + "name": "промпт" + }, + "model_ratio": { + "name": "соотношение" + }, + "model_resolution": { + "name": "разрешение" + }, + "seed": { + "name": "seed", + "tooltip": "Зерно для генерации." + }, + "watermark": { + "name": "watermark", + "tooltip": "Добавлять ли AI-водяной знак к результату." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "HappyHorseTextToVideoApi": { + "description": "Создаёт видео по текстовому промпту с помощью модели HappyHorse.", + "display_name": "HappyHorse: текст в видео", + "inputs": { + "control_after_generate": { + "name": "контроль после генерации" + }, + "model": { + "name": "model" + }, + "model_duration": { + "name": "длительность" + }, + "model_prompt": { + "name": "промпт" + }, + "model_ratio": { + "name": "соотношение" + }, + "model_resolution": { + "name": "разрешение" + }, + "seed": { + "name": "seed", + "tooltip": "Зерно для генерации." + }, + "watermark": { + "name": "watermark", + "tooltip": "Добавлять ли AI-водяной знак к результату." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "HappyHorseVideoEditApi": { + "description": "Редактируйте видео с помощью текстовых инструкций или референсных изображений в модели HappyHorse. Длительность результата — 3-15 секунд и соответствует входному видео; входные видео длиннее 15 секунд будут обрезаны.", + "display_name": "HappyHorse: редактирование видео", + "inputs": { + "control_after_generate": { + "name": "контроль после генерации" + }, + "model": { + "name": "model" + }, + "model_prompt": { + "name": "промпт" + }, + "model_ratio": { + "name": "соотношение" + }, + "model_resolution": { + "name": "разрешение" + }, + "seed": { + "name": "seed", + "tooltip": "Зерно для генерации." + }, + "video": { + "name": "video", + "tooltip": "Видео для редактирования." + }, + "watermark": { + "name": "watermark", + "tooltip": "Добавлять ли AI-водяной знак к результату." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "HitPawGeneralImageEnhance": { "description": "Увеличение разрешения низкокачественных изображений до сверхвысокого, устранение артефактов и шума. Максимальный выход: 32 мегапикселя.", "display_name": "HitPaw General Image Enhance", @@ -11685,6 +11835,14 @@ "control_after_generate": { "name": "control after generate" }, + "custom_height": { + "name": "custom_height", + "tooltip": "Используется только если значение «size» — «Custom». Должно быть кратно 16 (только для GPT Image 2)." + }, + "custom_width": { + "name": "custom_width", + "tooltip": "Используется только если значение «size» — «Custom». Должно быть кратно 16 (только для GPT Image 2)." + }, "image": { "name": "image", "tooltip": "Необязательное референсное изображение для редактирования." diff --git a/src/locales/ru/settings.json b/src/locales/ru/settings.json index 6d61e1cf26..836f275b5d 100644 --- a/src/locales/ru/settings.json +++ b/src/locales/ru/settings.json @@ -272,6 +272,10 @@ "v1 (legacy)": "v1 (устаревший)" } }, + "Comfy_NodeSearchBoxImpl_FollowCursor": { + "name": "Добавленные узлы следуют за курсором", + "tooltip": "Если включено, узлы, добавленные из поискового окна, будут следовать за курсором до тех пор, пока не будут размещены щелчком. Применяется только к реализации по умолчанию." + }, "Comfy_NodeSearchBoxImpl_NodePreview": { "name": "Предварительный просмотр ноды", "tooltip": "Применяется только к стандартной реализации" diff --git a/src/locales/tr/main.json b/src/locales/tr/main.json index 750a000dfe..4d7c5ebb07 100644 --- a/src/locales/tr/main.json +++ b/src/locales/tr/main.json @@ -1008,13 +1008,16 @@ "back": "Geri", "batchRename": "Toplu Yeniden Adlandır", "beta": "BETA", + "blueprints": "Planlar", "bookmark": "Kütüphaneye Kaydet", + "bookmarked": "Yer İmleri", "browserReservedKeybinding": "Bu kısayol bazı tarayıcılar tarafından ayrılmıştır ve beklenmeyen sonuçlara yol açabilir.", "browserReservedKeybindingTooltip": "Bu kısayol, tarayıcıya ayrılmış kısayollarla çakışıyor", "calculatingDimensions": "Boyutlar hesaplanıyor", "cancel": "İptal", "cancelled": "İptal Edildi", "capture": "yakala", + "categories": "Kategoriler", "category": "Kategori", "changeKeybinding": "Kısayol tuşunu değiştir", "chart": "Grafik", @@ -1235,6 +1238,7 @@ "output": "Çıktı", "overwrite": "Üzerine Yaz", "partner": "Partner", + "partnerNodes": "Ortak Düğümler", "pause": "Duraklat", "play": "Oynat", "playPause": "Oynat/Duraklat", @@ -2607,6 +2611,9 @@ "metadataFetchFailed": "Meta veriler alınamadı. Lütfen bağlantıyı kontrol edip tekrar deneyin.", "missingModelsTitle": "Eksik Modeller", "or": "VEYA", + "refresh": "Yenile", + "refreshFailed": "Eksik modeller yenilenemedi. Lütfen tekrar deneyin.", + "refreshing": "Eksik modeller yenileniyor.", "typeMismatch": "Bu model \"{detectedType}\" gibi görünüyor. Emin misiniz?", "unknownCategory": "Bilinmeyen", "unsupportedUrl": "Sadece Civitai ve Hugging Face URL'leri desteklenmektedir.", @@ -3500,6 +3507,7 @@ "no3dScene": "Doku uygulanacak 3D sahne yok", "no3dSceneToExport": "Dışa aktarılacak 3D sahne yok", "noTemplatesToExport": "Dışa aktarılacak şablon yok", + "nodeDefinitionsUpdateFailed": "Düğüm tanımları güncellenemedi", "nodeDefinitionsUpdated": "Düğüm tanımları güncellendi", "nothingSelected": "Hiçbir şey seçilmedi", "nothingToGroup": "Gruplandırılacak bir şey yok", diff --git a/src/locales/tr/nodeDefs.json b/src/locales/tr/nodeDefs.json index 974de9da1f..d684eab4bd 100644 --- a/src/locales/tr/nodeDefs.json +++ b/src/locales/tr/nodeDefs.json @@ -4534,6 +4534,156 @@ } } }, + "HappyHorseImageToVideoApi": { + "description": "HappyHorse modeliyle ilk kare görüntüsünden bir video oluşturun.", + "display_name": "HappyHorse Görüntüden Videoya", + "inputs": { + "control_after_generate": { + "name": "oluşturduktan sonra kontrol" + }, + "first_frame": { + "name": "ilk_kare", + "tooltip": "İlk kare görüntüsü. Çıktı en-boy oranı bu görüntüden alınır." + }, + "model": { + "name": "model" + }, + "model_duration": { + "name": "süre" + }, + "model_prompt": { + "name": "istem" + }, + "model_resolution": { + "name": "çözünürlük" + }, + "seed": { + "name": "tohum", + "tooltip": "Oluşturma için kullanılacak tohum." + }, + "watermark": { + "name": "filigran", + "tooltip": "Sonuca yapay zeka tarafından oluşturulmuş bir filigran eklenip eklenmeyeceği." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "HappyHorseReferenceVideoApi": { + "description": "HappyHorse modeliyle referans materyallerden bir kişi veya nesne içeren video oluşturun. Tek karakter performanslarını ve çok karakterli etkileşimleri destekler.", + "display_name": "HappyHorse Referanstan Videoya", + "inputs": { + "control_after_generate": { + "name": "oluşturduktan sonra kontrol" + }, + "model": { + "name": "model" + }, + "model_duration": { + "name": "süre" + }, + "model_prompt": { + "name": "istem" + }, + "model_ratio": { + "name": "oran" + }, + "model_resolution": { + "name": "çözünürlük" + }, + "seed": { + "name": "tohum", + "tooltip": "Oluşturma için kullanılacak tohum." + }, + "watermark": { + "name": "filigran", + "tooltip": "Sonuca yapay zeka tarafından oluşturulmuş bir filigran eklenip eklenmeyeceği." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "HappyHorseTextToVideoApi": { + "description": "HappyHorse modeliyle metin istemine dayalı bir video oluşturur.", + "display_name": "HappyHorse Metinden Videoya", + "inputs": { + "control_after_generate": { + "name": "oluşturduktan sonra kontrol" + }, + "model": { + "name": "model" + }, + "model_duration": { + "name": "süre" + }, + "model_prompt": { + "name": "istem" + }, + "model_ratio": { + "name": "oran" + }, + "model_resolution": { + "name": "çözünürlük" + }, + "seed": { + "name": "tohum", + "tooltip": "Oluşturma için kullanılacak tohum." + }, + "watermark": { + "name": "filigran", + "tooltip": "Sonuca yapay zeka tarafından oluşturulmuş bir filigran eklenip eklenmeyeceği." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "HappyHorseVideoEditApi": { + "description": "HappyHorse modeliyle metin talimatları veya referans görüntüler kullanarak bir videoyu düzenleyin. Çıktı süresi 3-15 saniyedir ve giriş videosuyla eşleşir; 15 saniyeden uzun girişler kısaltılır.", + "display_name": "HappyHorse Video Düzenleme", + "inputs": { + "control_after_generate": { + "name": "oluşturduktan sonra kontrol" + }, + "model": { + "name": "model" + }, + "model_prompt": { + "name": "istem" + }, + "model_ratio": { + "name": "oran" + }, + "model_resolution": { + "name": "çözünürlük" + }, + "seed": { + "name": "tohum", + "tooltip": "Oluşturma için kullanılacak tohum." + }, + "video": { + "name": "video", + "tooltip": "Düzenlenecek video." + }, + "watermark": { + "name": "filigran", + "tooltip": "Sonuca yapay zeka tarafından oluşturulmuş bir filigran eklenip eklenmeyeceği." + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "HitPawGeneralImageEnhance": { "description": "Düşük çözünürlüklü görüntüleri süper çözünürlüğe yükseltin, bozulmaları ve gürültüyü ortadan kaldırın. Maksimum çıktı: 32 megapiksel.", "display_name": "HitPaw Genel Görüntü İyileştirme", @@ -11685,6 +11835,14 @@ "control_after_generate": { "name": "oluşturduktan sonra kontrol et" }, + "custom_height": { + "name": "özel_yükseklik", + "tooltip": "`boyut` 'Özel' olarak ayarlandığında kullanılır. Yalnızca 16'nın katı olmalıdır (yalnızca GPT Image 2 için)." + }, + "custom_width": { + "name": "özel_genişlik", + "tooltip": "`boyut` 'Özel' olarak ayarlandığında kullanılır. Yalnızca 16'nın katı olmalıdır (yalnızca GPT Image 2 için)." + }, "image": { "name": "görüntü", "tooltip": "Görüntü düzenleme için isteğe bağlı referans görüntü." diff --git a/src/locales/tr/settings.json b/src/locales/tr/settings.json index ea83bcfd85..555d03a070 100644 --- a/src/locales/tr/settings.json +++ b/src/locales/tr/settings.json @@ -272,6 +272,10 @@ "v1 (legacy)": "v1 (eski)" } }, + "Comfy_NodeSearchBoxImpl_FollowCursor": { + "name": "Eklenen düğümler imleci takip eder", + "tooltip": "Etkinleştirildiğinde, arama kutusundan eklenen düğümler tıklanıp yerleştirilene kadar imleci takip eder. Yalnızca varsayılan uygulama için geçerlidir." + }, "Comfy_NodeSearchBoxImpl_NodePreview": { "name": "Düğüm önizlemesi", "tooltip": "Yalnızca varsayılan uygulama için geçerlidir" diff --git a/src/locales/zh-TW/main.json b/src/locales/zh-TW/main.json index c539c913bb..c1fa7c826f 100644 --- a/src/locales/zh-TW/main.json +++ b/src/locales/zh-TW/main.json @@ -1008,13 +1008,16 @@ "back": "返回", "batchRename": "批次重新命名", "beta": "測試版", + "blueprints": "藍圖", "bookmark": "儲存至程式庫", + "bookmarked": "已加入書籤", "browserReservedKeybinding": "此快捷鍵已被部分瀏覽器保留,可能會產生非預期結果。", "browserReservedKeybindingTooltip": "此快捷鍵與瀏覽器保留的快捷鍵衝突", "calculatingDimensions": "計算尺寸中", "cancel": "取消", "cancelled": "已取消", "capture": "擷取", + "categories": "分類", "category": "分類", "changeKeybinding": "變更快捷鍵", "chart": "圖表", @@ -1235,6 +1238,7 @@ "output": "輸出", "overwrite": "覆蓋", "partner": "合作夥伴", + "partnerNodes": "合作夥伴節點", "pause": "暫停", "play": "播放", "playPause": "播放/暫停", @@ -2607,6 +2611,9 @@ "metadataFetchFailed": "取得中繼資料失敗。請檢查連結並重試。", "missingModelsTitle": "缺少的模型", "or": "或", + "refresh": "重新整理", + "refreshFailed": "重新整理缺少的模型失敗。請再試一次。", + "refreshing": "正在重新整理缺少的模型。", "typeMismatch": "此模型似乎是「{detectedType}」。您確定嗎?", "unknownCategory": "未知", "unsupportedUrl": "僅支援 Civitai 與 Hugging Face 網址。", @@ -3500,6 +3507,7 @@ "no3dScene": "沒有 3D 場景可套用材質", "no3dSceneToExport": "沒有 3D 場景可匯出", "noTemplatesToExport": "沒有可匯出的範本", + "nodeDefinitionsUpdateFailed": "節點定義更新失敗", "nodeDefinitionsUpdated": "節點定義已更新", "nothingSelected": "未選取任何項目", "nothingToGroup": "沒有項目可分組", diff --git a/src/locales/zh-TW/nodeDefs.json b/src/locales/zh-TW/nodeDefs.json index e3bf7508b9..6ee4c1cf39 100644 --- a/src/locales/zh-TW/nodeDefs.json +++ b/src/locales/zh-TW/nodeDefs.json @@ -4534,6 +4534,156 @@ } } }, + "HappyHorseImageToVideoApi": { + "description": "使用 HappyHorse 模型,從首幀圖像生成影片。", + "display_name": "HappyHorse 圖像轉影片", + "inputs": { + "control_after_generate": { + "name": "生成後控制" + }, + "first_frame": { + "name": "first_frame", + "tooltip": "首幀圖像。輸出長寬比將依據此圖像。" + }, + "model": { + "name": "model" + }, + "model_duration": { + "name": "時長" + }, + "model_prompt": { + "name": "提示詞" + }, + "model_resolution": { + "name": "解析度" + }, + "seed": { + "name": "seed", + "tooltip": "用於生成的種子。" + }, + "watermark": { + "name": "watermark", + "tooltip": "是否在結果中加入 AI 生成的浮水印。" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "HappyHorseReferenceVideoApi": { + "description": "使用 HappyHorse 模型,根據參考素材生成包含人物或物件的影片。支援單角色表演與多角色互動。", + "display_name": "HappyHorse 參考素材轉影片", + "inputs": { + "control_after_generate": { + "name": "生成後控制" + }, + "model": { + "name": "model" + }, + "model_duration": { + "name": "時長" + }, + "model_prompt": { + "name": "提示詞" + }, + "model_ratio": { + "name": "比例" + }, + "model_resolution": { + "name": "解析度" + }, + "seed": { + "name": "seed", + "tooltip": "用於生成的種子。" + }, + "watermark": { + "name": "watermark", + "tooltip": "是否在結果中加入 AI 生成的浮水印。" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "HappyHorseTextToVideoApi": { + "description": "使用 HappyHorse 模型,根據文字提示生成影片。", + "display_name": "HappyHorse 文字轉影片", + "inputs": { + "control_after_generate": { + "name": "生成後控制" + }, + "model": { + "name": "model" + }, + "model_duration": { + "name": "時長" + }, + "model_prompt": { + "name": "提示詞" + }, + "model_ratio": { + "name": "比例" + }, + "model_resolution": { + "name": "解析度" + }, + "seed": { + "name": "seed", + "tooltip": "用於生成的種子。" + }, + "watermark": { + "name": "watermark", + "tooltip": "是否在結果中加入 AI 生成的浮水印。" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "HappyHorseVideoEditApi": { + "description": "使用 HappyHorse 模型,根據文字指令或參考圖像編輯影片。輸出時長為 3-15 秒,並與輸入影片一致;超過 15 秒的輸入將被截斷。", + "display_name": "HappyHorse 影片編輯", + "inputs": { + "control_after_generate": { + "name": "生成後控制" + }, + "model": { + "name": "model" + }, + "model_prompt": { + "name": "提示詞" + }, + "model_ratio": { + "name": "比例" + }, + "model_resolution": { + "name": "解析度" + }, + "seed": { + "name": "seed", + "tooltip": "用於生成的種子。" + }, + "video": { + "name": "video", + "tooltip": "要編輯的影片。" + }, + "watermark": { + "name": "watermark", + "tooltip": "是否在結果中加入 AI 生成的浮水印。" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "HitPawGeneralImageEnhance": { "description": "將低解析度影像升級為超高解析度,消除雜訊與失真。最大輸出:32 百萬像素。", "display_name": "HitPaw 通用影像增強", @@ -11685,6 +11835,14 @@ "control_after_generate": { "name": "生成後控制" }, + "custom_height": { + "name": "custom_height", + "tooltip": "僅當「size」為「Custom」時使用。必須為 16 的倍數(僅限 GPT Image 2)。" + }, + "custom_width": { + "name": "custom_width", + "tooltip": "僅當「size」為「Custom」時使用。必須為 16 的倍數(僅限 GPT Image 2)。" + }, "image": { "name": "參考影像", "tooltip": "可選的參考影像,用於影像編輯。" diff --git a/src/locales/zh-TW/settings.json b/src/locales/zh-TW/settings.json index d4b832e056..eb734abe60 100644 --- a/src/locales/zh-TW/settings.json +++ b/src/locales/zh-TW/settings.json @@ -272,6 +272,10 @@ "v1 (legacy)": "v1(舊版)" } }, + "Comfy_NodeSearchBoxImpl_FollowCursor": { + "name": "新增節點跟隨游標", + "tooltip": "啟用後,從搜尋框新增的節點會跟隨游標,直到點擊以放置。僅適用於預設實作。" + }, "Comfy_NodeSearchBoxImpl_NodePreview": { "name": "節點預覽", "tooltip": "僅適用於預設實作" diff --git a/src/locales/zh/main.json b/src/locales/zh/main.json index 870c007f3b..c3cc54282d 100644 --- a/src/locales/zh/main.json +++ b/src/locales/zh/main.json @@ -1008,13 +1008,16 @@ "back": "返回", "batchRename": "批量重命名", "beta": "测试版", + "blueprints": "蓝图", "bookmark": "保存到库", + "bookmarked": "已收藏", "browserReservedKeybinding": "此快捷键被部分浏览器保留,可能导致意外结果。", "browserReservedKeybindingTooltip": "此快捷键与浏览器保留的快捷键冲突", "calculatingDimensions": "正在计算尺寸", "cancel": "取消", "cancelled": "已取消", "capture": "捕获", + "categories": "分类", "category": "类别", "changeKeybinding": "更改快捷键", "chart": "图表", @@ -1235,6 +1238,7 @@ "output": "输出", "overwrite": "覆盖", "partner": "合作伙伴", + "partnerNodes": "合作节点", "pause": "暂停", "play": "播放", "playPause": "开启/暂停", @@ -2607,6 +2611,9 @@ "metadataFetchFailed": "获取元数据失败。请检查链接后重试。", "missingModelsTitle": "缺失模型", "or": "或", + "refresh": "刷新", + "refreshFailed": "刷新缺失模型失败。请重试。", + "refreshing": "正在刷新缺失模型。", "typeMismatch": "该模型似乎是“{detectedType}”。你确定要继续吗?", "unknownCategory": "未知", "unsupportedUrl": "仅支持 Civitai 和 Hugging Face 链接。", @@ -3512,6 +3519,7 @@ "no3dScene": "没有3D场景可以应用纹理", "no3dSceneToExport": "没有3D场景可以导出", "noTemplatesToExport": "没有模板可以导出", + "nodeDefinitionsUpdateFailed": "节点定义更新失败", "nodeDefinitionsUpdated": "节点定义已更新", "nothingSelected": "未选择任何内容", "nothingToGroup": "没有可分组的内容", diff --git a/src/locales/zh/nodeDefs.json b/src/locales/zh/nodeDefs.json index 965eeb9240..fee872aa56 100644 --- a/src/locales/zh/nodeDefs.json +++ b/src/locales/zh/nodeDefs.json @@ -4534,6 +4534,156 @@ } } }, + "HappyHorseImageToVideoApi": { + "description": "使用 HappyHorse 模型根据首帧图像生成视频。", + "display_name": "HappyHorse 图像转视频", + "inputs": { + "control_after_generate": { + "name": "生成后控制" + }, + "first_frame": { + "name": "first_frame", + "tooltip": "首帧图像。输出视频的宽高比将根据该图像确定。" + }, + "model": { + "name": "model" + }, + "model_duration": { + "name": "时长" + }, + "model_prompt": { + "name": "提示词" + }, + "model_resolution": { + "name": "分辨率" + }, + "seed": { + "name": "seed", + "tooltip": "用于生成的种子。" + }, + "watermark": { + "name": "watermark", + "tooltip": "是否为结果添加 AI 生成的水印。" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "HappyHorseReferenceVideoApi": { + "description": "使用 HappyHorse 模型根据参考素材生成包含人物或物体的视频。支持单角色表演和多角色互动。", + "display_name": "HappyHorse 参考转视频", + "inputs": { + "control_after_generate": { + "name": "生成后控制" + }, + "model": { + "name": "model" + }, + "model_duration": { + "name": "时长" + }, + "model_prompt": { + "name": "提示词" + }, + "model_ratio": { + "name": "比例" + }, + "model_resolution": { + "name": "分辨率" + }, + "seed": { + "name": "seed", + "tooltip": "用于生成的种子。" + }, + "watermark": { + "name": "watermark", + "tooltip": "是否为结果添加 AI 生成的水印。" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "HappyHorseTextToVideoApi": { + "description": "使用 HappyHorse 模型根据文本提示生成视频。", + "display_name": "HappyHorse 文本转视频", + "inputs": { + "control_after_generate": { + "name": "生成后控制" + }, + "model": { + "name": "model" + }, + "model_duration": { + "name": "时长" + }, + "model_prompt": { + "name": "提示词" + }, + "model_ratio": { + "name": "比例" + }, + "model_resolution": { + "name": "分辨率" + }, + "seed": { + "name": "seed", + "tooltip": "用于生成的种子。" + }, + "watermark": { + "name": "watermark", + "tooltip": "是否为结果添加 AI 生成的水印。" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, + "HappyHorseVideoEditApi": { + "description": "使用 HappyHorse 模型通过文本指令或参考图像编辑视频。输出时长为 3-15 秒,并与输入视频一致;超过 15 秒的输入将被截断。", + "display_name": "HappyHorse 视频编辑", + "inputs": { + "control_after_generate": { + "name": "生成后控制" + }, + "model": { + "name": "model" + }, + "model_prompt": { + "name": "提示词" + }, + "model_ratio": { + "name": "比例" + }, + "model_resolution": { + "name": "分辨率" + }, + "seed": { + "name": "seed", + "tooltip": "用于生成的种子。" + }, + "video": { + "name": "video", + "tooltip": "要编辑的视频。" + }, + "watermark": { + "name": "watermark", + "tooltip": "是否为结果添加 AI 生成的水印。" + } + }, + "outputs": { + "0": { + "tooltip": null + } + } + }, "HitPawGeneralImageEnhance": { "description": "将低分辨率图像放大至超分辨率,消除伪影和噪点。最大输出:3200万像素。", "display_name": "HitPaw 通用图像增强", @@ -11685,6 +11835,14 @@ "control_after_generate": { "name": "生成后控制" }, + "custom_height": { + "name": "custom_height", + "tooltip": "仅当 `size` 为“自定义”时使用。(仅限 GPT Image 2)必须为 16 的倍数。" + }, + "custom_width": { + "name": "custom_width", + "tooltip": "仅当 `size` 为“自定义”时使用。(仅限 GPT Image 2)必须为 16 的倍数。" + }, "image": { "name": "参考图像", "tooltip": "用于图像编辑的可选参考图像。" diff --git a/src/locales/zh/settings.json b/src/locales/zh/settings.json index 075b434c12..5d64012ecf 100644 --- a/src/locales/zh/settings.json +++ b/src/locales/zh/settings.json @@ -272,6 +272,10 @@ "v1 (legacy)": "v1(旧版)" } }, + "Comfy_NodeSearchBoxImpl_FollowCursor": { + "name": "新增节点跟随光标", + "tooltip": "启用后,从搜索框添加的节点会跟随光标,直到点击以放置。仅适用于默认实现。" + }, "Comfy_NodeSearchBoxImpl_NodePreview": { "name": "显示节点预览", "tooltip": "仅适用于默认框架" diff --git a/src/platform/assets/components/MediaAssetCard.vue b/src/platform/assets/components/MediaAssetCard.vue index c649de8cc1..f9bedbebea 100644 --- a/src/platform/assets/components/MediaAssetCard.vue +++ b/src/platform/assets/components/MediaAssetCard.vue @@ -44,7 +44,7 @@ :context="{ type: assetType }" class="absolute inset-0" @view="handleZoomClick" - @download="actions.downloadAsset()" + @download="asset && actions.downloadAssets([asset])" @video-playing-state-changed="isVideoPlaying = $event" @video-controls-changed="showVideoControls = $event" @image-loaded="handleImageLoaded" @@ -122,6 +122,7 @@ v-tooltip.top.pt:pointer-events-none=" $t('mediaAsset.actions.seeMoreOutputs') " + :aria-label="$t('mediaAsset.actions.seeMoreOutputs')" variant="secondary" @click.stop="handleOutputCountClick" > @@ -135,12 +136,14 @@ </template> <script setup lang="ts"> +import { cn } from '@comfyorg/tailwind-utils' import { useElementHover } from '@vueuse/core' import { computed, defineAsyncComponent, provide, ref, toRef } from 'vue' import IconGroup from '@/components/button/IconGroup.vue' import LoadingOverlay from '@/components/common/LoadingOverlay.vue' import Button from '@/components/ui/button/Button.vue' +import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema' import { isCloud } from '@/platform/distribution/types' import { useAssetsStore } from '@/stores/assetsStore' import { @@ -150,7 +153,6 @@ import { getMediaTypeFromFilename, isPreviewableMediaType } from '@/utils/formatUtil' -import { cn } from '@comfyorg/tailwind-utils' import { getAssetType } from '../composables/media/assetMappers' import { getAssetUrl } from '../utils/assetUrlUtil' @@ -158,7 +160,7 @@ import { useMediaAssetActions } from '../composables/useMediaAssetActions' import type { AssetItem } from '../schemas/assetSchema' import { getAssetDisplayName } from '../utils/assetMetadataUtils' import type { MediaKind } from '../schemas/mediaAssetSchema' -import { MediaAssetKey } from '../schemas/mediaAssetSchema' +import { MediaAssetKey, MIME_ASSET_INFO } from '../schemas/mediaAssetSchema' import MediaTitle from './MediaTitle.vue' type PreviewKind = ReturnType<typeof getMediaTypeFromFilename> @@ -314,6 +316,13 @@ function dragStart(e: DragEvent) { const { dataTransfer } = e if (!dataTransfer) return + const { filename, subfolder, type } = + getOutputAssetMetadata(asset.user_metadata)?.allOutputs?.[0] ?? {} + if (filename) { + const outputString = JSON.stringify({ filename, subfolder, type }) + dataTransfer.items.add(outputString, MIME_ASSET_INFO) + } + const url = URL.parse(asset.preview_url, location.href) if (!url) return diff --git a/src/platform/assets/components/MediaAssetContextMenu.test.ts b/src/platform/assets/components/MediaAssetContextMenu.test.ts index 4d42541e7b..7784366537 100644 --- a/src/platform/assets/components/MediaAssetContextMenu.test.ts +++ b/src/platform/assets/components/MediaAssetContextMenu.test.ts @@ -1,5 +1,7 @@ import { render } from '@testing-library/vue' +import type { MenuItem } from 'primevue/menuitem' import { afterEach, describe, expect, it, vi } from 'vitest' +import type { PropType } from 'vue' import { defineComponent, nextTick, onMounted, ref } from 'vue' import MediaAssetContextMenu from '@/platform/assets/components/MediaAssetContextMenu.vue' @@ -29,7 +31,7 @@ vi.mock('@/utils/loaderNodeUtil', () => ({ const mediaAssetActions = { addWorkflow: vi.fn(), - downloadAsset: vi.fn(), + downloadAssets: vi.fn(), openWorkflow: vi.fn(), exportWorkflow: vi.fn(), copyJobId: vi.fn(), @@ -40,12 +42,18 @@ vi.mock('../composables/useMediaAssetActions', () => ({ useMediaAssetActions: () => mediaAssetActions })) +const capturedMenu = vi.hoisted(() => ({ model: [] as MenuItem[] })) + const contextMenuStub = defineComponent({ name: 'ContextMenu', props: { pt: { type: Object, default: undefined + }, + model: { + type: Array as PropType<MenuItem[]>, + default: () => [] } }, emits: ['hide'], @@ -54,6 +62,14 @@ const contextMenuStub = defineComponent({ visible: false } }, + watch: { + model: { + immediate: true, + handler(items: MenuItem[]) { + capturedMenu.model = items + } + } + }, methods: { show() { this.visible = true @@ -127,9 +143,24 @@ async function showMenu(container: Element): Promise<HTMLElement> { afterEach(() => { vi.clearAllMocks() capturedRef = null + capturedMenu.model = [] document.body.innerHTML = '' }) +type MenuItemWithCommand = MenuItem & { + command: NonNullable<MenuItem['command']> +} + +function findDownloadMenuItem(): MenuItemWithCommand { + const downloadItem = capturedMenu.model.find( + (item) => item.label === 'mediaAsset.actions.download' + ) + if (!downloadItem?.command) { + throw new Error('Download menu item or command was not registered') + } + return downloadItem as MenuItemWithCommand +} + describe('MediaAssetContextMenu', () => { it('dismisses outside pointerdown using the rendered root id', async () => { const { container, unmount, onHide } = mountComponent() @@ -152,4 +183,19 @@ describe('MediaAssetContextMenu', () => { unmount() }) + + it('routes Download through downloadAssets so multi-output jobs zip', async () => { + const { container, unmount } = mountComponent() + await showMenu(container) + + const downloadItem = findDownloadMenuItem() + downloadItem.command({ + originalEvent: new MouseEvent('click'), + item: downloadItem + }) + + expect(mediaAssetActions.downloadAssets).toHaveBeenCalledWith([asset]) + + unmount() + }) }) diff --git a/src/platform/assets/components/MediaAssetContextMenu.vue b/src/platform/assets/components/MediaAssetContextMenu.vue index fe476f60a7..89a45b302b 100644 --- a/src/platform/assets/components/MediaAssetContextMenu.vue +++ b/src/platform/assets/components/MediaAssetContextMenu.vue @@ -217,7 +217,7 @@ const contextMenuItems = computed<MenuItem[]>(() => { items.push({ label: t('mediaAsset.actions.download'), icon: 'icon-[lucide--download]', - command: () => actions.downloadAsset(asset) + command: () => actions.downloadAssets([asset]) }) // Separator before workflow actions (only if there are workflow actions) diff --git a/src/platform/assets/composables/useAssetSelection.ts b/src/platform/assets/composables/useAssetSelection.ts index 935d3faf1c..fd2512152f 100644 --- a/src/platform/assets/composables/useAssetSelection.ts +++ b/src/platform/assets/composables/useAssetSelection.ts @@ -3,6 +3,10 @@ import { computed, ref } from 'vue' import type { AssetItem } from '@/platform/assets/schemas/assetSchema' import { useAssetSelectionStore } from '@/platform/assets/composables/useAssetSelectionStore' +import { + getAssetOutputCount, + getTotalAssetOutputCount +} from '@/platform/assets/utils/outputAssetUtil' export function useAssetSelection() { const selectionStore = useAssetSelectionStore() @@ -142,15 +146,14 @@ export function useAssetSelection() { * Same logic as in AssetsSidebarTab.vue */ function getOutputCount(item: AssetItem): number { - const count = item.user_metadata?.outputCount - return typeof count === 'number' && count > 0 ? count : 1 + return getAssetOutputCount(item) } /** * Get the total output count for given assets */ function getTotalOutputCount(assets: AssetItem[]): number { - return assets.reduce((sum, asset) => sum + getOutputCount(asset), 0) + return getTotalAssetOutputCount(assets) } /** diff --git a/src/platform/assets/composables/useAssetSelectionStore.ts b/src/platform/assets/composables/useAssetSelectionStore.ts index 46c2698218..3348754350 100644 --- a/src/platform/assets/composables/useAssetSelectionStore.ts +++ b/src/platform/assets/composables/useAssetSelectionStore.ts @@ -1,11 +1,13 @@ import { defineStore } from 'pinia' import { computed, ref } from 'vue' +import type { AssetId } from '@/platform/assets/schemas/assetSchema' + export const useAssetSelectionStore = defineStore('assetSelection', () => { // State - const selectedAssetIds = ref<Set<string>>(new Set()) + const selectedAssetIds = ref<Set<AssetId>>(new Set()) const lastSelectedIndex = ref<number>(-1) - const lastSelectedAssetId = ref<string | null>(null) + const lastSelectedAssetId = ref<AssetId | null>(null) // Getters const selectedCount = computed(() => selectedAssetIds.value.size) @@ -13,15 +15,15 @@ export const useAssetSelectionStore = defineStore('assetSelection', () => { const selectedIdsArray = computed(() => Array.from(selectedAssetIds.value)) // Actions - function addToSelection(assetId: string) { + function addToSelection(assetId: AssetId) { selectedAssetIds.value.add(assetId) } - function removeFromSelection(assetId: string) { + function removeFromSelection(assetId: AssetId) { selectedAssetIds.value.delete(assetId) } - function setSelection(assetIds: string[]) { + function setSelection(assetIds: AssetId[]) { selectedAssetIds.value = new Set(assetIds) } @@ -31,7 +33,7 @@ export const useAssetSelectionStore = defineStore('assetSelection', () => { lastSelectedAssetId.value = null } - function toggleSelection(assetId: string) { + function toggleSelection(assetId: AssetId) { if (isSelected(assetId)) { removeFromSelection(assetId) } else { @@ -39,7 +41,7 @@ export const useAssetSelectionStore = defineStore('assetSelection', () => { } } - function isSelected(assetId: string): boolean { + function isSelected(assetId: AssetId): boolean { return selectedAssetIds.value.has(assetId) } @@ -47,7 +49,7 @@ export const useAssetSelectionStore = defineStore('assetSelection', () => { lastSelectedIndex.value = index } - function setLastSelectedAssetId(assetId: string | null) { + function setLastSelectedAssetId(assetId: AssetId | null) { lastSelectedAssetId.value = assetId } diff --git a/src/platform/assets/composables/useMediaAssetActions.test.ts b/src/platform/assets/composables/useMediaAssetActions.test.ts index 38c83f77ba..375a90abec 100644 --- a/src/platform/assets/composables/useMediaAssetActions.test.ts +++ b/src/platform/assets/composables/useMediaAssetActions.test.ts @@ -1,10 +1,15 @@ import { createTestingPinia } from '@pinia/testing' import { fromAny } from '@total-typescript/shoehorn' import { setActivePinia } from 'pinia' +import { useToast } from 'primevue/usetoast' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createApp, defineComponent, h, provide, ref } from 'vue' +import { useI18n } from 'vue-i18n' import type { LGraphNode } from '@/lib/litegraph/src/litegraph' +import { MediaAssetKey } from '@/platform/assets/schemas/mediaAssetSchema' import type { AssetItem } from '@/platform/assets/schemas/assetSchema' +import type { AssetMeta } from '@/platform/assets/schemas/mediaAssetSchema' import { useMediaAssetActions } from './useMediaAssetActions' // Use vi.hoisted to create a mutable reference for isCloud @@ -13,28 +18,33 @@ const mockIsCloud = vi.hoisted(() => ({ value: false })) // Track the filename passed to createAnnotatedPath const capturedFilenames = vi.hoisted(() => ({ values: [] as string[] })) +const mockDownloadFile = vi.hoisted(() => vi.fn()) +vi.mock('@/base/common/downloadUtil', () => ({ + downloadFile: mockDownloadFile +})) + vi.mock('@/platform/distribution/types', () => ({ get isCloud() { return mockIsCloud.value } })) -vi.mock('primevue/usetoast', () => ({ - useToast: () => ({ - add: vi.fn() - }) -})) +vi.mock('primevue/usetoast', () => { + const add = vi.fn() + return { + useToast: () => ({ add }) + } +}) -vi.mock('vue-i18n', () => ({ - useI18n: () => ({ - t: (key: string) => key - }), - createI18n: () => ({ - global: { - t: (key: string) => key - } - }) -})) +vi.mock('vue-i18n', () => { + const t = vi.fn((key: string) => key) + return { + useI18n: () => ({ t }), + createI18n: () => ({ + global: { t } + }) + } +}) const mockShowDialog = vi.hoisted(() => vi.fn()) vi.mock('@/stores/dialogStore', () => ({ @@ -168,13 +178,58 @@ function createMockAsset(overrides: Partial<AssetItem> = {}): AssetItem { } } +function createMockMediaAsset(overrides: Partial<AssetMeta> = {}): AssetMeta { + return { + ...createMockAsset(), + kind: 'image', + src: 'https://example.com/default-preview.png', + ...overrides + } +} + +function mountMediaActions(asset?: AssetMeta) { + let actions: ReturnType<typeof useMediaAssetActions> | undefined + + const ChildComponent = defineComponent({ + setup() { + actions = useMediaAssetActions() + return () => null + } + }) + + const HostComponent = defineComponent({ + setup() { + provide(MediaAssetKey, { + asset: ref(asset), + context: ref({ type: 'input' as const }), + isVideoPlaying: ref(false), + showVideoControls: ref(false) + }) + return () => h(ChildComponent) + } + }) + + const host = document.createElement('div') + const app = createApp(HostComponent) + app.mount(host) + + if (!actions) throw new Error('media asset actions not initialized') + + return { + actions, + unmount: () => app.unmount() + } +} + describe('useMediaAssetActions', () => { beforeEach(() => { - vi.resetModules() setActivePinia(createTestingPinia({ stubActions: false })) vi.clearAllMocks() capturedFilenames.values = [] mockIsCloud.value = false + mockGetOutputAssetMetadata.mockReset() + mockGetOutputAssetMetadata.mockReturnValue(null) + mockGetAssetType.mockReset() }) describe('addWorkflow', () => { @@ -275,7 +330,102 @@ describe('useMediaAssetActions', () => { }) }) - describe('downloadMultipleAssets - job_asset_name_filters', () => { + describe('downloadAssets', () => { + it('downloads the injected media asset when called without explicit assets', () => { + const mediaAsset = createMockMediaAsset({ + id: 'context-asset', + name: 'context-name.png', + display_name: 'Context image.png', + preview_url: 'https://example.com/context-preview.png' + }) + + const { actions, unmount } = mountMediaActions(mediaAsset) + actions.downloadAssets() + + expect(mockDownloadFile).toHaveBeenCalledOnce() + expect(mockDownloadFile).toHaveBeenCalledWith( + 'https://example.com/context-preview.png', + 'Context image.png' + ) + expect(mockCreateAssetExport).not.toHaveBeenCalled() + expect(mockTrackExport).not.toHaveBeenCalled() + + unmount() + }) + + it('does nothing when called without explicit assets and no media context asset', () => { + const { actions, unmount } = mountMediaActions() + actions.downloadAssets() + + expect(mockDownloadFile).not.toHaveBeenCalled() + expect(mockCreateAssetExport).not.toHaveBeenCalled() + expect(mockTrackExport).not.toHaveBeenCalled() + + unmount() + }) + + it('keeps single explicit assets on the direct download path in cloud', () => { + mockIsCloud.value = true + mockGetOutputAssetMetadata.mockReturnValue({ + jobId: 'job1', + outputCount: 1 + }) + + const asset = createMockAsset({ + id: 'single-output', + name: 'single-output.png', + preview_url: 'https://example.com/single-output.png', + tags: ['output'], + user_metadata: { jobId: 'job1', outputCount: 1 } + }) + + const actions = useMediaAssetActions() + actions.downloadAssets([asset]) + + expect(mockDownloadFile).toHaveBeenCalledOnce() + expect(mockDownloadFile).toHaveBeenCalledWith( + 'https://example.com/single-output.png', + 'single-output.png' + ) + expect(mockCreateAssetExport).not.toHaveBeenCalled() + expect(mockTrackExport).not.toHaveBeenCalled() + }) + + it('uses ZIP export for an injected single multi-output asset in cloud', async () => { + mockIsCloud.value = true + mockGetAssetType.mockReturnValue('output') + mockGetOutputAssetMetadata.mockReturnValue({ + jobId: 'job1', + outputCount: 3 + }) + + const mediaAsset = createMockMediaAsset({ + id: 'multi-output', + name: 'multi-output.png', + preview_url: 'https://example.com/multi-output.png', + tags: ['output'], + user_metadata: { jobId: 'job1', outputCount: 3 } + }) + + const { actions, unmount } = mountMediaActions(mediaAsset) + actions.downloadAssets() + + await vi.waitFor(() => { + expect(mockCreateAssetExport).toHaveBeenCalledTimes(1) + }) + + expect(mockDownloadFile).not.toHaveBeenCalled() + expect(mockCreateAssetExport).toHaveBeenCalledWith({ + job_ids: ['job1'], + naming_strategy: 'preserve' + }) + expect(mockTrackExport).toHaveBeenCalledWith('test-task-id') + + unmount() + }) + }) + + describe('downloadAssets - cloud zip filters', () => { beforeEach(() => { mockIsCloud.value = true mockCreateAssetExport.mockClear() @@ -301,15 +451,11 @@ describe('useMediaAssetActions', () => { }) } - it('should omit name filters for job-level selections (outputCount known)', async () => { - const assets = [ - createOutputAsset('a1', 'img1.png', 'job1', 3), - createOutputAsset('a2', 'img2.png', 'job1', 3), - createOutputAsset('a3', 'img3.png', 'job1', 3) - ] + it('should use preserve strategy when selection spans a single job', async () => { + const assets = [createOutputAsset('a1', 'img1.png', 'job1', 3)] const actions = useMediaAssetActions() - actions.downloadMultipleAssets(assets) + actions.downloadAssets(assets) await vi.waitFor(() => { expect(mockCreateAssetExport).toHaveBeenCalledTimes(1) @@ -318,6 +464,7 @@ describe('useMediaAssetActions', () => { const payload = mockCreateAssetExport.mock.calls[0][0] expect(payload.job_ids).toEqual(['job1']) expect(payload.job_asset_name_filters).toBeUndefined() + expect(payload.naming_strategy).toBe('preserve') }) it('should omit name filters for multiple job-level selections', async () => { @@ -326,7 +473,7 @@ describe('useMediaAssetActions', () => { const j2 = createOutputAsset('a3', 'out2.png', 'job2', 1) const actions = useMediaAssetActions() - actions.downloadMultipleAssets([j1a, j1b, j2]) + actions.downloadAssets([j1a, j1b, j2]) await vi.waitFor(() => { expect(mockCreateAssetExport).toHaveBeenCalledTimes(1) @@ -335,6 +482,7 @@ describe('useMediaAssetActions', () => { const payload = mockCreateAssetExport.mock.calls[0][0] expect(payload.job_ids).toEqual(['job1', 'job2']) expect(payload.job_asset_name_filters).toBeUndefined() + expect(payload.naming_strategy).toBe('group_by_job_time') }) it('should include name filters when outputCount is unknown', async () => { @@ -342,7 +490,7 @@ describe('useMediaAssetActions', () => { const asset2 = createOutputAsset('a2', 'img2.png', 'job2') const actions = useMediaAssetActions() - actions.downloadMultipleAssets([asset1, asset2]) + actions.downloadAssets([asset1, asset2]) await vi.waitFor(() => { expect(mockCreateAssetExport).toHaveBeenCalledTimes(1) @@ -353,6 +501,7 @@ describe('useMediaAssetActions', () => { job1: ['img1.png'], job2: ['img2.png'] }) + expect(payload.naming_strategy).toBe('group_by_job_time') }) it('should mix: omit filters for known outputCount, keep for unknown', async () => { @@ -361,7 +510,7 @@ describe('useMediaAssetActions', () => { const j2 = createOutputAsset('a3', 'img2.png', 'job2') const actions = useMediaAssetActions() - actions.downloadMultipleAssets([j1a, j1b, j2]) + actions.downloadAssets([j1a, j1b, j2]) await vi.waitFor(() => { expect(mockCreateAssetExport).toHaveBeenCalledTimes(1) @@ -372,6 +521,114 @@ describe('useMediaAssetActions', () => { expect(payload.job_asset_name_filters).toEqual({ job2: ['img2.png'] }) + expect(payload.naming_strategy).toBe('group_by_job_time') + }) + + it('should preserve multiple selected outputs from one job', async () => { + const asset1 = createOutputAsset('a1', 'img1.png', 'job1') + const asset2 = createOutputAsset('a2', 'img2.png', 'job1') + + const actions = useMediaAssetActions() + actions.downloadAssets([asset1, asset2]) + + await vi.waitFor(() => { + expect(mockCreateAssetExport).toHaveBeenCalledTimes(1) + }) + + const payload = mockCreateAssetExport.mock.calls[0][0] + expect(payload.job_ids).toEqual(['job1']) + expect(payload.job_asset_name_filters).toEqual({ + job1: ['img1.png', 'img2.png'] + }) + expect(payload.naming_strategy).toBe('preserve') + }) + }) + + describe('downloadAssets - export toast file count', () => { + beforeEach(() => { + mockIsCloud.value = true + mockCreateAssetExport.mockClear() + mockGetAssetType.mockReturnValue('output') + mockGetOutputAssetMetadata.mockImplementation( + (meta: Record<string, unknown> | undefined) => + meta && 'jobId' in meta ? meta : null + ) + }) + + function createOutputAsset( + id: string, + name: string, + jobId: string, + outputCount?: number + ): AssetItem { + return createMockAsset({ + id, + name, + tags: ['output'], + user_metadata: { jobId, nodeId: '1', subfolder: '', outputCount } + }) + } + + async function expectExportToastFileCount(count: number) { + await vi.waitFor(() => { + expect(mockCreateAssetExport).toHaveBeenCalledTimes(1) + }) + + const { add } = useToast() + await vi.waitFor(() => { + expect(add).toHaveBeenCalledWith( + expect.objectContaining({ + detail: 'mediaAsset.selection.exportStarted' + }) + ) + }) + + const { t } = useI18n() + expect(t).toHaveBeenCalledWith( + 'mediaAsset.selection.exportStarted', + { count }, + count + ) + } + + it('should report total file count, not job count, for multi-output jobs', async () => { + const j1 = createOutputAsset('a1', 'img1.png', 'job1', 2) + const j2 = createOutputAsset('a2', 'img2.png', 'job2', 4) + + const actions = useMediaAssetActions() + actions.downloadAssets([j1, j2]) + + await expectExportToastFileCount(6) + }) + + it('should treat assets without outputCount as a single file', async () => { + const a1 = createOutputAsset('a1', 'img1.png', 'job1') + const a2 = createOutputAsset('a2', 'img2.png', 'job2') + + const actions = useMediaAssetActions() + actions.downloadAssets([a1, a2]) + + await expectExportToastFileCount(2) + }) + + it('should mix multi-output and single-output assets correctly', async () => { + const j1 = createOutputAsset('a1', 'img1.png', 'job1', 3) + const a2 = createOutputAsset('a2', 'img2.png', 'job2') + + const actions = useMediaAssetActions() + actions.downloadAssets([j1, a2]) + + await expectExportToastFileCount(4) + }) + + it('should only count duplicate job-level output selections once', async () => { + const j1 = createOutputAsset('a1', 'img1.png', 'job1', 3) + const j1Duplicate = createOutputAsset('a2', 'img2.png', 'job1', 3) + + const actions = useMediaAssetActions() + actions.downloadAssets([j1, j1Duplicate]) + + await expectExportToastFileCount(3) }) }) diff --git a/src/platform/assets/composables/useMediaAssetActions.ts b/src/platform/assets/composables/useMediaAssetActions.ts index 7bdee89be9..3f9b8e6ab7 100644 --- a/src/platform/assets/composables/useMediaAssetActions.ts +++ b/src/platform/assets/composables/useMediaAssetActions.ts @@ -17,6 +17,7 @@ import { useDialogStore } from '@/stores/dialogStore' import { getAssetDisplayName } from '../utils/assetMetadataUtils' import { getAssetType } from '../utils/assetTypeUtil' import { getAssetUrl } from '../utils/assetUrlUtil' +import { getAssetOutputCount } from '../utils/outputAssetUtil' import { createAnnotatedPath } from '@/utils/createAnnotatedPath' import { detectNodeTypeFromFilename } from '@/utils/loaderNodeUtil' import { isResultItemType } from '@/utils/typeGuardUtil' @@ -64,52 +65,30 @@ export function useMediaAssetActions() { } } - const downloadAsset = (asset?: AssetItem) => { - const targetAsset = asset ?? mediaContext?.asset.value - if (!targetAsset) return - - try { - const filename = getAssetDisplayName(targetAsset) - // Prefer preview_url (already includes subfolder) with getAssetUrl as fallback - const downloadUrl = targetAsset.preview_url || getAssetUrl(targetAsset) - - downloadFile(downloadUrl, filename) - - toast.add({ - severity: 'success', - summary: t('g.success'), - detail: t('mediaAsset.selection.downloadsStarted', 1), - life: 2000 - }) - } catch (error) { - toast.add({ - severity: 'error', - summary: t('g.error'), - detail: t('g.failedToDownloadImage') - }) - } - } - /** - * Download multiple assets at once. - * In cloud mode with 2+ assets, creates a ZIP export via the backend. - * Falls back to individual downloads in OSS mode or for single assets. + * Download one or more assets. + * In cloud mode, creates a ZIP export via the backend when called with + * 2+ assets or with any asset whose job has `outputCount > 1`. + * Falls back to direct downloads in OSS mode and for single single-output + * assets. With no argument, uses the asset from `MediaAssetKey` context. */ - const downloadMultipleAssets = (assets: AssetItem[]) => { - if (!assets || assets.length === 0) return + const downloadAssets = (assets?: AssetItem[]) => { + const targetAssets = + assets ?? (mediaContext?.asset.value ? [mediaContext.asset.value] : []) + if (targetAssets.length === 0) return - const hasMultiOutputJobs = assets.some((a) => { + const hasMultiOutputJobs = targetAssets.some((a) => { const count = getOutputAssetMetadata(a.user_metadata)?.outputCount return typeof count === 'number' && count > 1 }) - if (isCloud && (assets.length > 1 || hasMultiOutputJobs)) { - void downloadMultipleAssetsAsZip(assets) + if (isCloud && (targetAssets.length > 1 || hasMultiOutputJobs)) { + void downloadAssetsAsZip(targetAssets) return } try { - assets.forEach((asset) => { + targetAssets.forEach((asset) => { const filename = getAssetDisplayName(asset) const downloadUrl = asset.preview_url || getAssetUrl(asset) downloadFile(downloadUrl, filename) @@ -118,7 +97,7 @@ export function useMediaAssetActions() { toast.add({ severity: 'success', summary: t('g.success'), - detail: t('mediaAsset.selection.downloadsStarted', assets.length), + detail: t('mediaAsset.selection.downloadsStarted', targetAssets.length), life: 2000 }) } catch (error) { @@ -131,13 +110,15 @@ export function useMediaAssetActions() { } } - async function downloadMultipleAssetsAsZip(assets: AssetItem[]) { + async function downloadAssetsAsZip(assets: AssetItem[]) { const assetExportStore = useAssetExportStore() try { const jobIds: string[] = [] const assetIds: string[] = [] const jobAssetNameFilters: Record<string, string[]> = {} + const countedOutputJobIds = new Set<string>() + let fileCount = 0 for (const asset of assets) { if (getAssetType(asset) === 'output') { @@ -149,6 +130,15 @@ export function useMediaAssetActions() { // Only add name filters when outputCount is unknown. // When outputCount is set, the asset is a job-level selection // from the gallery and the user wants all outputs for that job. + if (metadata?.outputCount != null) { + if (!countedOutputJobIds.has(jobId)) { + countedOutputJobIds.add(jobId) + fileCount += getAssetOutputCount(asset) + } + } else { + fileCount += 1 + } + if (metadata?.jobId && asset.name && metadata.outputCount == null) { if (!jobAssetNameFilters[metadata.jobId]) { jobAssetNameFilters[metadata.jobId] = [] @@ -159,16 +149,22 @@ export function useMediaAssetActions() { } } else { assetIds.push(asset.id) + fileCount += 1 } } + const spansMultipleJobs = jobIds.length > 1 + const namingStrategy = spansMultipleJobs + ? 'group_by_job_time' + : 'preserve' + const result = await assetService.createAssetExport({ ...(jobIds.length > 0 ? { job_ids: jobIds } : {}), ...(assetIds.length > 0 ? { asset_ids: assetIds } : {}), ...(Object.keys(jobAssetNameFilters).length > 0 ? { job_asset_name_filters: jobAssetNameFilters } : {}), - naming_strategy: 'preserve' + naming_strategy: namingStrategy }) assetExportStore.trackExport(result.task_id) @@ -176,7 +172,11 @@ export function useMediaAssetActions() { toast.add({ severity: 'info', summary: t('exportToast.exportStarted'), - detail: t('mediaAsset.selection.exportStarted', assets.length), + detail: t( + 'mediaAsset.selection.exportStarted', + { count: fileCount }, + fileCount + ), life: 3000 }) } catch (error) { @@ -715,8 +715,7 @@ export function useMediaAssetActions() { } return { - downloadAsset, - downloadMultipleAssets, + downloadAssets, deleteAssets, copyJobId, addWorkflow, diff --git a/src/platform/assets/schemas/assetSchema.ts b/src/platform/assets/schemas/assetSchema.ts index bba7472093..bd31113009 100644 --- a/src/platform/assets/schemas/assetSchema.ts +++ b/src/platform/assets/schemas/assetSchema.ts @@ -85,6 +85,15 @@ export const assetItemSchema = zAsset export const assetResponseSchema = zAssetResponse export const asyncUploadResponseSchema = zAsyncUploadResponse +/** + * Identifier for a single asset record. + * + * Backed by `AssetItem.id` which the API serialises as a string. This alias + * names that primitive at use sites (services, stores, composables) without + * changing structural typing. + */ +export type AssetId = string + // Export types derived from Zod schemas export type AssetItem = z.infer<typeof zAsset> export type AssetResponse = z.infer<typeof zAssetResponse> diff --git a/src/platform/assets/schemas/mediaAssetSchema.ts b/src/platform/assets/schemas/mediaAssetSchema.ts index acd909e001..75cf9e25b0 100644 --- a/src/platform/assets/schemas/mediaAssetSchema.ts +++ b/src/platform/assets/schemas/mediaAssetSchema.ts @@ -49,3 +49,5 @@ interface MediaAssetProviderValue { export const MediaAssetKey: InjectionKey<MediaAssetProviderValue> = Symbol('mediaAsset') + +export const MIME_ASSET_INFO = 'application/x-comfy-asset-info' diff --git a/src/platform/assets/services/assetService.test.ts b/src/platform/assets/services/assetService.test.ts index 2a8c525b07..e718cb3d72 100644 --- a/src/platform/assets/services/assetService.test.ts +++ b/src/platform/assets/services/assetService.test.ts @@ -1,6 +1,13 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { assetService } from '@/platform/assets/services/assetService' +import type { AssetItem } from '@/platform/assets/schemas/assetSchema' +import { + MISSING_TAG, + assetService, + isBlake3AssetHash, + toBlake3AssetHash +} from '@/platform/assets/services/assetService' +import { api } from '@/scripts/api' const mockDistributionState = vi.hoisted(() => ({ isCloud: false })) const mockSettingStoreGet = vi.hoisted(() => vi.fn(() => false)) @@ -40,6 +47,32 @@ vi.mock('@/i18n', () => ({ st: vi.fn((_key: string, fallback: string) => fallback) })) +const fetchApiMock = vi.mocked(api.fetchApi) + +const validBlake3Hash = + '1111111111111111111111111111111111111111111111111111111111111111' +const validBlake3AssetHash = `blake3:${validBlake3Hash}` + +function buildResponse( + body: unknown, + init: { ok?: boolean; status?: number } = {} +): Response { + return { + ok: init.ok ?? true, + status: init.status ?? 200, + json: vi.fn().mockResolvedValue(body) + } as unknown as Response +} + +function validAsset(overrides: Partial<AssetItem> = {}): AssetItem { + return { + id: 'asset-1', + name: 'model.safetensors', + tags: ['models'], + ...overrides + } +} + describe(assetService.shouldUseAssetBrowser, () => { beforeEach(() => { vi.clearAllMocks() @@ -104,3 +137,763 @@ describe(assetService.shouldUseAssetBrowser, () => { ).toBe(false) }) }) + +describe(assetService.getAssetMetadata, () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('throws a localized message when the response is not ok', async () => { + fetchApiMock.mockResolvedValueOnce( + buildResponse({ code: 'FILE_TOO_LARGE' }, { ok: false, status: 413 }) + ) + + await expect( + assetService.getAssetMetadata('https://example.com/model.safetensors') + ).rejects.toThrow('File too large') + }) + + it('throws a localized message when validation reports is_valid=false', async () => { + fetchApiMock.mockResolvedValueOnce( + buildResponse({ + content_length: 100, + final_url: 'https://example.com/model.safetensors', + validation: { + is_valid: false, + errors: [{ code: 'UNSAFE_VIRUS_SCAN', message: 'bad', field: 'file' }] + } + }) + ) + + await expect( + assetService.getAssetMetadata('https://example.com/model.safetensors') + ).rejects.toThrow('Unsafe virus scan') + }) + + it('encodes the URL in the query string', async () => { + fetchApiMock.mockResolvedValueOnce( + buildResponse({ + content_length: 1, + final_url: 'https://example.com/x' + }) + ) + + await assetService.getAssetMetadata('https://example.com/foo bar?x=1') + + expect(fetchApiMock).toHaveBeenCalledWith( + expect.stringContaining( + '/assets/remote-metadata?url=' + + encodeURIComponent('https://example.com/foo bar?x=1') + ) + ) + }) +}) + +describe(isBlake3AssetHash, () => { + it('accepts only prefixed 64-character blake3 hashes', () => { + expect(isBlake3AssetHash(validBlake3AssetHash)).toBe(true) + expect(isBlake3AssetHash('BLAKE3:' + validBlake3Hash.toUpperCase())).toBe( + true + ) + expect(isBlake3AssetHash('blake3:abc')).toBe(false) + expect(isBlake3AssetHash(validBlake3Hash)).toBe(false) + }) +}) + +describe(toBlake3AssetHash, () => { + it('normalizes 64-character blake3 hex values to asset hashes', () => { + expect(toBlake3AssetHash(validBlake3Hash)).toBe(validBlake3AssetHash) + expect(toBlake3AssetHash('abc')).toBeNull() + expect(toBlake3AssetHash(undefined)).toBeNull() + }) +}) + +describe(assetService.uploadAssetFromUrl, () => { + beforeEach(() => { + vi.clearAllMocks() + assetService.invalidateInputAssetsIncludingPublic() + }) + + it('does not invalidate cached input assets when the upload response is invalid', async () => { + const staleAssets = [validAsset({ id: 'stale-input', tags: ['input'] })] + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + fetchApiMock + .mockResolvedValueOnce(buildResponse({ assets: staleAssets })) + .mockResolvedValueOnce(buildResponse({ id: 'missing-name' })) + + await assetService.getInputAssetsIncludingPublic() + await expect( + assetService.uploadAssetFromUrl({ + url: 'https://example.com/input.png', + name: 'input.png', + tags: ['input'] + }) + ).rejects.toThrow('Failed to upload asset') + const cached = await assetService.getInputAssetsIncludingPublic() + + expect(cached).toEqual(staleAssets) + expect(fetchApiMock).toHaveBeenCalledTimes(2) + consoleSpy.mockRestore() + }) + + it('requires upload responses to include created_new', async () => { + const staleAssets = [validAsset({ id: 'stale-input', tags: ['input'] })] + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + fetchApiMock + .mockResolvedValueOnce(buildResponse({ assets: staleAssets })) + .mockResolvedValueOnce( + buildResponse(validAsset({ id: 'uploaded-input', tags: ['input'] })) + ) + + await assetService.getInputAssetsIncludingPublic() + await expect( + assetService.uploadAssetFromUrl({ + url: 'https://example.com/input.png', + name: 'input.png', + tags: ['input'] + }) + ).rejects.toThrow('Failed to upload asset') + const cached = await assetService.getInputAssetsIncludingPublic() + + expect(cached).toEqual(staleAssets) + expect(fetchApiMock).toHaveBeenCalledTimes(2) + consoleSpy.mockRestore() + }) + + it('returns validated upload responses with created_new', async () => { + const uploadedAsset = { + ...validAsset({ id: 'uploaded-input', tags: ['input'] }), + created_new: true + } + fetchApiMock.mockResolvedValueOnce(buildResponse(uploadedAsset)) + + await expect( + assetService.uploadAssetFromUrl({ + url: 'https://example.com/input.png', + name: 'input.png', + tags: ['input'] + }) + ).resolves.toEqual(uploadedAsset) + }) +}) + +describe(assetService.uploadAssetFromBase64, () => { + beforeEach(() => { + vi.clearAllMocks() + assetService.invalidateInputAssetsIncludingPublic() + }) + + it('throws before calling the network when data is not a data URL', async () => { + await expect( + assetService.uploadAssetFromBase64({ + data: 'not-a-data-url', + name: 'image.png' + }) + ).rejects.toThrow('Invalid data URL') + + expect(fetchApiMock).not.toHaveBeenCalled() + }) + + it('does not invalidate cached input assets when the upload response is invalid', async () => { + const staleAssets = [validAsset({ id: 'stale-input', tags: ['input'] })] + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const fetchSpy = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValueOnce(new Response('hello')) + fetchApiMock + .mockResolvedValueOnce(buildResponse({ assets: staleAssets })) + .mockResolvedValueOnce(buildResponse({ id: 'missing-name' })) + + await assetService.getInputAssetsIncludingPublic() + await expect( + assetService.uploadAssetFromBase64({ + data: 'data:text/plain;base64,aGVsbG8=', + name: 'input.txt', + tags: ['input'] + }) + ).rejects.toThrow('Failed to upload asset') + const cached = await assetService.getInputAssetsIncludingPublic() + + expect(cached).toEqual(staleAssets) + expect(fetchApiMock).toHaveBeenCalledTimes(2) + fetchSpy.mockRestore() + consoleSpy.mockRestore() + }) + + it('rejects upload responses with a non-boolean created_new', async () => { + const staleAssets = [validAsset({ id: 'stale-input', tags: ['input'] })] + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const fetchSpy = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValueOnce(new Response('hello')) + fetchApiMock + .mockResolvedValueOnce(buildResponse({ assets: staleAssets })) + .mockResolvedValueOnce( + buildResponse({ + ...validAsset({ id: 'uploaded-input', tags: ['input'] }), + created_new: 'true' + }) + ) + + await assetService.getInputAssetsIncludingPublic() + await expect( + assetService.uploadAssetFromBase64({ + data: 'data:text/plain;base64,aGVsbG8=', + name: 'input.txt', + tags: ['input'] + }) + ).rejects.toThrow('Failed to upload asset') + const cached = await assetService.getInputAssetsIncludingPublic() + + expect(cached).toEqual(staleAssets) + expect(fetchApiMock).toHaveBeenCalledTimes(2) + fetchSpy.mockRestore() + consoleSpy.mockRestore() + }) +}) + +describe(assetService.uploadAssetAsync, () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('returns an async result when the server responds 202', async () => { + fetchApiMock.mockResolvedValueOnce( + buildResponse( + { task_id: 'task-1', status: 'running' }, + { ok: true, status: 202 } + ) + ) + + const result = await assetService.uploadAssetAsync({ + source_url: 'https://example.com/model.safetensors' + }) + + expect(result).toEqual({ + type: 'async', + task: { task_id: 'task-1', status: 'running' } + }) + }) + + it('returns a sync result when the server responds 200', async () => { + fetchApiMock.mockResolvedValueOnce( + buildResponse(validAsset({ id: 'asset-2', name: 'sync.safetensors' })) + ) + + const result = await assetService.uploadAssetAsync({ + source_url: 'https://example.com/model.safetensors' + }) + + expect(result).toEqual({ + type: 'sync', + asset: expect.objectContaining({ id: 'asset-2' }) + }) + }) +}) + +describe(assetService.deleteAsset, () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('throws an error containing the status code when the response is not ok', async () => { + fetchApiMock.mockResolvedValueOnce( + buildResponse(null, { ok: false, status: 503 }) + ) + + await expect(assetService.deleteAsset('asset-1')).rejects.toThrow(/503/) + }) + + it('issues a DELETE to the asset endpoint when the response is ok', async () => { + fetchApiMock.mockResolvedValueOnce(buildResponse(null)) + + await assetService.deleteAsset('asset-1') + + expect(fetchApiMock).toHaveBeenCalledWith( + '/assets/asset-1', + expect.objectContaining({ method: 'DELETE' }) + ) + }) +}) + +describe(assetService.getAssetModelFolders, () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('filters out missing-tagged assets and blacklisted directories, returning alphabetical unique folders without include_public', async () => { + fetchApiMock.mockResolvedValueOnce( + buildResponse({ + assets: [ + validAsset({ id: 'a', tags: ['models', 'loras'] }), + validAsset({ id: 'b', tags: ['models', 'checkpoints'] }), + validAsset({ id: 'c', tags: ['models', 'configs'] }), + validAsset({ id: 'd', tags: ['models', 'missing', 'controlnet'] }), + validAsset({ id: 'e', tags: ['models', 'loras'] }) + ] + }) + ) + + const folders = await assetService.getAssetModelFolders() + + expect(folders).toEqual([ + { name: 'checkpoints', folders: [] }, + { name: 'loras', folders: [] } + ]) + + const requestedUrl = fetchApiMock.mock.calls[0]?.[0] as string + const params = new URL(requestedUrl, 'http://localhost').searchParams + expect(params.has('include_public')).toBe(false) + }) +}) + +describe(assetService.updateAsset, () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('throws when the response body fails schema validation', async () => { + fetchApiMock.mockResolvedValueOnce( + buildResponse({ name: 'no-id-field.safetensors' }) + ) + + await expect( + assetService.updateAsset('asset-1', { name: 'renamed.safetensors' }) + ).rejects.toThrow(/Invalid response/) + }) + + it('PUTs the JSON payload and returns the parsed asset', async () => { + fetchApiMock.mockResolvedValueOnce( + buildResponse(validAsset({ id: 'asset-1', name: 'renamed.safetensors' })) + ) + + const result = await assetService.updateAsset('asset-1', { + name: 'renamed.safetensors' + }) + + expect(result).toEqual( + expect.objectContaining({ id: 'asset-1', name: 'renamed.safetensors' }) + ) + expect(fetchApiMock).toHaveBeenCalledWith( + '/assets/asset-1', + expect.objectContaining({ + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'renamed.safetensors' }) + }) + ) + }) +}) + +describe(assetService.getAssetsByTag, () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('forwards include_public=true by default and excludes missing-tagged assets', async () => { + fetchApiMock.mockResolvedValueOnce( + buildResponse({ + assets: [ + validAsset({ id: 'visible', tags: ['input'] }), + validAsset({ id: 'hidden', tags: ['input', 'missing'] }) + ] + }) + ) + + const assets = await assetService.getAssetsByTag('input') + + expect(assets.map((a) => a.id)).toEqual(['visible']) + + const requestedUrl = fetchApiMock.mock.calls[0]?.[0] as string + const params = new URL(requestedUrl, 'http://localhost').searchParams + expect(params.get('include_public')).toBe('true') + }) +}) + +describe(assetService.getAllAssetsByTag, () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('paginates tagged asset requests with include_public=true', async () => { + fetchApiMock + .mockResolvedValueOnce( + buildResponse({ + assets: [ + validAsset({ id: 'a', tags: ['input'] }), + validAsset({ id: 'b', tags: ['input'] }) + ] + }) + ) + .mockResolvedValueOnce( + buildResponse({ + assets: [validAsset({ id: 'c', tags: ['input'] })] + }) + ) + + const assets = await assetService.getAllAssetsByTag('input', true, { + limit: 2 + }) + + expect(assets.map((a) => a.id)).toEqual(['a', 'b', 'c']) + + const firstUrl = fetchApiMock.mock.calls[0]?.[0] as string + const firstParams = new URL(firstUrl, 'http://localhost').searchParams + expect(firstParams.get('include_public')).toBe('true') + expect(firstParams.get('limit')).toBe('2') + expect(firstParams.has('offset')).toBe(false) + + const secondUrl = fetchApiMock.mock.calls[1]?.[0] as string + const secondParams = new URL(secondUrl, 'http://localhost').searchParams + expect(secondParams.get('include_public')).toBe('true') + expect(secondParams.get('limit')).toBe('2') + expect(secondParams.get('offset')).toBe('2') + }) + + it('paginates from raw response size before filtering missing-tagged assets', async () => { + fetchApiMock + .mockResolvedValueOnce( + buildResponse({ + assets: [ + validAsset({ id: 'visible', tags: ['input'] }), + validAsset({ id: 'hidden', tags: ['input', MISSING_TAG] }) + ] + }) + ) + .mockResolvedValueOnce( + buildResponse({ + assets: [validAsset({ id: 'later-public', tags: ['input'] })] + }) + ) + + const assets = await assetService.getAllAssetsByTag('input', true, { + limit: 2 + }) + + expect(assets.map((a) => a.id)).toEqual(['visible', 'later-public']) + expect(fetchApiMock).toHaveBeenCalledTimes(2) + + const secondUrl = fetchApiMock.mock.calls[1]?.[0] + if (typeof secondUrl !== 'string') { + throw new Error('Expected a second asset request URL') + } + const secondParams = new URL(secondUrl, 'http://localhost').searchParams + expect(secondParams.get('offset')).toBe('2') + }) + + it('honors has_more when walking tagged asset pages', async () => { + fetchApiMock + .mockResolvedValueOnce( + buildResponse({ + assets: [ + validAsset({ id: 'first', tags: ['input'] }), + validAsset({ id: 'second', tags: ['input'] }) + ], + has_more: true + }) + ) + .mockResolvedValueOnce( + buildResponse({ + assets: [validAsset({ id: 'later-public', tags: ['input'] })], + has_more: false + }) + ) + + const assets = await assetService.getAllAssetsByTag('input', true, { + limit: 3 + }) + + expect(assets.map((a) => a.id)).toEqual(['first', 'second', 'later-public']) + expect(fetchApiMock).toHaveBeenCalledTimes(2) + + const secondUrl = fetchApiMock.mock.calls[1]?.[0] + if (typeof secondUrl !== 'string') { + throw new Error('Expected a second asset request URL') + } + const secondParams = new URL(secondUrl, 'http://localhost').searchParams + expect(secondParams.get('offset')).toBe('2') + }) + + it('passes abort signals through paginated requests', async () => { + const controller = new AbortController() + fetchApiMock.mockResolvedValueOnce( + buildResponse({ + assets: [validAsset({ id: 'a', tags: ['input'] })] + }) + ) + + await assetService.getAllAssetsByTag('input', true, { + limit: 2, + signal: controller.signal + }) + + expect(fetchApiMock).toHaveBeenCalledWith(expect.any(String), { + signal: controller.signal + }) + }) + + it('stops pagination when aborted between pages', async () => { + const controller = new AbortController() + fetchApiMock.mockImplementationOnce(async () => { + controller.abort() + return buildResponse({ + assets: [ + validAsset({ id: 'a', tags: ['input'] }), + validAsset({ id: 'b', tags: ['input'] }) + ] + }) + }) + + await expect( + assetService.getAllAssetsByTag('input', true, { + limit: 2, + signal: controller.signal + }) + ).rejects.toMatchObject({ name: 'AbortError' }) + + expect(fetchApiMock).toHaveBeenCalledOnce() + }) +}) + +describe(assetService.getInputAssetsIncludingPublic, () => { + beforeEach(() => { + vi.clearAllMocks() + assetService.invalidateInputAssetsIncludingPublic() + }) + + it('loads input assets with public assets included and reuses the cache', async () => { + const assets = [ + validAsset({ id: 'user-input', tags: ['input'] }), + validAsset({ id: 'public-input', tags: ['input'], is_immutable: true }) + ] + fetchApiMock.mockResolvedValueOnce(buildResponse({ assets })) + + const first = await assetService.getInputAssetsIncludingPublic() + const second = await assetService.getInputAssetsIncludingPublic() + + expect(first).toEqual(assets) + expect(second).toBe(first) + expect(fetchApiMock).toHaveBeenCalledOnce() + + const requestedUrl = fetchApiMock.mock.calls[0]?.[0] as string + const params = new URL(requestedUrl, 'http://localhost').searchParams + expect(params.get('include_public')).toBe('true') + expect(params.get('limit')).toBe('500') + }) + + it('fetches fresh input assets after explicit invalidation', async () => { + const staleAssets = [validAsset({ id: 'stale-input', tags: ['input'] })] + const freshAssets = [validAsset({ id: 'fresh-input', tags: ['input'] })] + fetchApiMock + .mockResolvedValueOnce(buildResponse({ assets: staleAssets })) + .mockResolvedValueOnce(buildResponse({ assets: freshAssets })) + + await assetService.getInputAssetsIncludingPublic() + assetService.invalidateInputAssetsIncludingPublic() + const refreshed = await assetService.getInputAssetsIncludingPublic() + + expect(refreshed).toEqual(freshAssets) + expect(fetchApiMock).toHaveBeenCalledTimes(2) + }) + + it('does not let one caller abort the shared input asset load for other callers', async () => { + const firstController = new AbortController() + const secondController = new AbortController() + const assets = [validAsset({ id: 'public-input', tags: ['input'] })] + let resolveResponse!: (response: Response) => void + let serviceSignal: AbortSignal | undefined + fetchApiMock.mockImplementationOnce(async (_url, options) => { + serviceSignal = options?.signal ?? undefined + return await new Promise<Response>((resolve) => { + resolveResponse = resolve + }) + }) + + const first = assetService.getInputAssetsIncludingPublic( + firstController.signal + ) + const second = assetService.getInputAssetsIncludingPublic( + secondController.signal + ) + firstController.abort() + + await expect(first).rejects.toMatchObject({ name: 'AbortError' }) + expect(serviceSignal).toBeUndefined() + + resolveResponse(buildResponse({ assets })) + + await expect(second).resolves.toEqual(assets) + expect(fetchApiMock).toHaveBeenCalledOnce() + }) + + it('keeps the shared input asset load alive after all callers abort', async () => { + const firstController = new AbortController() + const secondController = new AbortController() + const assets = [validAsset({ id: 'public-input', tags: ['input'] })] + let resolveResponse!: (response: Response) => void + fetchApiMock.mockImplementationOnce( + async () => + await new Promise<Response>((resolve) => { + resolveResponse = resolve + }) + ) + + const first = assetService.getInputAssetsIncludingPublic( + firstController.signal + ) + const second = assetService.getInputAssetsIncludingPublic( + secondController.signal + ) + firstController.abort() + secondController.abort() + + await expect(first).rejects.toMatchObject({ name: 'AbortError' }) + await expect(second).rejects.toMatchObject({ name: 'AbortError' }) + + resolveResponse(buildResponse({ assets })) + await Promise.resolve() + + await expect(assetService.getInputAssetsIncludingPublic()).resolves.toEqual( + assets + ) + expect(fetchApiMock).toHaveBeenCalledOnce() + }) + + it('does not abort in-flight input asset loads when invalidated', async () => { + const assets = [validAsset({ id: 'stale-input', tags: ['input'] })] + const freshAssets = [validAsset({ id: 'fresh-input', tags: ['input'] })] + let resolveResponse!: (response: Response) => void + fetchApiMock + .mockImplementationOnce( + async () => + await new Promise<Response>((resolve) => { + resolveResponse = resolve + }) + ) + .mockResolvedValueOnce(buildResponse({ assets: freshAssets })) + + const inFlight = assetService.getInputAssetsIncludingPublic() + assetService.invalidateInputAssetsIncludingPublic() + + resolveResponse(buildResponse({ assets })) + + await expect(inFlight).resolves.toEqual(assets) + await expect(assetService.getInputAssetsIncludingPublic()).resolves.toEqual( + freshAssets + ) + expect(fetchApiMock).toHaveBeenCalledTimes(2) + }) + + it('invalidates cached input assets after deleting an asset', async () => { + const staleAssets = [validAsset({ id: 'stale-input', tags: ['input'] })] + const freshAssets = [validAsset({ id: 'fresh-input', tags: ['input'] })] + fetchApiMock + .mockResolvedValueOnce(buildResponse({ assets: staleAssets })) + .mockResolvedValueOnce(buildResponse(null)) + .mockResolvedValueOnce(buildResponse({ assets: freshAssets })) + + await assetService.getInputAssetsIncludingPublic() + await assetService.deleteAsset('stale-input') + const refreshed = await assetService.getInputAssetsIncludingPublic() + + expect(refreshed).toEqual(freshAssets) + expect(fetchApiMock).toHaveBeenCalledTimes(3) + expect(fetchApiMock.mock.calls[1]).toEqual([ + '/assets/stale-input', + expect.objectContaining({ method: 'DELETE' }) + ]) + }) + + it('invalidates cached input assets after an input asset upload', async () => { + const staleAssets = [validAsset({ id: 'stale-input', tags: ['input'] })] + const uploadedAsset = validAsset({ id: 'uploaded-input', tags: ['input'] }) + const freshAssets = [uploadedAsset] + fetchApiMock + .mockResolvedValueOnce(buildResponse({ assets: staleAssets })) + .mockResolvedValueOnce(buildResponse(uploadedAsset)) + .mockResolvedValueOnce(buildResponse({ assets: freshAssets })) + + await assetService.getInputAssetsIncludingPublic() + await assetService.uploadAssetAsync({ + source_url: 'https://example.com/input.png', + tags: ['input'] + }) + const refreshed = await assetService.getInputAssetsIncludingPublic() + + expect(refreshed).toEqual(freshAssets) + expect(fetchApiMock).toHaveBeenCalledTimes(3) + }) + + it('does not invalidate cached input assets for pending async input uploads', async () => { + const staleAssets = [validAsset({ id: 'stale-input', tags: ['input'] })] + fetchApiMock + .mockResolvedValueOnce(buildResponse({ assets: staleAssets })) + .mockResolvedValueOnce( + buildResponse( + { task_id: 'task-1', status: 'running' }, + { ok: true, status: 202 } + ) + ) + + await assetService.getInputAssetsIncludingPublic() + await assetService.uploadAssetAsync({ + source_url: 'https://example.com/input.png', + tags: ['input'] + }) + const cached = await assetService.getInputAssetsIncludingPublic() + + expect(cached).toEqual(staleAssets) + expect(fetchApiMock).toHaveBeenCalledTimes(2) + }) + + it('does not invalidate cached input assets for non-input uploads', async () => { + const staleAssets = [validAsset({ id: 'stale-input', tags: ['input'] })] + fetchApiMock + .mockResolvedValueOnce(buildResponse({ assets: staleAssets })) + .mockResolvedValueOnce(buildResponse(validAsset({ tags: ['models'] }))) + + await assetService.getInputAssetsIncludingPublic() + await assetService.uploadAssetAsync({ + source_url: 'https://example.com/model.safetensors', + tags: ['models'] + }) + const cached = await assetService.getInputAssetsIncludingPublic() + + expect(cached).toEqual(staleAssets) + expect(fetchApiMock).toHaveBeenCalledTimes(2) + }) +}) + +describe(assetService.checkAssetHash, () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it.each([ + [200, 'exists'], + [404, 'missing'], + [400, 'invalid'] + ] as const)('maps %s responses to %s', async (status, expected) => { + const hash = + 'blake3:1111111111111111111111111111111111111111111111111111111111111111' + fetchApiMock.mockResolvedValueOnce(buildResponse(null, { status })) + + await expect(assetService.checkAssetHash(hash)).resolves.toBe(expected) + + expect(fetchApiMock).toHaveBeenCalledWith( + `/assets/hash/${encodeURIComponent(hash)}`, + { + method: 'HEAD', + signal: undefined + } + ) + }) + + it('throws for unexpected responses', async () => { + fetchApiMock.mockResolvedValueOnce(buildResponse(null, { status: 500 })) + + await expect(assetService.checkAssetHash('blake3:abc')).rejects.toThrow( + 'Unexpected asset hash check status: 500' + ) + }) +}) diff --git a/src/platform/assets/services/assetService.ts b/src/platform/assets/services/assetService.ts index d85a44aec3..65132bace7 100644 --- a/src/platform/assets/services/assetService.ts +++ b/src/platform/assets/services/assetService.ts @@ -1,4 +1,5 @@ import { fromZodError } from 'zod-validation-error' +import { z } from 'zod' import { st } from '@/i18n' @@ -9,6 +10,7 @@ import { tagsOperationResultSchema } from '@/platform/assets/schemas/assetSchema' import type { + AssetId, AssetItem, AssetMetadata, AssetResponse, @@ -28,17 +30,22 @@ export interface PaginationOptions { offset?: number } +interface AssetPaginationOptions extends PaginationOptions { + signal?: AbortSignal +} + interface AssetRequestOptions extends PaginationOptions { includeTags: string[] includePublic?: boolean + signal?: AbortSignal } interface AssetExportOptions { job_ids?: string[] - asset_ids?: string[] + asset_ids?: AssetId[] naming_strategy?: | 'group_by_job_id' - | 'prepend_job_id' + | 'group_by_job_time' | 'preserve' | 'asset_id' job_asset_name_filters?: Record<string, string[]> @@ -169,10 +176,61 @@ const ASSETS_DOWNLOAD_ENDPOINT = '/assets/download' const ASSETS_EXPORT_ENDPOINT = '/assets/export' const EXPERIMENTAL_WARNING = `EXPERIMENTAL: If you are seeing this please make sure "Comfy.Assets.UseAssetAPI" is set to "false" in your ComfyUI Settings.\n` const DEFAULT_LIMIT = 500 +const INPUT_ASSETS_WITH_PUBLIC_LIMIT = 500 export const MODELS_TAG = 'models' +/** Asset tag used by the backend for placeholder records that are not installed. */ export const MISSING_TAG = 'missing' +/** Result of a HEAD lookup against an exact asset hash. */ +export type AssetHashStatus = 'exists' | 'missing' | 'invalid' + +const BLAKE3_ASSET_HASH_PATTERN = /^blake3:[0-9a-f]{64}$/i +const BLAKE3_HEX_PATTERN = /^[0-9a-f]{64}$/i +const uploadedAssetResponseSchema = assetItemSchema.extend({ + created_new: z.boolean() +}) + +/** Returns true for a prefixed BLAKE3 asset hash: `blake3:<64 hex>`. */ +export function isBlake3AssetHash(value: string): boolean { + return BLAKE3_ASSET_HASH_PATTERN.test(value) +} + +/** Converts a raw 64-character BLAKE3 hex digest into an asset hash. */ +export function toBlake3AssetHash(hash: string | undefined): string | null { + if (!hash || !BLAKE3_HEX_PATTERN.test(hash)) return null + return `blake3:${hash}` +} + +function createAbortError(): DOMException { + return new DOMException('Aborted', 'AbortError') +} + +function throwIfAborted(signal?: AbortSignal): void { + if (signal?.aborted) throw createAbortError() +} + +async function withCallerAbort<T>( + promise: Promise<T>, + signal?: AbortSignal +): Promise<T> { + throwIfAborted(signal) + if (!signal) return await promise + + let removeAbortListener = () => {} + const abortPromise = new Promise<never>((_, reject) => { + const onAbort = () => reject(createAbortError()) + signal.addEventListener('abort', onAbort, { once: true }) + removeAbortListener = () => signal.removeEventListener('abort', onAbort) + }) + + try { + return await Promise.race([promise, abortPromise]) + } finally { + removeAbortListener() + } +} + /** * Validates asset response data using Zod schema */ @@ -186,11 +244,43 @@ function validateAssetResponse(data: unknown): AssetResponse { ) } +function validateUploadedAssetResponse( + data: unknown +): AssetItem & { created_new: boolean } { + const result = uploadedAssetResponseSchema.safeParse(data) + if (result.success) { + return result.data + } + + console.error('Invalid asset upload response:', fromZodError(result.error)) + throw new Error( + st( + 'assetBrowser.errorUploadFailed', + 'Failed to upload asset. Please try again.' + ) + ) +} + /** * Private service for asset-related network requests * Not exposed globally - used internally by ComfyApi */ function createAssetService() { + let inputAssetsIncludingPublic: AssetItem[] | null = null + let inputAssetsIncludingPublicRequestId = 0 + let pendingInputAssetsIncludingPublic: Promise<AssetItem[]> | null = null + + /** Invalidates the cached public-inclusive input assets without aborting in-flight readers. */ + function invalidateInputAssetsIncludingPublic(): void { + inputAssetsIncludingPublicRequestId++ + pendingInputAssetsIncludingPublic = null + inputAssetsIncludingPublic = null + } + + function invalidateInputAssetsCacheIfNeeded(tags?: string[]): void { + if (tags?.includes('input')) invalidateInputAssetsIncludingPublic() + } + /** * Handles API response with consistent error handling and Zod validation */ @@ -202,7 +292,8 @@ function createAssetService() { includeTags, limit = DEFAULT_LIMIT, offset, - includePublic + includePublic, + signal } = options const queryParams = new URLSearchParams({ include_tags: includeTags.join(','), @@ -216,7 +307,9 @@ function createAssetService() { } const url = `${ASSETS_ENDPOINT}?${queryParams.toString()}` - const res = await api.fetchApi(url) + const res = signal + ? await api.fetchApi(url, { signal }) + : await api.fetchApi(url) if (!res.ok) { throw new Error( `${EXPERIMENTAL_WARNING}Unable to load ${context}: Server returned ${res.status}. Please try again.` @@ -371,7 +464,7 @@ function createAssetService() { * @param id - The asset ID * @returns Promise<AssetItem> - Complete asset object with user_metadata */ - async function getAssetDetails(id: string): Promise<AssetItem> { + async function getAssetDetails(id: AssetId): Promise<AssetItem> { const res = await api.fetchApi(`${ASSETS_ENDPOINT}/${id}`) if (!res.ok) { throw new Error( @@ -402,15 +495,16 @@ function createAssetService() { * @param options - Pagination options * @param options.limit - Maximum number of assets to return (default: 500) * @param options.offset - Number of assets to skip (default: 0) + * @param options.signal - Optional abort signal for cancelling the request * @returns Promise<AssetItem[]> - Full asset objects filtered by tag, excluding missing assets */ async function getAssetsByTag( tag: string, includePublic: boolean = true, - { limit = DEFAULT_LIMIT, offset = 0 }: PaginationOptions = {} + { limit = DEFAULT_LIMIT, offset = 0, signal }: AssetPaginationOptions = {} ): Promise<AssetItem[]> { const data = await handleAssetRequest( - { includeTags: [tag], limit, offset, includePublic }, + { includeTags: [tag], limit, offset, includePublic, signal }, `assets for tag ${tag}` ) @@ -419,6 +513,116 @@ function createAssetService() { ) } + /** + * Gets every asset for a tag by walking paginated asset API responses. + * + * @param tag - The tag to filter by (e.g., 'models', 'input') + * @param includePublic - Whether to include public assets (default: true) + * @param options - Pagination options + * @param options.limit - Page size for each request (default: 500) + * @param options.signal - Optional abort signal for cancelling requests + * @returns Promise<AssetItem[]> - Full asset objects filtered by tag + */ + async function getAllAssetsByTag( + tag: string, + includePublic: boolean = true, + { limit = DEFAULT_LIMIT, signal }: AssetPaginationOptions = {} + ): Promise<AssetItem[]> { + const assets: AssetItem[] = [] + const pageSize = limit > 0 ? limit : DEFAULT_LIMIT + let offset = 0 + + while (true) { + if (signal?.aborted) throw createAbortError() + + const data = await handleAssetRequest( + { + includeTags: [tag], + limit: pageSize, + offset, + includePublic, + signal + }, + `assets for tag ${tag}` + ) + const batch = data.assets ?? [] + assets.push(...batch.filter((asset) => !asset.tags.includes(MISSING_TAG))) + + const noMoreFromServer = data.has_more === false + const inferredLastPage = + data.has_more === undefined && batch.length < pageSize + if (batch.length === 0 || noMoreFromServer || inferredLastPage) { + return assets + } + + offset += batch.length + } + } + + function startInputAssetsIncludingPublicRequest(): Promise<AssetItem[]> { + const requestId = ++inputAssetsIncludingPublicRequestId + + pendingInputAssetsIncludingPublic = getAllAssetsByTag('input', true, { + limit: INPUT_ASSETS_WITH_PUBLIC_LIMIT + }) + .then((assets) => { + if (requestId === inputAssetsIncludingPublicRequestId) { + inputAssetsIncludingPublic = assets + } + return assets + }) + .finally(() => { + if (requestId === inputAssetsIncludingPublicRequestId) { + pendingInputAssetsIncludingPublic = null + } + }) + + void pendingInputAssetsIncludingPublic.catch(() => {}) + return pendingInputAssetsIncludingPublic + } + + /** + * Gets cached input assets including public assets for missing media checks. + * Caller aborts cancel only that caller; shared fetches are invalidated + * through invalidateInputAssetsIncludingPublic(). + */ + async function getInputAssetsIncludingPublic( + signal?: AbortSignal + ): Promise<AssetItem[]> { + throwIfAborted(signal) + if (inputAssetsIncludingPublic) return inputAssetsIncludingPublic + + const request = + pendingInputAssetsIncludingPublic ?? + startInputAssetsIncludingPublicRequest() + return await withCallerAbort(request, signal) + } + + /** + * Checks whether an asset exists for an exact asset hash. + * + * Uses the HEAD /assets/hash/{hash} endpoint and maps status-only responses: + * 200 -> exists, 404 -> missing, and 400 -> invalid hash format. + */ + async function checkAssetHash( + assetHash: string, + signal?: AbortSignal + ): Promise<AssetHashStatus> { + const response = await api.fetchApi( + `${ASSETS_ENDPOINT}/hash/${encodeURIComponent(assetHash)}`, + { + method: 'HEAD', + signal + } + ) + + if (response.status === 200) return 'exists' + if (response.status === 404) return 'missing' + if (response.status === 400) return 'invalid' + + throw new Error(`Unexpected asset hash check status: ${response.status}`) + } + /** * Deletes an asset by ID * Only available in cloud environment @@ -427,7 +631,7 @@ function createAssetService() { * @returns Promise<void> * @throws Error if deletion fails */ - async function deleteAsset(id: string): Promise<void> { + async function deleteAsset(id: AssetId): Promise<void> { const res = await api.fetchApi(`${ASSETS_ENDPOINT}/${id}`, { method: 'DELETE' }) @@ -437,6 +641,8 @@ function createAssetService() { `Unable to delete asset ${id}: Server returned ${res.status}` ) } + + invalidateInputAssetsIncludingPublic() } /** @@ -449,7 +655,7 @@ function createAssetService() { * @throws Error if update fails */ async function updateAsset( - id: string, + id: AssetId, newData: AssetUpdatePayload ): Promise<AssetItem> { const res = await api.fetchApi(`${ASSETS_ENDPOINT}/${id}`, { @@ -544,7 +750,9 @@ function createAssetService() { ) } - return await res.json() + const asset = validateUploadedAssetResponse(await res.json()) + invalidateInputAssetsCacheIfNeeded(params.tags) + return asset } /** @@ -597,7 +805,9 @@ function createAssetService() { ) } - return await res.json() + const asset = validateUploadedAssetResponse(await res.json()) + invalidateInputAssetsCacheIfNeeded(params.tags) + return asset } /** @@ -627,6 +837,7 @@ function createAssetService() { if (!parseResult.success) { throw fromZodError(parseResult.error) } + invalidateInputAssetsIncludingPublic() return parseResult.data } @@ -657,6 +868,7 @@ function createAssetService() { if (!parseResult.success) { throw fromZodError(parseResult.error) } + invalidateInputAssetsIncludingPublic() return parseResult.data } @@ -708,6 +920,13 @@ function createAssetService() { ) ) } + if ( + params.tags?.includes('input') && + result.data.type === 'async' && + result.data.task.status === 'completed' + ) { + invalidateInputAssetsIncludingPublic() + } return result.data } @@ -723,6 +942,7 @@ function createAssetService() { ) ) } + invalidateInputAssetsCacheIfNeeded(params.tags) return result.data } @@ -763,6 +983,10 @@ function createAssetService() { getAssetsForNodeType, getAssetDetails, getAssetsByTag, + getAllAssetsByTag, + getInputAssetsIncludingPublic, + invalidateInputAssetsIncludingPublic, + checkAssetHash, deleteAsset, updateAsset, addAssetTags, diff --git a/src/platform/assets/utils/outputAssetUtil.ts b/src/platform/assets/utils/outputAssetUtil.ts index 941b96fd7f..d104252bff 100644 --- a/src/platform/assets/utils/outputAssetUtil.ts +++ b/src/platform/assets/utils/outputAssetUtil.ts @@ -38,6 +38,19 @@ function shouldLoadFullOutputs( ) } +export function getAssetOutputCount( + asset: Pick<AssetItem, 'user_metadata'> +): number { + const count = asset.user_metadata?.outputCount + return typeof count === 'number' && count > 0 ? count : 1 +} + +export function getTotalAssetOutputCount( + assets: Pick<AssetItem, 'user_metadata'>[] +): number { + return assets.reduce((sum, asset) => sum + getAssetOutputCount(asset), 0) +} + export function getOutputKey({ nodeId, subfolder, diff --git a/src/platform/cloud/onboarding/CloudSurveyView.vue b/src/platform/cloud/onboarding/CloudSurveyView.vue index 2ec132c1c0..56b50f33d3 100644 --- a/src/platform/cloud/onboarding/CloudSurveyView.vue +++ b/src/platform/cloud/onboarding/CloudSurveyView.vue @@ -1,251 +1,40 @@ <template> - <div> - <Stepper - value="1" - class="flex h-[638px] max-h-[80vh] w-[320px] max-w-[90vw] flex-col" - > - <ProgressBar - :value="progressPercent" - :show-value="false" - class="mb-8 h-2" - /> - - <StepPanels class="flex flex-1 flex-col p-0"> - <StepPanel - v-slot="{ activateCallback }" - value="1" - class="flex min-h-full flex-1 flex-col justify-between bg-transparent" - > - <div> - <label class="mb-8 block text-lg font-medium">{{ - t('cloudSurvey_steps_familiarity') - }}</label> - <div class="flex flex-col gap-6"> - <div - v-for="opt in familiarityOptions" - :key="opt.value" - class="flex items-center gap-3" - > - <RadioButton - v-model="surveyData.familiarity" - :input-id="`fam-${opt.value}`" - name="familiarity" - :value="opt.value" - /> - <label - :for="`fam-${opt.value}`" - class="cursor-pointer text-sm" - >{{ opt.label }}</label - > - </div> - </div> - </div> - - <div class="flex justify-between pt-4"> - <span /> - <Button - :disabled="!validStep1" - class="h-10 w-full border-none text-white" - @click="goTo(2, activateCallback)" - > - {{ $t('g.next') }} - </Button> - </div> - </StepPanel> - - <StepPanel - v-slot="{ activateCallback }" - value="2" - class="flex min-h-full flex-1 flex-col justify-between bg-transparent" - > - <div> - <label class="mb-8 block text-lg font-medium">{{ - t('cloudSurvey_steps_purpose') - }}</label> - <div class="flex flex-col gap-6"> - <div - v-for="opt in purposeOptions" - :key="opt.value" - class="flex items-center gap-3" - > - <RadioButton - v-model="surveyData.useCase" - :input-id="`purpose-${opt.value}`" - name="purpose" - :value="opt.value" - /> - <label - :for="`purpose-${opt.value}`" - class="cursor-pointer text-sm" - >{{ opt.label }}</label - > - </div> - </div> - <div v-if="surveyData.useCase === 'other'" class="mt-4 ml-8"> - <InputText - v-model="surveyData.useCaseOther" - class="w-full" - :placeholder=" - $t('cloudOnboarding.survey.options.industry.otherPlaceholder') - " - /> - </div> - </div> - - <div class="flex gap-6 pt-4"> - <Button - variant="secondary" - class="flex-1 text-white" - @click="goTo(1, activateCallback)" - > - {{ $t('g.back') }} - </Button> - <Button - :disabled="!validStep2" - class="h-10 flex-1 text-white" - @click="goTo(3, activateCallback)" - > - {{ $t('g.next') }} - </Button> - </div> - </StepPanel> - - <StepPanel - v-slot="{ activateCallback }" - value="3" - class="flex min-h-full flex-1 flex-col justify-between bg-transparent" - > - <div> - <label class="mb-8 block text-lg font-medium">{{ - t('cloudSurvey_steps_industry') - }}</label> - <div class="flex flex-col gap-6"> - <div - v-for="opt in industryOptions" - :key="opt.value" - class="flex items-center gap-3" - > - <RadioButton - v-model="surveyData.industry" - :input-id="`industry-${opt.value}`" - name="industry" - :value="opt.value" - /> - <label - :for="`industry-${opt.value}`" - class="cursor-pointer text-sm" - >{{ opt.label }}</label - > - </div> - </div> - <div v-if="surveyData.industry === 'other'" class="mt-4 ml-8"> - <InputText - v-model="surveyData.industryOther" - class="w-full" - :placeholder=" - $t('cloudOnboarding.survey.options.industry.otherPlaceholder') - " - /> - </div> - </div> - - <div class="flex gap-6 pt-4"> - <Button - variant="secondary" - class="flex-1 text-white" - @click="goTo(2, activateCallback)" - > - {{ $t('g.back') }} - </Button> - <Button - :disabled="!validStep3" - class="h-10 flex-1 border-none text-white" - @click="goTo(4, activateCallback)" - > - {{ $t('g.next') }} - </Button> - </div> - </StepPanel> - - <StepPanel - v-slot="{ activateCallback }" - value="4" - class="flex min-h-full flex-1 flex-col justify-between bg-transparent" - > - <div> - <label class="mb-8 block text-lg font-medium">{{ - t('cloudSurvey_steps_making') - }}</label> - <div class="flex flex-col gap-6"> - <div - v-for="opt in makingOptions" - :key="opt.value" - class="flex items-center gap-3" - > - <Checkbox - v-model="surveyData.making" - :input-id="`making-${opt.value}`" - :value="opt.value" - /> - <label - :for="`making-${opt.value}`" - class="cursor-pointer text-sm" - >{{ opt.label }}</label - > - </div> - </div> - </div> - - <div class="flex gap-6 pt-4"> - <Button - variant="secondary" - class="flex-1 text-white" - @click="goTo(3, activateCallback)" - > - {{ $t('g.back') }} - </Button> - <Button - :disabled="!validStep4 || isSubmitting" - :loading="isSubmitting" - class="h-10 flex-1 border-none text-white" - @click="onSubmitSurvey" - > - {{ $t('g.submit') }} - </Button> - </div> - </StepPanel> - </StepPanels> - </Stepper> + <div class="flex h-[700px] max-h-[85vh] w-[320px] max-w-[90vw] flex-col"> + <DynamicSurveyForm + :key="activeSurvey.version" + :survey="activeSurvey" + :is-submitting="isSubmitting" + @submit="onSubmitSurvey" + /> </div> </template> <script setup lang="ts"> -import Checkbox from 'primevue/checkbox' -import InputText from 'primevue/inputtext' -import ProgressBar from 'primevue/progressbar' -import RadioButton from 'primevue/radiobutton' -import StepPanel from 'primevue/steppanel' -import StepPanels from 'primevue/steppanels' -import Stepper from 'primevue/stepper' import { computed, onMounted, ref } from 'vue' -import { useI18n } from 'vue-i18n' import { useRouter } from 'vue-router' -import Button from '@/components/ui/button/Button.vue' import { useFeatureFlags } from '@/composables/useFeatureFlags' import { getSurveyCompletedStatus, submitSurvey } from '@/platform/cloud/onboarding/auth' import { isCloud } from '@/platform/distribution/types' +import { remoteConfig } from '@/platform/remoteConfig/remoteConfig' import { useTelemetry } from '@/platform/telemetry' -const { t } = useI18n() +import DynamicSurveyForm from './survey/DynamicSurveyForm.vue' +import { defaultOnboardingSurvey } from './survey/defaultSurveySchema' + const router = useRouter() const { flags } = useFeatureFlags() const onboardingSurveyEnabled = computed(() => flags.onboardingSurveyEnabled) -// Check if survey is already completed on mount +const activeSurvey = computed( + () => remoteConfig.value.onboarding_survey ?? defaultOnboardingSurvey +) + +const isSubmitting = ref(false) + onMounted(async () => { if (!onboardingSurveyEnabled.value) { await router.replace({ name: 'cloud-user-check' }) @@ -254,156 +43,31 @@ onMounted(async () => { try { const surveyCompleted = await getSurveyCompletedStatus() if (surveyCompleted) { - // User already completed survey, return to onboarding flow await router.replace({ name: 'cloud-user-check' }) - } else { - // Track survey opened event - if (isCloud) { - useTelemetry()?.trackSurvey('opened') - } + return + } + if (isCloud) { + useTelemetry()?.trackSurvey('opened') } } catch (error) { console.error('Failed to check survey status:', error) } }) -const activeStep = ref(1) -const totalSteps = 4 -const progressPercent = computed(() => - Math.max(20, Math.min(100, ((activeStep.value - 1) / (totalSteps - 1)) * 100)) -) - -const isSubmitting = ref(false) - -const surveyData = ref({ - familiarity: '', - useCase: '', - useCaseOther: '', - industry: '', - industryOther: '', - making: [] as string[] -}) - -// Options -const familiarityOptions = [ - { label: 'New to ComfyUI (never used it before)', value: 'new' }, - { label: 'Just getting started (following tutorials)', value: 'starting' }, - { label: 'Comfortable with basics', value: 'basics' }, - { label: 'Advanced user (custom workflows)', value: 'advanced' }, - { label: 'Expert (help others)', value: 'expert' } -] - -const purposeOptions = [ - { label: 'Personal projects/hobby', value: 'personal' }, - { - label: 'Community contributions (nodes, workflows, etc.)', - value: 'community' - }, - { label: 'Client work (freelance)', value: 'client' }, - { label: 'My own workplace (in-house)', value: 'inhouse' }, - { label: 'Academic research', value: 'research' }, - { label: 'Other', value: 'other' } -] - -const industryOptions = [ - { label: 'Film, TV, & animation', value: 'film_tv_animation' }, - { label: 'Gaming', value: 'gaming' }, - { label: 'Marketing & advertising', value: 'marketing' }, - { label: 'Architecture', value: 'architecture' }, - { label: 'Product & graphic design', value: 'product_design' }, - { label: 'Fine art & illustration', value: 'fine_art' }, - { label: 'Software & technology', value: 'software' }, - { label: 'Education', value: 'education' }, - { label: 'Other', value: 'other' } -] - -const makingOptions = [ - { label: 'Images', value: 'images' }, - { label: 'Video & animation', value: 'video' }, - { label: '3D assets', value: '3d' }, - { label: 'Audio/music', value: 'audio' }, - { label: 'Custom nodes & workflows', value: 'custom_nodes' } -] - -// Validation per step -const validStep1 = computed(() => !!surveyData.value.familiarity) -const validStep2 = computed(() => { - if (!surveyData.value.useCase) return false - if (surveyData.value.useCase === 'other') { - return !!surveyData.value.useCaseOther?.trim() +const onSubmitSurvey = async (payload: Record<string, unknown>) => { + if (!onboardingSurveyEnabled.value) { + await router.replace({ name: 'cloud-user-check' }) + return } - return true -}) -const validStep3 = computed(() => { - if (!surveyData.value.industry) return false - if (surveyData.value.industry === 'other') { - return !!surveyData.value.industryOther?.trim() - } - return true -}) -const validStep4 = computed(() => surveyData.value.making.length > 0) - -const changeActiveStep = (step: number) => { - activeStep.value = step -} - -const goTo = (step: number, activate: (val: string | number) => void) => { - // keep Stepper panel and progress bar in sync; Stepper values are strings - changeActiveStep(step) - activate(String(step)) -} - -// Submit -const onSubmitSurvey = async () => { + isSubmitting.value = true try { - if (!onboardingSurveyEnabled.value) { - await router.replace({ name: 'cloud-user-check' }) - return - } - isSubmitting.value = true - // prepare payload with consistent structure - const payload = { - familiarity: surveyData.value.familiarity, - useCase: - surveyData.value.useCase === 'other' - ? surveyData.value.useCaseOther?.trim() || 'other' - : surveyData.value.useCase, - industry: - surveyData.value.industry === 'other' - ? surveyData.value.industryOther?.trim() || 'other' - : surveyData.value.industry, - making: surveyData.value.making - } - await submitSurvey(payload) - - // Track survey submitted event with responses if (isCloud) { - useTelemetry()?.trackSurvey('submitted', { - industry: payload.industry, - useCase: payload.useCase, - familiarity: payload.familiarity, - making: payload.making - }) + useTelemetry()?.trackSurvey('submitted', payload) } - await router.push({ name: 'cloud-user-check' }) } finally { isSubmitting.value = false } } </script> - -<style scoped> -:deep(.p-progressbar .p-progressbar-value) { - background-color: #f0ff41 !important; -} -:deep(.p-radiobutton-checked .p-radiobutton-box) { - background-color: #f0ff41 !important; - border-color: #f0ff41 !important; -} -:deep(.p-checkbox-checked .p-checkbox-box) { - background-color: #f0ff41 !important; - border-color: #f0ff41 !important; -} -</style> diff --git a/src/platform/cloud/onboarding/auth.ts b/src/platform/cloud/onboarding/auth.ts index e5d148303f..30a1d7878e 100644 --- a/src/platform/cloud/onboarding/auth.ts +++ b/src/platform/cloud/onboarding/auth.ts @@ -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, diff --git a/src/platform/cloud/onboarding/survey/DynamicSurveyField.vue b/src/platform/cloud/onboarding/survey/DynamicSurveyField.vue new file mode 100644 index 0000000000..8148d00089 --- /dev/null +++ b/src/platform/cloud/onboarding/survey/DynamicSurveyField.vue @@ -0,0 +1,161 @@ +<template> + <fieldset + v-if="field.type !== 'text'" + :aria-invalid="Boolean(errorMessage)" + class="flex flex-col gap-4 border-0 p-0" + > + <legend class="mb-2 block text-lg font-medium text-base-foreground"> + {{ resolvedLabel }} + </legend> + <template v-if="field.type === 'single'"> + <div + v-for="option in field.options" + :key="option.value" + class="flex items-center gap-3" + > + <RadioButton + :model-value="(modelValue as string) ?? ''" + :input-id="`${field.id}-${option.value}`" + :name="field.id" + :value="option.value" + :dt="checkedTokens" + @update:model-value="onSingleChange" + /> + <label + :for="`${field.id}-${option.value}`" + class="cursor-pointer text-sm" + >{{ resolveOptionLabel(option) }}</label + > + </div> + </template> + <template v-else> + <div + v-for="option in field.options" + :key="option.value" + class="flex items-center gap-3" + > + <Checkbox + :model-value="(modelValue as string[]) ?? []" + :input-id="`${field.id}-${option.value}`" + :value="option.value" + :dt="checkedTokens" + @update:model-value="onMultiChange" + /> + <label + :for="`${field.id}-${option.value}`" + class="cursor-pointer text-sm" + >{{ resolveOptionLabel(option) }}</label + > + </div> + </template> + <Input + v-if="field.allowOther && field.otherFieldId && modelValue === 'other'" + :model-value="(otherValue as string) ?? ''" + :placeholder=" + $t( + `cloudOnboarding.survey.options.${field.id}.otherPlaceholder`, + $t('cloudOnboarding.survey.otherPlaceholder') + ) + " + class="ml-1" + @update:model-value="onOtherChange" + /> + <p v-if="errorMessage" class="text-danger text-xs">{{ errorMessage }}</p> + </fieldset> + <div v-else class="flex flex-col gap-3"> + <label + :for="controlId" + class="block text-lg font-medium text-base-foreground" + > + {{ resolvedLabel }} + </label> + <Input + :id="controlId" + :model-value="(modelValue as string) ?? ''" + :placeholder="field.placeholder" + :aria-invalid="Boolean(errorMessage)" + @update:model-value="onTextChange" + /> + <p v-if="errorMessage" class="text-danger text-xs">{{ errorMessage }}</p> + </div> +</template> + +<script setup lang="ts"> +import Checkbox from 'primevue/checkbox' +import RadioButton from 'primevue/radiobutton' +import { useId } from 'vue' +import { useI18n } from 'vue-i18n' + +import Input from '@/components/ui/input/Input.vue' +import type { + LocalizedString, + OnboardingSurveyField, + OnboardingSurveyOption +} from '@/platform/remoteConfig/types' + +const { + field, + modelValue, + otherValue, + errorMessage = '' +} = defineProps<{ + field: OnboardingSurveyField + modelValue: string | string[] | undefined + otherValue?: string + errorMessage?: string +}>() + +const emit = defineEmits<{ + 'update:modelValue': [value: string | string[]] + 'update:otherValue': [value: string] +}>() + +const { t, te, locale } = useI18n() +const controlId = useId() + +const resolveLocalized = (value: LocalizedString): string => { + if (typeof value === 'string') return value + return value[locale.value] ?? value.en ?? Object.values(value)[0] ?? '' +} + +const checkedTokens = { + checked: { + background: 'var(--color-electric-400)', + borderColor: 'var(--color-electric-400)', + hoverBackground: 'var(--color-electric-400)', + hoverBorderColor: 'var(--color-electric-400)' + } +} + +const resolvedLabel = (() => { + if (field.labelKey && te(field.labelKey)) return t(field.labelKey) + if (field.label != null) return resolveLocalized(field.label) + return field.id +})() + +const resolveOptionLabel = (option: OnboardingSurveyOption): string => { + if (option.labelKey && te(option.labelKey)) return t(option.labelKey) + if (option.label != null) return resolveLocalized(option.label) + return option.value +} + +const onSingleChange = (value: unknown) => { + emit('update:modelValue', typeof value === 'string' ? value : '') +} +const onMultiChange = (value: unknown) => { + if (!Array.isArray(value)) { + emit('update:modelValue', []) + return + } + emit( + 'update:modelValue', + value.filter((v): v is string => typeof v === 'string') + ) +} +const onTextChange = (value: string | number | undefined) => { + emit('update:modelValue', String(value ?? '')) +} +const onOtherChange = (value: string | number | undefined) => { + emit('update:otherValue', String(value ?? '')) +} +</script> diff --git a/src/platform/cloud/onboarding/survey/DynamicSurveyForm.test.ts b/src/platform/cloud/onboarding/survey/DynamicSurveyForm.test.ts new file mode 100644 index 0000000000..72d91d24d0 --- /dev/null +++ b/src/platform/cloud/onboarding/survey/DynamicSurveyForm.test.ts @@ -0,0 +1,320 @@ +import userEvent from '@testing-library/user-event' +import { render, screen } from '@testing-library/vue' +import PrimeVue from 'primevue/config' +import { describe, expect, it } from 'vitest' +import { createI18n } from 'vue-i18n' + +import type { OnboardingSurvey } from '@/platform/remoteConfig/types' + +import DynamicSurveyForm from './DynamicSurveyForm.vue' + +const flushPromises = () => new Promise((resolve) => setTimeout(resolve, 0)) + +const i18n = createI18n({ + legacy: false, + locale: 'en', + messages: { + en: { + g: { back: 'Back', next: 'Next', submit: 'Submit' }, + cloudOnboarding: { + survey: { + intro: 'Help us tailor your ComfyUI experience.', + errors: { + chooseAnOption: 'Please choose an option.', + selectAtLeastOne: 'Please select at least one option.', + describeAnswer: 'Please describe your answer.' + } + } + } + } + } +}) + +const renderForm = (survey: OnboardingSurvey) => + render(DynamicSurveyForm, { + global: { plugins: [PrimeVue, i18n] }, + props: { survey } + }) + +const twoStepSurvey: OnboardingSurvey = { + version: 1, + introKey: 'cloudOnboarding.survey.intro', + fields: [ + { + id: 'usage', + type: 'single', + label: 'How do you plan to use ComfyUI?', + required: true, + options: [ + { value: 'personal', label: 'Personal use' }, + { value: 'work', label: 'Work' } + ] + }, + { + id: 'intent', + type: 'multi', + label: 'What do you want to create with ComfyUI?', + required: true, + options: [ + { value: 'images', label: 'Images' }, + { value: 'videos', label: 'Videos' } + ] + } + ] +} + +describe('DynamicSurveyForm', () => { + it('renders the intro text and the first field options', () => { + renderForm(twoStepSurvey) + + expect( + screen.getByText('Help us tailor your ComfyUI experience.') + ).toBeInTheDocument() + expect(screen.getByText('How do you plan to use ComfyUI?')).toBeVisible() + expect(screen.getByLabelText('Personal use')).toBeInTheDocument() + expect(screen.getByLabelText('Work')).toBeInTheDocument() + }) + + it('disables Next until the user selects an option, then advances', async () => { + const user = userEvent.setup() + renderForm(twoStepSurvey) + + const next = screen.getByRole('button', { name: 'Next' }) + expect(next).toBeDisabled() + + await user.click(screen.getByLabelText('Personal use')) + expect(next).toBeEnabled() + + await user.click(next) + await flushPromises() + + expect( + screen.getByText('What do you want to create with ComfyUI?') + ).toBeVisible() + expect(screen.getByLabelText('Images')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Back' })).toBeInTheDocument() + }) + + it('navigates back to the previous step', async () => { + const user = userEvent.setup() + renderForm(twoStepSurvey) + + await user.click(screen.getByLabelText('Personal use')) + await user.click(screen.getByRole('button', { name: 'Next' })) + await flushPromises() + expect( + screen.getByText('What do you want to create with ComfyUI?') + ).toBeVisible() + + await user.click(screen.getByRole('button', { name: 'Back' })) + await flushPromises() + expect(screen.getByText('How do you plan to use ComfyUI?')).toBeVisible() + }) + + it('resolves option and field labels via labelKey when provided', () => { + const localizedI18n = createI18n({ + legacy: false, + locale: 'en', + messages: { + en: { + g: { back: 'Back', next: 'Next', submit: 'Submit' }, + cloudOnboarding: { + survey: { + intro: 'Help us tailor your ComfyUI experience.', + errors: { + chooseAnOption: '', + selectAtLeastOne: '', + describeAnswer: '' + } + } + }, + survey_label: 'Localized question?', + survey_a: 'Localized A', + survey_b: 'Localized B' + } + } + }) + + render(DynamicSurveyForm, { + global: { plugins: [PrimeVue, localizedI18n] }, + props: { + survey: { + version: 1, + fields: [ + { + id: 'q', + type: 'single', + labelKey: 'survey_label', + required: true, + options: [ + { value: 'a', labelKey: 'survey_a' }, + { value: 'b', labelKey: 'survey_b' } + ] + } + ] + } + } + }) + + expect(screen.getByText('Localized question?')).toBeVisible() + expect(screen.getByLabelText('Localized A')).toBeInTheDocument() + expect(screen.getByLabelText('Localized B')).toBeInTheDocument() + }) + + it('renders server-supplied translations from a label locale map', () => { + const koreanI18n = createI18n({ + legacy: false, + locale: 'ko', + fallbackLocale: 'en', + messages: { + en: { + g: { back: 'Back', next: 'Next', submit: 'Submit' }, + cloudOnboarding: { + survey: { + intro: '', + errors: { + chooseAnOption: '', + selectAtLeastOne: '', + describeAnswer: '' + } + } + } + }, + ko: { g: { back: '뒤로', next: '다음', submit: '제출' } } + } + }) + + render(DynamicSurveyForm, { + global: { plugins: [PrimeVue, koreanI18n] }, + props: { + survey: { + version: 1, + fields: [ + { + id: 'usage', + type: 'single', + label: { + en: 'How will you use it?', + ko: '어떻게 사용하시겠어요?' + }, + required: true, + options: [ + { + value: 'personal', + label: { en: 'Personal use', ko: '개인 용도' } + }, + { value: 'work', label: { en: 'Work', ko: '업무' } } + ] + } + ] + } + } + }) + + expect(screen.getByText('어떻게 사용하시겠어요?')).toBeVisible() + expect(screen.getByLabelText('개인 용도')).toBeInTheDocument() + expect(screen.getByLabelText('업무')).toBeInTheDocument() + }) + + it('falls back to English when current locale missing from label map', () => { + const fallbackI18n = createI18n({ + legacy: false, + locale: 'fr', + fallbackLocale: 'en', + messages: { + en: { + g: { back: 'Back', next: 'Next', submit: 'Submit' }, + cloudOnboarding: { + survey: { + intro: '', + errors: { + chooseAnOption: '', + selectAtLeastOne: '', + describeAnswer: '' + } + } + } + }, + fr: {} + } + }) + + render(DynamicSurveyForm, { + global: { plugins: [PrimeVue, fallbackI18n] }, + props: { + survey: { + version: 1, + fields: [ + { + id: 'q', + type: 'single', + label: { en: 'English question', ko: '한국어' }, + required: true, + options: [ + { value: 'a', label: { en: 'English A', ko: '한국어 A' } } + ] + } + ] + } + } + }) + + // fr is not in the map → falls back to en + expect(screen.getByText('English question')).toBeVisible() + expect(screen.getByLabelText('English A')).toBeInTheDocument() + }) + + it('allows advancing past an optional field while still empty', async () => { + const user = userEvent.setup() + render(DynamicSurveyForm, { + global: { plugins: [PrimeVue, i18n] }, + props: { + survey: { + version: 1, + fields: [ + { + id: 'q1', + type: 'single', + label: 'Optional question?', + options: [ + { value: 'a', label: 'A' }, + { value: 'b', label: 'B' } + ] + // no required: true — should be skippable + }, + { + id: 'q2', + type: 'single', + label: 'Required question?', + required: true, + options: [{ value: 'c', label: 'C' }] + } + ] + } + } + }) + + const next = screen.getByRole('button', { name: 'Next' }) + expect(next).toBeEnabled() + + await user.click(next) + await flushPromises() + expect(screen.getByText('Required question?')).toBeVisible() + }) + + it('enables Submit only after the multi-select field has at least one choice', async () => { + const user = userEvent.setup() + renderForm(twoStepSurvey) + + await user.click(screen.getByLabelText('Work')) + await user.click(screen.getByRole('button', { name: 'Next' })) + await flushPromises() + + const submitBtn = screen.getByRole('button', { name: 'Submit' }) + expect(submitBtn).toBeDisabled() + + await user.click(screen.getByRole('checkbox', { name: /Images/i })) + await flushPromises() + expect(submitBtn).toBeEnabled() + }) +}) diff --git a/src/platform/cloud/onboarding/survey/DynamicSurveyForm.vue b/src/platform/cloud/onboarding/survey/DynamicSurveyForm.vue new file mode 100644 index 0000000000..9ed1148a72 --- /dev/null +++ b/src/platform/cloud/onboarding/survey/DynamicSurveyForm.vue @@ -0,0 +1,212 @@ +<template> + <form class="flex size-full flex-col" @submit.prevent="onSubmit"> + <p v-if="introText" class="mb-4 text-sm text-muted"> + {{ introText }} + </p> + <div + class="mb-8 h-2 w-full overflow-hidden rounded-full bg-secondary-background" + > + <div + class="h-full bg-electric-400 transition-[width] duration-300 ease-out" + :style="{ width: `${progressPercent}%` }" + /> + </div> + + <div class="flex flex-1 flex-col overflow-hidden"> + <div + v-if="currentField" + :key="currentField.id" + class="flex flex-1 flex-col gap-4 overflow-y-auto pr-1" + > + <DynamicSurveyField + :field="currentField" + :model-value="values[currentField.id]" + :other-value=" + currentField.otherFieldId + ? (values[currentField.otherFieldId] as string) + : undefined + " + :error-message=" + errors[currentField.id] ?? + (currentField.otherFieldId + ? errors[currentField.otherFieldId] + : undefined) + " + @update:model-value="(value) => onFieldChange(currentField.id, value)" + @update:other-value=" + (value) => + currentField.otherFieldId && + onFieldChange(currentField.otherFieldId, value) + " + /> + </div> + </div> + + <div class="flex gap-6 pt-4"> + <Button + v-if="!isFirst" + type="button" + variant="secondary" + class="h-10 flex-1 text-white" + @click="goPrevious" + > + {{ $t('g.back') }} + </Button> + <span v-else class="flex-1" /> + <Button + v-if="!isLast" + type="button" + :disabled="!isCurrentValid" + :class=" + cn( + 'h-10 flex-1 border-none', + isCurrentValid + ? 'bg-electric-400 text-black hover:bg-electric-400/85' + : 'bg-zinc-800 text-zinc-500' + ) + " + @click="goNext" + > + {{ $t('g.next') }} + </Button> + <Button + v-else + type="submit" + :disabled="!isCurrentValid || isSubmitting" + :loading="isSubmitting" + :class=" + cn( + 'h-10 flex-1 border-none', + isCurrentValid && !isSubmitting + ? 'bg-electric-400 text-black hover:bg-electric-400/85' + : 'bg-zinc-800 text-zinc-500' + ) + " + > + {{ $t('g.submit') }} + </Button> + </div> + </form> +</template> + +<script setup lang="ts"> +import { cn } from '@comfyorg/tailwind-utils' +import { toTypedSchema } from '@vee-validate/zod' +import { useForm } from 'vee-validate' +import { computed, ref, watch } from 'vue' +import { useI18n } from 'vue-i18n' + +import Button from '@/components/ui/button/Button.vue' +import type { OnboardingSurvey } from '@/platform/remoteConfig/types' + +import DynamicSurveyField from './DynamicSurveyField.vue' +import { + buildInitialValues, + buildSubmissionPayload, + buildZodSchema, + prepareSurvey, + visibleFields +} from './surveySchema' +import type { SurveyValues } from './surveySchema' + +const { survey } = defineProps<{ + survey: OnboardingSurvey + isSubmitting?: boolean +}>() + +const emit = defineEmits<{ + submit: [payload: Record<string, unknown>] +}>() + +const { t, te } = useI18n() + +const preparedSurvey = computed(() => prepareSurvey(survey)) + +const introText = computed(() => { + const key = preparedSurvey.value.introKey + if (!key) return '' + return te(key) ? t(key) : '' +}) + +const liveValues = ref<SurveyValues>(buildInitialValues(preparedSurvey.value)) + +const validationSchema = computed(() => + toTypedSchema(buildZodSchema(preparedSurvey.value, liveValues.value, t)) +) + +const { values, errors, setFieldValue, validate, resetForm } = + useForm<SurveyValues>({ + initialValues: liveValues.value, + validationSchema + }) + +watch( + () => survey, + () => { + const fresh = buildInitialValues(preparedSurvey.value) + liveValues.value = { ...fresh } + resetForm({ values: fresh }) + stepIndex.value = 0 + } +) + +const visible = computed(() => + visibleFields(preparedSurvey.value, values as SurveyValues) +) +const stepIndex = ref(0) + +const currentField = computed(() => visible.value[stepIndex.value]) +const isFirst = computed(() => stepIndex.value === 0) +const isLast = computed(() => stepIndex.value === visible.value.length - 1) + +const totalSteps = computed(() => Math.max(visible.value.length, 1)) +const progressPercent = computed(() => + Math.max( + 100 / totalSteps.value, + ((stepIndex.value + 1) / totalSteps.value) * 100 + ) +) + +const isCurrentValid = computed(() => { + const field = currentField.value + if (!field) return false + + const value = values[field.id] + const isEmpty = + field.type === 'multi' + ? !Array.isArray(value) || value.length === 0 + : typeof value !== 'string' || value.length === 0 + + if (isEmpty) return !field.required + + if (field.allowOther && field.otherFieldId && value === 'other') { + const other = values[field.otherFieldId] + return typeof other === 'string' && other.trim().length > 0 + } + return true +}) + +const onFieldChange = (id: string, value: string | string[]) => { + setFieldValue(id, value) + liveValues.value = { ...liveValues.value, [id]: value } + if (stepIndex.value > visible.value.length - 1) { + stepIndex.value = Math.max(0, visible.value.length - 1) + } +} + +const goNext = () => { + if (stepIndex.value < visible.value.length - 1) stepIndex.value += 1 +} +const goPrevious = () => { + if (stepIndex.value > 0) stepIndex.value -= 1 +} + +const onSubmit = async () => { + const result = await validate() + if (!result.valid) return + emit( + 'submit', + buildSubmissionPayload(preparedSurvey.value, values as SurveyValues) + ) +} +</script> diff --git a/src/platform/cloud/onboarding/survey/defaultSurveySchema.ts b/src/platform/cloud/onboarding/survey/defaultSurveySchema.ts new file mode 100644 index 0000000000..69c05ce5e7 --- /dev/null +++ b/src/platform/cloud/onboarding/survey/defaultSurveySchema.ts @@ -0,0 +1,76 @@ +import type { OnboardingSurvey } from '@/platform/remoteConfig/types' + +const optionsFor = ( + fieldId: string, + values: string[] +): { value: string; labelKey: string }[] => + values.map((value) => ({ + value, + labelKey: `cloudOnboarding.survey.options.${fieldId}.${value}` + })) + +export const defaultOnboardingSurvey: OnboardingSurvey = { + version: 2, + introKey: 'cloudOnboarding.survey.intro', + fields: [ + { + id: 'usage', + type: 'single', + labelKey: 'cloudSurvey_steps_usage', + required: true, + options: optionsFor('usage', ['personal', 'work', 'education']) + }, + { + id: 'familiarity', + type: 'single', + labelKey: 'cloudSurvey_steps_familiarity', + required: true, + options: optionsFor('familiarity', [ + 'new', + 'starting', + 'basics', + 'advanced', + 'expert' + ]) + }, + { + id: 'intent', + type: 'multi', + labelKey: 'cloudSurvey_steps_intent', + required: true, + randomize: true, + options: optionsFor('intent', [ + 'workflows', + 'custom_nodes', + 'videos', + 'images', + '3d_game', + 'audio', + 'apps', + 'api', + 'not_sure' + ]) + }, + { + id: 'source', + type: 'single', + labelKey: 'cloudSurvey_steps_source', + required: true, + randomize: true, + options: optionsFor('source', [ + 'youtube', + 'reddit', + 'twitter', + 'instagram', + 'linkedin', + 'friend', + 'search', + 'newsletter', + 'conference', + 'discord', + 'github', + 'other' + ]) + } + ] +} diff --git a/src/platform/cloud/onboarding/survey/surveySchema.test.ts b/src/platform/cloud/onboarding/survey/surveySchema.test.ts new file mode 100644 index 0000000000..1695c85132 --- /dev/null +++ b/src/platform/cloud/onboarding/survey/surveySchema.test.ts @@ -0,0 +1,248 @@ +import { describe, expect, it } from 'vitest' + +import type { OnboardingSurvey } from '@/platform/remoteConfig/types' + +import { + buildInitialValues, + buildSubmissionPayload, + buildZodSchema, + prepareSurvey, + visibleFields +} from './surveySchema' + +const baseSurvey: OnboardingSurvey = { + version: 1, + fields: [ + { + id: 'usage', + type: 'single', + required: true, + options: [ + { value: 'work', label: 'Work' }, + { value: 'personal', label: 'Personal' } + ] + }, + { + id: 'role', + type: 'single', + required: true, + showWhen: { field: 'usage', equals: 'work' }, + options: [{ value: 'engineer', label: 'Engineer' }] + }, + { + id: 'industry', + type: 'single', + required: true, + allowOther: true, + otherFieldId: 'industryOther', + showWhen: { field: 'usage', equals: 'work' }, + options: [ + { value: 'tech', label: 'Tech' }, + { value: 'other', label: 'Other' } + ] + }, + { + id: 'making', + type: 'multi', + required: true, + options: [ + { value: 'video', label: 'Video' }, + { value: 'images', label: 'Images' } + ] + } + ] +} + +describe('visibleFields', () => { + it('hides fields when showWhen does not match', () => { + const visible = visibleFields(baseSurvey, { usage: 'personal' }) + expect(visible.map((f) => f.id)).toEqual(['usage', 'making']) + }) + + it('shows gated fields when showWhen matches', () => { + const visible = visibleFields(baseSurvey, { usage: 'work' }) + expect(visible.map((f) => f.id)).toEqual([ + 'usage', + 'role', + 'industry', + 'making' + ]) + }) + + it('treats array equals as membership', () => { + const survey: OnboardingSurvey = { + version: 1, + fields: [ + { + id: 'role', + type: 'single', + showWhen: { field: 'usage', equals: ['work', 'education'] } + } + ] + } + expect(visibleFields(survey, { usage: 'education' })).toHaveLength(1) + expect(visibleFields(survey, { usage: 'personal' })).toHaveLength(0) + }) + + it('intersects multi-select source values with expected set', () => { + const survey: OnboardingSurvey = { + version: 1, + fields: [ + { + id: 'follow_up', + type: 'single', + showWhen: { field: 'making', equals: ['video', '3d'] } + } + ] + } + expect(visibleFields(survey, { making: [] })).toHaveLength(0) + expect(visibleFields(survey, { making: ['images'] })).toHaveLength(0) + expect(visibleFields(survey, { making: ['images', 'video'] })).toHaveLength( + 1 + ) + }) +}) + +describe('buildInitialValues', () => { + it('initializes single fields to empty string and multi to empty array', () => { + expect(buildInitialValues(baseSurvey)).toMatchObject({ + usage: '', + role: '', + industry: '', + industryOther: '', + making: [] + }) + }) +}) + +describe('buildZodSchema', () => { + it('omits hidden fields from validation', () => { + const schema = buildZodSchema(baseSurvey, { usage: 'personal' }) + const result = schema.safeParse({ usage: 'personal', making: ['video'] }) + expect(result.success).toBe(true) + }) + + it('requires gated fields once visible', () => { + const schema = buildZodSchema(baseSurvey, { usage: 'work' }) + const result = schema.safeParse({ usage: 'work', making: ['video'] }) + expect(result.success).toBe(false) + }) + + it('requires "other" detail when option is selected', () => { + const schema = buildZodSchema(baseSurvey, { + usage: 'work', + role: 'engineer', + industry: 'other', + making: ['video'] + }) + expect( + schema.safeParse({ + usage: 'work', + role: 'engineer', + industry: 'other', + industryOther: '', + making: ['video'] + }).success + ).toBe(false) + expect( + schema.safeParse({ + usage: 'work', + role: 'engineer', + industry: 'other', + industryOther: 'Aerospace', + making: ['video'] + }).success + ).toBe(true) + }) +}) + +describe('buildSubmissionPayload', () => { + it('clears hidden fields and prefers free-text "other" detail', () => { + const payload = buildSubmissionPayload(baseSurvey, { + usage: 'work', + role: 'engineer', + industry: 'other', + industryOther: ' Aerospace ', + making: ['video'] + }) + expect(payload).toEqual({ + usage: 'work', + role: 'engineer', + industry: 'Aerospace', + making: ['video'] + }) + }) + + it('falls back to "other" when free-text is empty', () => { + const payload = buildSubmissionPayload(baseSurvey, { + usage: 'work', + role: 'engineer', + industry: 'other', + industryOther: '', + making: ['video'] + }) + expect(payload.industry).toBe('other') + }) + + it('zeroes out fields hidden by showWhen', () => { + const payload = buildSubmissionPayload(baseSurvey, { + usage: 'personal', + role: 'engineer', + making: ['video'] + }) + expect(payload).toMatchObject({ + usage: 'personal', + role: '', + industry: '', + making: ['video'] + }) + }) +}) + +describe('prepareSurvey', () => { + it('preserves option contents but may reorder when randomize=true', () => { + const survey: OnboardingSurvey = { + version: 1, + fields: [ + { + id: 'making', + type: 'multi', + randomize: true, + options: [ + { value: 'a', label: 'A' }, + { value: 'b', label: 'B' }, + { value: 'other', label: 'Other' } + ] + } + ] + } + const prepared = prepareSurvey(survey) + const values = prepared.fields[0]!.options!.map((o) => o.value) + expect(values).toContain('a') + expect(values).toContain('b') + expect(values[values.length - 1]).toBe('other') + }) + + it('pins both "other" and "not_sure" at the end while randomizing the rest', () => { + const survey: OnboardingSurvey = { + version: 1, + fields: [ + { + id: 'intent', + type: 'multi', + randomize: true, + options: [ + { value: 'a', label: 'A' }, + { value: 'b', label: 'B' }, + { value: 'other', label: 'Other' }, + { value: 'not_sure', label: 'Not sure' } + ] + } + ] + } + const prepared = prepareSurvey(survey) + const values = prepared.fields[0]!.options!.map((o) => o.value) + expect(values.slice(-2).sort()).toEqual(['not_sure', 'other']) + expect(values.slice(0, -2).sort()).toEqual(['a', 'b']) + }) +}) diff --git a/src/platform/cloud/onboarding/survey/surveySchema.ts b/src/platform/cloud/onboarding/survey/surveySchema.ts new file mode 100644 index 0000000000..d039d89139 --- /dev/null +++ b/src/platform/cloud/onboarding/survey/surveySchema.ts @@ -0,0 +1,137 @@ +import { shuffle } from 'es-toolkit' +import { z } from 'zod' + +import type { + OnboardingSurvey, + OnboardingSurveyField, + OnboardingSurveyFieldCondition +} from '@/platform/remoteConfig/types' + +export type SurveyValues = Record<string, string | string[] | undefined> + +const hasNonEmptyValue = (current: string | string[] | undefined): boolean => { + if (current === undefined || current === '') return false + if (Array.isArray(current)) return current.length > 0 + return true +} + +const conditionMatches = ( + condition: OnboardingSurveyFieldCondition | undefined, + values: SurveyValues +): boolean => { + if (!condition) return true + const current = values[condition.field] + if (!hasNonEmptyValue(current)) return false + const expected = condition.equals + if (expected === undefined) return true + const expectedSet = Array.isArray(expected) ? expected : [expected] + if (Array.isArray(current)) { + return current.some((v) => expectedSet.includes(v)) + } + return typeof current === 'string' && expectedSet.includes(current) +} + +export const visibleFields = ( + survey: OnboardingSurvey, + values: SurveyValues +): OnboardingSurveyField[] => + survey.fields.filter((field) => conditionMatches(field.showWhen, values)) + +const PIN_LAST_VALUES = new Set(['other', 'not_sure']) + +const randomizeOptions = (field: OnboardingSurveyField) => { + if (!field.randomize || !field.options) return field + const pinned = field.options.filter((opt) => PIN_LAST_VALUES.has(opt.value)) + const rest = field.options.filter((opt) => !PIN_LAST_VALUES.has(opt.value)) + return { + ...field, + options: [...shuffle(rest), ...pinned] + } +} + +export const prepareSurvey = (survey: OnboardingSurvey): OnboardingSurvey => ({ + ...survey, + fields: survey.fields.map(randomizeOptions) +}) + +type Translator = (key: string) => string + +const identityTranslator: Translator = (key) => key + +const fieldSchema = (field: OnboardingSurveyField, t: Translator) => { + if (field.type === 'multi') { + const arr = z.array(z.string()) + return field.required + ? arr.min(1, { + message: t('cloudOnboarding.survey.errors.selectAtLeastOne') + }) + : arr.optional() + } + if (field.required) { + return z.string().min(1, { + message: t('cloudOnboarding.survey.errors.chooseAnOption') + }) + } + return z.string().optional() +} + +export const buildZodSchema = ( + survey: OnboardingSurvey, + values: SurveyValues, + t: Translator = identityTranslator +) => { + const shape: Record<string, z.ZodTypeAny> = {} + for (const field of survey.fields) { + if (!conditionMatches(field.showWhen, values)) continue + shape[field.id] = fieldSchema(field, t) + if ( + field.allowOther && + field.otherFieldId && + values[field.id] === 'other' + ) { + shape[field.otherFieldId] = z.string().min(1, { + message: t('cloudOnboarding.survey.errors.describeAnswer') + }) + } else if (field.otherFieldId) { + shape[field.otherFieldId] = z.string().optional() + } + } + return z.object(shape) +} + +export const buildInitialValues = (survey: OnboardingSurvey): SurveyValues => { + const initial: SurveyValues = {} + for (const field of survey.fields) { + initial[field.id] = field.type === 'multi' ? [] : '' + if (field.otherFieldId) initial[field.otherFieldId] = '' + } + return initial +} + +export const buildSubmissionPayload = ( + survey: OnboardingSurvey, + values: SurveyValues +): Record<string, unknown> => { + const payload: Record<string, unknown> = {} + for (const field of survey.fields) { + const visible = conditionMatches(field.showWhen, values) + if (!visible) { + payload[field.id] = field.type === 'multi' ? [] : '' + continue + } + const value = values[field.id] + const otherRaw = field.otherFieldId ? values[field.otherFieldId] : undefined + if ( + field.allowOther && + field.otherFieldId && + value === 'other' && + typeof otherRaw === 'string' + ) { + const other = otherRaw.trim() + payload[field.id] = other || 'other' + } else { + payload[field.id] = field.type === 'multi' ? (value ?? []) : (value ?? '') + } + } + return payload +} diff --git a/src/platform/cloud/subscription/utils/subscriptionCheckoutTracker.ts b/src/platform/cloud/subscription/utils/subscriptionCheckoutTracker.ts index 364af345b2..9b7f0079eb 100644 --- a/src/platform/cloud/subscription/utils/subscriptionCheckoutTracker.ts +++ b/src/platform/cloud/subscription/utils/subscriptionCheckoutTracker.ts @@ -228,18 +228,6 @@ export const recordPendingSubscriptionCheckoutAttempt = ( input: RecordPendingSubscriptionCheckoutAttemptInput ): PendingSubscriptionCheckoutAttempt => { const storage = getStorage() - if (!storage) { - return { - attempt_id: createAttemptId(), - started_at_ms: Date.now(), - tier: input.tier, - cycle: input.cycle, - checkout_type: input.checkout_type, - ...(input.previous_tier ? { previous_tier: input.previous_tier } : {}), - ...(input.previous_cycle ? { previous_cycle: input.previous_cycle } : {}) - } - } - const attempt: PendingSubscriptionCheckoutAttempt = { attempt_id: createAttemptId(), started_at_ms: Date.now(), @@ -250,6 +238,10 @@ export const recordPendingSubscriptionCheckoutAttempt = ( ...(input.previous_cycle ? { previous_cycle: input.previous_cycle } : {}) } + if (!storage) { + return attempt + } + try { storage.setItem( PENDING_SUBSCRIPTION_CHECKOUT_STORAGE_KEY, diff --git a/src/platform/missingMedia/missingMediaScan.test.ts b/src/platform/missingMedia/missingMediaScan.test.ts index 867bb4d3f7..8e77aae88c 100644 --- a/src/platform/missingMedia/missingMediaScan.test.ts +++ b/src/platform/missingMedia/missingMediaScan.test.ts @@ -1,9 +1,11 @@ import { fromAny } from '@total-typescript/shoehorn' -import { describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import type { LGraph } from '@/lib/litegraph/src/LGraph' import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode' import type { IComboWidget } from '@/lib/litegraph/src/types/widgets' +import type { AssetItem } from '@/platform/assets/schemas/assetSchema' +import type * as AssetServiceModule from '@/platform/assets/services/assetService' import { scanAllMediaCandidates, scanNodeMediaCandidates, @@ -13,6 +15,13 @@ import { } from './missingMediaScan' import type { MissingMediaCandidate } from './types' +const { mockCheckAssetHash, mockGetInputAssetsIncludingPublic } = vi.hoisted( + () => ({ + mockCheckAssetHash: vi.fn(), + mockGetInputAssetsIncludingPublic: vi.fn() + }) +) + vi.mock('@/utils/graphTraversalUtil', () => ({ collectAllNodes: (graph: { _testNodes: LGraphNode[] }) => graph._testNodes, getExecutionIdByNode: ( @@ -21,6 +30,21 @@ vi.mock('@/utils/graphTraversalUtil', () => ({ ) => node._testExecutionId ?? String(node.id) })) +vi.mock('@/platform/assets/services/assetService', async () => { + const actual = await vi.importActual<typeof AssetServiceModule>( + '@/platform/assets/services/assetService' + ) + + return { + ...actual, + assetService: { + ...actual.assetService, + checkAssetHash: mockCheckAssetHash, + getInputAssetsIncludingPublic: mockGetInputAssetsIncludingPublic + } + } +}) + function makeCandidate( nodeId: string, name: string, @@ -70,6 +94,16 @@ function makeGraph(nodes: LGraphNode[]): LGraph { return fromAny<LGraph, unknown>({ _testNodes: nodes }) } +function makeAsset(name: string, assetHash: string | null = null): AssetItem { + return { + id: name, + name, + asset_hash: assetHash, + mime_type: null, + tags: ['input'] + } +} + describe('scanNodeMediaCandidates', () => { it('returns candidate for a LoadImage node with missing image', () => { const graph = makeGraph([]) @@ -232,37 +266,43 @@ describe('groupCandidatesByMediaType', () => { }) describe('verifyCloudMediaCandidates', () => { - it('marks candidates missing when not in input assets', async () => { + const existingHash = + 'blake3:1111111111111111111111111111111111111111111111111111111111111111' + const missingHash = + 'blake3:2222222222222222222222222222222222222222222222222222222222222222' + + beforeEach(() => { + vi.clearAllMocks() + mockCheckAssetHash.mockResolvedValue('missing') + mockGetInputAssetsIncludingPublic.mockResolvedValue([]) + }) + + it('marks candidates missing when the asset hash is not found', async () => { const candidates = [ - makeCandidate('1', 'abc123.png', { isMissing: undefined }), - makeCandidate('2', 'def456.png', { isMissing: undefined }) + makeCandidate('1', missingHash, { isMissing: undefined }), + makeCandidate('2', existingHash, { isMissing: undefined }) ] - const mockStore = { - updateInputs: async () => {}, - inputAssets: [{ asset_hash: 'def456.png', name: 'my-photo.png' }] - } + const checkAssetHash = vi.fn(async (assetHash: string) => + assetHash === existingHash ? ('exists' as const) : ('missing' as const) + ) - await verifyCloudMediaCandidates(candidates, undefined, mockStore) + await verifyCloudMediaCandidates(candidates, undefined, checkAssetHash) expect(candidates[0].isMissing).toBe(true) expect(candidates[1].isMissing).toBe(false) }) - it('calls updateInputs before checking assets', async () => { - let updateCalled = false - const candidates = [makeCandidate('1', 'abc.png', { isMissing: undefined })] + it('uses assetService.checkAssetHash by default', async () => { + const candidates = [ + makeCandidate('1', existingHash, { isMissing: undefined }) + ] + mockCheckAssetHash.mockResolvedValue('exists') - const mockStore = { - updateInputs: async () => { - updateCalled = true - }, - inputAssets: [] - } + await verifyCloudMediaCandidates(candidates) - await verifyCloudMediaCandidates(candidates, undefined, mockStore) - - expect(updateCalled).toBe(true) + expect(candidates[0].isMissing).toBe(false) + expect(mockCheckAssetHash).toHaveBeenCalledWith(existingHash, undefined) }) it('respects abort signal before execution', async () => { @@ -270,69 +310,221 @@ describe('verifyCloudMediaCandidates', () => { controller.abort() const candidates = [ - makeCandidate('1', 'abc123.png', { isMissing: undefined }) + makeCandidate('1', missingHash, { isMissing: undefined }) ] await verifyCloudMediaCandidates(candidates, controller.signal) expect(candidates[0].isMissing).toBeUndefined() + expect(mockCheckAssetHash).not.toHaveBeenCalled() }) - it('respects abort signal after updateInputs', async () => { + it('respects abort signal after hash verification', async () => { const controller = new AbortController() - const candidates = [makeCandidate('1', 'abc.png', { isMissing: undefined })] + const candidates = [ + makeCandidate('1', existingHash, { isMissing: undefined }) + ] + const checkAssetHash = vi.fn(async () => { + controller.abort() + return 'exists' as const + }) - const mockStore = { - updateInputs: async () => { - controller.abort() - }, - inputAssets: [{ asset_hash: 'abc.png', name: 'photo.png' }] - } - - await verifyCloudMediaCandidates(candidates, controller.signal, mockStore) + await verifyCloudMediaCandidates( + candidates, + controller.signal, + checkAssetHash + ) expect(candidates[0].isMissing).toBeUndefined() }) it('skips candidates already resolved as true', async () => { - const candidates = [makeCandidate('1', 'abc.png', { isMissing: true })] + const candidates = [makeCandidate('1', missingHash, { isMissing: true })] - const mockStore = { - updateInputs: async () => {}, - inputAssets: [] - } - - await verifyCloudMediaCandidates(candidates, undefined, mockStore) + await verifyCloudMediaCandidates(candidates) expect(candidates[0].isMissing).toBe(true) + expect(mockCheckAssetHash).not.toHaveBeenCalled() }) it('skips candidates already resolved as false', async () => { - const candidates = [makeCandidate('1', 'abc.png', { isMissing: false })] + const candidates = [makeCandidate('1', existingHash, { isMissing: false })] - const mockStore = { - updateInputs: async () => {}, - inputAssets: [] - } - - await verifyCloudMediaCandidates(candidates, undefined, mockStore) + await verifyCloudMediaCandidates(candidates) expect(candidates[0].isMissing).toBe(false) + expect(mockCheckAssetHash).not.toHaveBeenCalled() }) it('skips entirely when no pending candidates', async () => { - let updateCalled = false - const candidates = [makeCandidate('1', 'abc.png', { isMissing: true })] + const candidates = [makeCandidate('1', missingHash, { isMissing: true })] - const mockStore = { - updateInputs: async () => { - updateCalled = true - }, - inputAssets: [] - } + await verifyCloudMediaCandidates(candidates) - await verifyCloudMediaCandidates(candidates, undefined, mockStore) + expect(mockCheckAssetHash).not.toHaveBeenCalled() + }) - expect(updateCalled).toBe(false) + it('falls back to input assets for non-blake3 candidate names', async () => { + const candidates = [ + makeCandidate('1', 'photo.png', { isMissing: undefined }), + makeCandidate('2', 'missing.png', { isMissing: undefined }) + ] + const fetchInputAssets = vi.fn(async () => [ + makeAsset('stored-photo.png', 'photo.png') + ]) + + await verifyCloudMediaCandidates( + candidates, + undefined, + undefined, + fetchInputAssets + ) + + expect(mockCheckAssetHash).not.toHaveBeenCalled() + expect(fetchInputAssets).toHaveBeenCalledOnce() + expect(candidates[0].isMissing).toBe(false) + expect(candidates[1].isMissing).toBe(true) + }) + + it('uses public input assets for default legacy fallback', async () => { + const candidates = [ + makeCandidate('1', 'public-photo.png', { isMissing: undefined }) + ] + const inputAssets = Array.from({ length: 500 }, (_, index) => + makeAsset(`asset-${index}.png`) + ) + inputAssets[42] = makeAsset('public-asset-record', 'public-photo.png') + mockGetInputAssetsIncludingPublic.mockResolvedValue(inputAssets) + + await verifyCloudMediaCandidates(candidates) + + expect(mockGetInputAssetsIncludingPublic).toHaveBeenCalledWith(undefined) + expect(candidates[0].isMissing).toBe(false) + }) + + it('silences aborts while loading legacy fallback input assets', async () => { + const abortError = new Error('aborted') + abortError.name = 'AbortError' + const controller = new AbortController() + const candidates = [ + makeCandidate('1', 'photo.png', { isMissing: undefined }) + ] + const fetchInputAssets = vi.fn(async () => { + controller.abort() + throw abortError + }) + + await expect( + verifyCloudMediaCandidates( + candidates, + controller.signal, + undefined, + fetchInputAssets + ) + ).resolves.toBeUndefined() + + expect(candidates[0].isMissing).toBeUndefined() + }) + + it('silences aborts from the default legacy fallback input asset store path', async () => { + const abortError = new Error('aborted') + abortError.name = 'AbortError' + const controller = new AbortController() + const candidates = [ + makeCandidate('1', 'photo.png', { isMissing: undefined }) + ] + mockGetInputAssetsIncludingPublic.mockImplementationOnce(async () => { + controller.abort() + throw abortError + }) + + await expect( + verifyCloudMediaCandidates(candidates, controller.signal) + ).resolves.toBeUndefined() + + expect(mockGetInputAssetsIncludingPublic).toHaveBeenCalledWith( + controller.signal + ) + expect(candidates[0].isMissing).toBeUndefined() + }) + + it('falls back to input assets when the hash endpoint returns 400', async () => { + const candidates = [ + makeCandidate('1', existingHash, { isMissing: undefined }) + ] + mockCheckAssetHash.mockResolvedValue('invalid') + const fetchInputAssets = vi.fn(async () => [ + makeAsset('photo.png', existingHash) + ]) + + await verifyCloudMediaCandidates( + candidates, + undefined, + undefined, + fetchInputAssets + ) + + expect(mockCheckAssetHash).toHaveBeenCalledWith(existingHash, undefined) + expect(fetchInputAssets).toHaveBeenCalledOnce() + expect(candidates[0].isMissing).toBe(false) + }) + + it('falls back to input assets when hash verification fails', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const candidates = [ + makeCandidate('1', existingHash, { isMissing: undefined }) + ] + const checkAssetHash = vi.fn(async () => { + throw new Error('network failed') + }) + const fetchInputAssets = vi.fn(async () => [ + makeAsset('photo.png', existingHash) + ]) + + await verifyCloudMediaCandidates( + candidates, + undefined, + checkAssetHash, + fetchInputAssets + ) + + expect(fetchInputAssets).toHaveBeenCalledOnce() + expect(candidates[0].isMissing).toBe(false) + expect(warn).toHaveBeenCalledOnce() + warn.mockRestore() + }) + + it('does not call the hash endpoint for malformed blake3-looking values', async () => { + const malformedHash = 'blake3:abc' + const candidates = [ + makeCandidate('1', malformedHash, { isMissing: undefined }) + ] + const fetchInputAssets = vi.fn(async () => [ + makeAsset('legacy.png', malformedHash) + ]) + + await verifyCloudMediaCandidates( + candidates, + undefined, + undefined, + fetchInputAssets + ) + + expect(mockCheckAssetHash).not.toHaveBeenCalled() + expect(fetchInputAssets).toHaveBeenCalledOnce() + expect(candidates[0].isMissing).toBe(false) + }) + + it('deduplicates checks for repeated candidate names', async () => { + const candidates = [ + makeCandidate('1', missingHash, { isMissing: undefined }), + makeCandidate('2', missingHash, { isMissing: undefined }) + ] + + await verifyCloudMediaCandidates(candidates) + + expect(mockCheckAssetHash).toHaveBeenCalledOnce() + expect(candidates[0].isMissing).toBe(true) + expect(candidates[1].isMissing).toBe(true) }) }) diff --git a/src/platform/missingMedia/missingMediaScan.ts b/src/platform/missingMedia/missingMediaScan.ts index 7b4592768b..5050996e06 100644 --- a/src/platform/missingMedia/missingMediaScan.ts +++ b/src/platform/missingMedia/missingMediaScan.ts @@ -18,6 +18,12 @@ import { } from '@/utils/graphTraversalUtil' import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums' import { resolveComboValues } from '@/utils/litegraphUtil' +import type { AssetItem } from '@/platform/assets/schemas/assetSchema' +import type { AssetHashStatus } from '@/platform/assets/services/assetService' +import { + assetService, + isBlake3AssetHash +} from '@/platform/assets/services/assetService' /** Map of node types to their media widget name and media type. */ const MEDIA_NODE_WIDGETS: Record< @@ -106,41 +112,130 @@ export function scanNodeMediaCandidates( return candidates } -interface InputVerifier { - updateInputs: () => Promise<unknown> - inputAssets: Array<{ asset_hash?: string | null; name: string }> +type AssetHashVerifier = ( + assetHash: string, + signal?: AbortSignal +) => Promise<AssetHashStatus> + +type InputAssetFetcher = (signal?: AbortSignal) => Promise<AssetItem[]> + +function groupCandidatesForHashLookup(candidates: MissingMediaCandidate[]): { + candidatesByHash: Map<string, MissingMediaCandidate[]> + legacyCandidates: MissingMediaCandidate[] +} { + const candidatesByHash = new Map<string, MissingMediaCandidate[]>() + const legacyCandidates: MissingMediaCandidate[] = [] + + for (const candidate of candidates) { + if (!isBlake3AssetHash(candidate.name)) { + legacyCandidates.push(candidate) + continue + } + + const hashCandidates = candidatesByHash.get(candidate.name) + if (hashCandidates) hashCandidates.push(candidate) + else candidatesByHash.set(candidate.name, [candidate]) + } + + return { candidatesByHash, legacyCandidates } +} + +async function verifyCandidatesByHash( + candidatesByHash: Map<string, MissingMediaCandidate[]>, + legacyCandidates: MissingMediaCandidate[], + signal: AbortSignal | undefined, + checkAssetHash: AssetHashVerifier +): Promise<void> { + await Promise.all( + Array.from(candidatesByHash, async ([assetHash, hashCandidates]) => { + if (signal?.aborted) return + + let status: AssetHashStatus + try { + status = await checkAssetHash(assetHash, signal) + if (signal?.aborted) return + } catch (err) { + if (signal?.aborted || isAbortError(err)) return + console.warn( + '[Missing Media Pipeline] Failed to verify asset hash:', + err + ) + legacyCandidates.push(...hashCandidates) + return + } + + if (status === 'invalid') { + legacyCandidates.push(...hashCandidates) + return + } + + for (const candidate of hashCandidates) { + candidate.isMissing = status === 'missing' + } + }) + ) } /** - * Verify cloud media candidates against the input assets fetched from the - * assets store. Mutates candidates' `isMissing` in place. + * Verify cloud media candidates by probing the asset hash endpoint first. + * Invalid hash values fall back to the legacy input asset list check. */ export async function verifyCloudMediaCandidates( candidates: MissingMediaCandidate[], signal?: AbortSignal, - assetsStore?: InputVerifier + checkAssetHash: AssetHashVerifier = assetService.checkAssetHash, + fetchInputAssets: InputAssetFetcher = fetchMissingInputAssets ): Promise<void> { if (signal?.aborted) return const pending = candidates.filter((c) => c.isMissing === undefined) if (pending.length === 0) return - const store = - assetsStore ?? (await import('@/stores/assetsStore')).useAssetsStore() + const { candidatesByHash, legacyCandidates } = + groupCandidatesForHashLookup(pending) + await verifyCandidatesByHash( + candidatesByHash, + legacyCandidates, + signal, + checkAssetHash + ) - await store.updateInputs() + if (signal?.aborted || legacyCandidates.length === 0) return + + let inputAssets: AssetItem[] + try { + inputAssets = await fetchInputAssets(signal) + } catch (err) { + if (signal?.aborted || isAbortError(err)) return + throw err + } if (signal?.aborted) return const assetHashes = new Set( - store.inputAssets.map((a) => a.asset_hash).filter((h): h is string => !!h) + inputAssets.map((a) => a.asset_hash).filter((h): h is string => !!h) ) - for (const c of pending) { - c.isMissing = !assetHashes.has(c.name) + for (const candidate of legacyCandidates) { + candidate.isMissing = !assetHashes.has(candidate.name) } } +async function fetchMissingInputAssets( + signal?: AbortSignal +): Promise<AssetItem[]> { + return await assetService.getInputAssetsIncludingPublic(signal) +} + +function isAbortError(err: unknown): boolean { + return ( + typeof err === 'object' && + err !== null && + 'name' in err && + err.name === 'AbortError' + ) +} + /** Group confirmed-missing candidates by file name into view models. */ export function groupCandidatesByName( candidates: MissingMediaCandidate[] diff --git a/src/platform/missingModel/missingModelPipeline.test.ts b/src/platform/missingModel/missingModelPipeline.test.ts new file mode 100644 index 0000000000..b6c1b1cda3 --- /dev/null +++ b/src/platform/missingModel/missingModelPipeline.test.ts @@ -0,0 +1,601 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import type { LGraph } from '@/lib/litegraph/src/litegraph' +import type { MissingModelCandidate } from '@/platform/missingModel/types' +import type { + ComfyWorkflowJSON, + ModelFile +} from '@/platform/workflow/validation/schemas/workflowSchema' +import { + refreshMissingModelPipeline, + runMissingModelPipeline +} from '@/platform/missingModel/missingModelPipeline' + +const { mockHandles } = vi.hoisted(() => { + const state = { + enrichedCandidates: [] as MissingModelCandidate[] + } + + return { + mockHandles: { + state, + missingModelStore: { + missingModelCandidates: null as MissingModelCandidate[] | null, + createVerificationAbortController: vi.fn(() => new AbortController()), + setFolderPaths: vi.fn(), + setFileSize: vi.fn() + }, + workspaceWorkflow: { + activeWorkflow: null as { + activeState?: Pick<ComfyWorkflowJSON, 'models'> | null + pendingWarnings?: unknown + } | null + }, + executionErrorStore: { + surfaceMissingModels: vi.fn() + }, + modelStore: { + loadModelFolders: vi.fn(), + getLoadedModelFolder: vi.fn() + }, + modelToNodeStore: { + getCategoryForNodeType: vi.fn() + }, + scanAllModelCandidates: vi.fn( + ( + _graph: LGraph, + _isAssetSupported: (nodeType: string, widgetName: string) => boolean, + _getDirectory?: (nodeType: string) => string | undefined + ): MissingModelCandidate[] => [] + ), + enrichWithEmbeddedMetadata: vi.fn( + async ( + _candidates: readonly MissingModelCandidate[], + _graphData: ComfyWorkflowJSON, + _checkModelInstalled: ( + name: string, + directory: string + ) => Promise<boolean>, + _isAssetSupported?: (nodeType: string, widgetName: string) => boolean + ) => state.enrichedCandidates + ), + verifyAssetSupportedCandidates: vi.fn( + async ( + _candidates: readonly MissingModelCandidate[], + _signal: AbortSignal + ) => undefined + ), + toastStore: { + add: vi.fn() + }, + assetService: { + shouldUseAssetBrowser: vi.fn() + }, + api: { + getFolderPaths: vi.fn() + }, + fetchModelMetadata: vi.fn(), + isAncestorPathActive: vi.fn((_graph: LGraph, _nodeId: string) => true), + isMissingCandidateActive: vi.fn( + (_graph: LGraph, _candidate: MissingModelCandidate) => true + ) + } + } +}) + +vi.mock('@/platform/distribution/types', () => ({ + isCloud: false +})) + +vi.mock('@/platform/assets/services/assetService', () => ({ + assetService: { + shouldUseAssetBrowser: (nodeType: string, widgetName: string) => + mockHandles.assetService.shouldUseAssetBrowser(nodeType, widgetName) + } +})) + +vi.mock('@/stores/workspaceStore', () => ({ + useWorkspaceStore: () => ({ + workflow: mockHandles.workspaceWorkflow + }) +})) + +vi.mock('@/stores/executionErrorStore', () => ({ + useExecutionErrorStore: () => mockHandles.executionErrorStore +})) + +vi.mock('@/stores/modelStore', () => ({ + useModelStore: () => mockHandles.modelStore +})) + +vi.mock('@/stores/modelToNodeStore', () => ({ + useModelToNodeStore: () => mockHandles.modelToNodeStore +})) + +vi.mock('@/platform/missingModel/missingModelScan', () => ({ + scanAllModelCandidates: ( + graph: LGraph, + isAssetSupported: (nodeType: string, widgetName: string) => boolean, + getDirectory?: (nodeType: string) => string | undefined + ) => + mockHandles.scanAllModelCandidates(graph, isAssetSupported, getDirectory), + enrichWithEmbeddedMetadata: ( + candidates: readonly MissingModelCandidate[], + graphData: ComfyWorkflowJSON, + checkModelInstalled: (name: string, directory: string) => Promise<boolean>, + isAssetSupported?: (nodeType: string, widgetName: string) => boolean + ) => + mockHandles.enrichWithEmbeddedMetadata( + candidates, + graphData, + checkModelInstalled, + isAssetSupported + ), + verifyAssetSupportedCandidates: ( + candidates: readonly MissingModelCandidate[], + signal: AbortSignal + ) => mockHandles.verifyAssetSupportedCandidates(candidates, signal) +})) + +vi.mock('@/platform/updates/common/toastStore', () => ({ + useToastStore: () => mockHandles.toastStore +})) + +vi.mock('@/scripts/api', () => ({ + api: { + getFolderPaths: () => mockHandles.api.getFolderPaths() + } +})) + +vi.mock('@/platform/missingModel/missingModelDownload', () => ({ + fetchModelMetadata: (url: string) => mockHandles.fetchModelMetadata(url) +})) + +vi.mock('@/utils/graphTraversalUtil', () => ({ + isAncestorPathActive: (graph: LGraph, nodeId: string) => + mockHandles.isAncestorPathActive(graph, nodeId), + isMissingCandidateActive: (graph: LGraph, candidate: MissingModelCandidate) => + mockHandles.isMissingCandidateActive(graph, candidate) +})) + +function createWorkflowGraphData(): ComfyWorkflowJSON { + return { + last_node_id: 0, + last_link_id: 0, + nodes: [], + links: [], + groups: [], + config: {}, + extra: {}, + version: 0.4 + } +} + +function createGraph(graphData = createWorkflowGraphData()): LGraph { + return { + serialize: vi.fn(() => graphData) + } as unknown as LGraph +} + +describe('missingModelPipeline', () => { + beforeEach(() => { + vi.clearAllMocks() + mockHandles.state.enrichedCandidates = [] + mockHandles.missingModelStore.missingModelCandidates = null + mockHandles.workspaceWorkflow.activeWorkflow = null + mockHandles.missingModelStore.createVerificationAbortController.mockImplementation( + () => new AbortController() + ) + mockHandles.modelStore.loadModelFolders.mockResolvedValue(undefined) + mockHandles.modelStore.getLoadedModelFolder.mockResolvedValue(undefined) + mockHandles.modelToNodeStore.getCategoryForNodeType.mockReturnValue( + undefined + ) + mockHandles.scanAllModelCandidates.mockReturnValue([]) + mockHandles.api.getFolderPaths.mockResolvedValue({}) + mockHandles.fetchModelMetadata.mockResolvedValue({ fileSize: null }) + mockHandles.isAncestorPathActive.mockReturnValue(true) + mockHandles.isMissingCandidateActive.mockReturnValue(true) + }) + + describe('refreshMissingModelPipeline', () => { + it('reloads node definitions before scanning the current graph', async () => { + const order: string[] = [] + const graph = createGraph() + const reloadNodeDefs = vi.fn(async () => { + order.push('reload') + }) + mockHandles.scanAllModelCandidates.mockImplementation(() => { + order.push('scan') + return [] + }) + + await refreshMissingModelPipeline({ + graph, + reloadNodeDefs, + missingModelStore: mockHandles.missingModelStore + }) + + expect(order).toEqual(['reload', 'scan']) + }) + + it('reuses active workflow model metadata when refreshing the current graph', async () => { + const activeModels: ModelFile[] = [ + { + name: 'embedded.safetensors', + url: 'https://example.com/embedded.safetensors', + directory: 'checkpoints' + } + ] + mockHandles.workspaceWorkflow.activeWorkflow = { + activeState: { models: activeModels }, + pendingWarnings: null + } + mockHandles.missingModelStore.missingModelCandidates = [ + { + nodeId: '1', + nodeType: 'CheckpointLoaderSimple', + widgetName: 'ckpt_name', + name: 'candidate.safetensors', + url: 'https://example.com/candidate.safetensors', + directory: 'checkpoints', + isMissing: true, + isAssetSupported: true + } + ] + + await refreshMissingModelPipeline({ + graph: createGraph(), + reloadNodeDefs: vi.fn(), + missingModelStore: mockHandles.missingModelStore, + silent: false + }) + + expect(mockHandles.enrichWithEmbeddedMetadata).toHaveBeenCalledWith( + expect.any(Array), + expect.objectContaining({ models: activeModels }), + expect.any(Function), + undefined + ) + expect( + mockHandles.executionErrorStore.surfaceMissingModels + ).toHaveBeenCalledWith([], { silent: false }) + }) + + it('falls back to current missing model metadata when workflow state has no models', async () => { + mockHandles.missingModelStore.missingModelCandidates = [ + { + nodeId: '1', + nodeType: 'CheckpointLoaderSimple', + widgetName: 'ckpt_name', + name: 'candidate.safetensors', + url: 'https://example.com/candidate.safetensors', + directory: 'checkpoints', + hash: 'abc123', + hashType: 'sha256', + isMissing: true, + isAssetSupported: true + }, + { + nodeId: '2', + nodeType: 'CheckpointLoaderSimple', + widgetName: 'ckpt_name', + name: 'missing-url.safetensors', + directory: 'checkpoints', + isMissing: true, + isAssetSupported: true + } + ] + + await refreshMissingModelPipeline({ + graph: createGraph(), + reloadNodeDefs: vi.fn(), + missingModelStore: mockHandles.missingModelStore + }) + + expect(mockHandles.enrichWithEmbeddedMetadata).toHaveBeenCalledWith( + expect.any(Array), + expect.objectContaining({ + models: [ + { + name: 'candidate.safetensors', + url: 'https://example.com/candidate.safetensors', + directory: 'checkpoints', + hash: 'abc123', + hash_type: 'sha256' + } + ] + }), + expect.any(Function), + undefined + ) + expect( + mockHandles.executionErrorStore.surfaceMissingModels + ).toHaveBeenCalledWith([], { silent: true }) + }) + + it('does not add model metadata when no active workflow or current candidate metadata exists', async () => { + const graphData = createWorkflowGraphData() + + await refreshMissingModelPipeline({ + graph: createGraph(graphData), + reloadNodeDefs: vi.fn(), + missingModelStore: mockHandles.missingModelStore + }) + + expect(mockHandles.enrichWithEmbeddedMetadata).toHaveBeenCalledWith( + expect.any(Array), + graphData, + expect.any(Function), + undefined + ) + }) + + it('rejects when injected node definition reload fails', async () => { + const error = new Error('object_info failed') + + await expect( + refreshMissingModelPipeline({ + graph: createGraph(), + reloadNodeDefs: vi.fn().mockRejectedValue(error), + missingModelStore: mockHandles.missingModelStore + }) + ).rejects.toThrow(error) + + expect(mockHandles.scanAllModelCandidates).not.toHaveBeenCalled() + }) + }) + + describe('runMissingModelPipeline', () => { + it('returns confirmed missing models and caches pending warning candidates', async () => { + const confirmedCandidate = { + nodeType: 'CheckpointLoaderSimple', + widgetName: 'ckpt_name', + name: 'missing.safetensors', + url: 'https://example.com/missing.safetensors', + directory: 'checkpoints', + isMissing: true, + isAssetSupported: true + } satisfies MissingModelCandidate + const installedCandidate = { + nodeType: 'CheckpointLoaderSimple', + widgetName: 'ckpt_name', + name: 'installed.safetensors', + directory: 'checkpoints', + isMissing: false, + isAssetSupported: true + } satisfies MissingModelCandidate + const activeWorkflow = { + activeState: null, + pendingWarnings: null + } + mockHandles.state.enrichedCandidates = [ + confirmedCandidate, + installedCandidate + ] + mockHandles.workspaceWorkflow.activeWorkflow = activeWorkflow + + const result = await runMissingModelPipeline({ + graph: createGraph(), + graphData: createWorkflowGraphData(), + missingModelStore: mockHandles.missingModelStore, + missingNodeTypes: ['MissingCustomNode'] + }) + await vi.dynamicImportSettled() + + expect(result).toEqual({ + missingModels: [ + { + name: 'missing.safetensors', + url: 'https://example.com/missing.safetensors', + directory: 'checkpoints', + hash: undefined, + hash_type: undefined + } + ], + confirmedCandidates: [confirmedCandidate] + }) + expect(activeWorkflow.pendingWarnings).toEqual({ + missingNodeTypes: ['MissingCustomNode'], + missingModelCandidates: [confirmedCandidate], + missingMediaCandidates: undefined + }) + }) + + it('does not expose downloadable model metadata without a directory', async () => { + const confirmedCandidate = { + nodeType: 'CheckpointLoaderSimple', + widgetName: 'ckpt_name', + name: 'missing.safetensors', + url: 'https://example.com/missing.safetensors', + isMissing: true, + isAssetSupported: true + } satisfies MissingModelCandidate + mockHandles.state.enrichedCandidates = [confirmedCandidate] + + const result = await runMissingModelPipeline({ + graph: createGraph(), + graphData: createWorkflowGraphData(), + missingModelStore: mockHandles.missingModelStore + }) + + expect(result).toEqual({ + missingModels: [], + confirmedCandidates: [confirmedCandidate] + }) + }) + + it('fetches file sizes only for candidates with complete download metadata', async () => { + const downloadableCandidate = { + nodeType: 'CheckpointLoaderSimple', + widgetName: 'ckpt_name', + name: 'downloadable.safetensors', + url: 'https://example.com/downloadable.safetensors', + directory: 'checkpoints', + isMissing: true, + isAssetSupported: true + } satisfies MissingModelCandidate + const urlOnlyCandidate = { + nodeType: 'CheckpointLoaderSimple', + widgetName: 'ckpt_name', + name: 'url-only.safetensors', + url: 'https://example.com/url-only.safetensors', + isMissing: true, + isAssetSupported: true + } satisfies MissingModelCandidate + mockHandles.state.enrichedCandidates = [ + downloadableCandidate, + urlOnlyCandidate + ] + mockHandles.fetchModelMetadata.mockResolvedValue({ fileSize: 1024 }) + + await runMissingModelPipeline({ + graph: createGraph(), + graphData: createWorkflowGraphData(), + missingModelStore: mockHandles.missingModelStore + }) + await vi.dynamicImportSettled() + + expect(mockHandles.fetchModelMetadata).toHaveBeenCalledOnce() + expect(mockHandles.fetchModelMetadata).toHaveBeenCalledWith( + 'https://example.com/downloadable.safetensors' + ) + expect(mockHandles.missingModelStore.setFileSize).toHaveBeenCalledWith( + 'https://example.com/downloadable.safetensors', + 1024 + ) + }) + + it('clears surfaced and cached missing models when no candidates are confirmed missing', async () => { + const installedCandidate = { + nodeType: 'CheckpointLoaderSimple', + widgetName: 'ckpt_name', + name: 'installed.safetensors', + directory: 'checkpoints', + isMissing: false, + isAssetSupported: true + } satisfies MissingModelCandidate + const activeWorkflow = { + activeState: null, + pendingWarnings: { + missingModelCandidates: [ + { + nodeType: 'CheckpointLoaderSimple', + widgetName: 'ckpt_name', + name: 'stale.safetensors', + directory: 'checkpoints', + isMissing: true, + isAssetSupported: true + } + ], + missingNodeTypes: undefined, + missingMediaCandidates: undefined + } + } + mockHandles.state.enrichedCandidates = [installedCandidate] + mockHandles.workspaceWorkflow.activeWorkflow = activeWorkflow + + await runMissingModelPipeline({ + graph: createGraph(), + graphData: createWorkflowGraphData(), + missingModelStore: mockHandles.missingModelStore + }) + + expect( + mockHandles.executionErrorStore.surfaceMissingModels + ).toHaveBeenCalledWith([], { silent: false }) + expect(activeWorkflow.pendingWarnings).toBeNull() + }) + + it('drops candidates whose ancestor path is inactive', async () => { + const activeCandidate = { + nodeId: '1', + nodeType: 'CheckpointLoaderSimple', + widgetName: 'ckpt_name', + name: 'active.safetensors', + directory: 'checkpoints', + isMissing: true, + isAssetSupported: true + } satisfies MissingModelCandidate + const inactiveCandidate = { + nodeId: '2', + nodeType: 'CheckpointLoaderSimple', + widgetName: 'ckpt_name', + name: 'inactive.safetensors', + directory: 'checkpoints', + isMissing: true, + isAssetSupported: true + } satisfies MissingModelCandidate + const activeWorkflow = { + activeState: null, + pendingWarnings: null + } + const graph = createGraph() + mockHandles.state.enrichedCandidates = [ + activeCandidate, + inactiveCandidate + ] + mockHandles.workspaceWorkflow.activeWorkflow = activeWorkflow + mockHandles.isAncestorPathActive.mockImplementation( + (_graph: LGraph, nodeId: string) => nodeId !== '2' + ) + + const result = await runMissingModelPipeline({ + graph, + graphData: createWorkflowGraphData(), + missingModelStore: mockHandles.missingModelStore + }) + + expect(result.confirmedCandidates).toEqual([activeCandidate]) + expect(activeWorkflow.pendingWarnings).toEqual({ + missingNodeTypes: undefined, + missingModelCandidates: [activeCandidate], + missingMediaCandidates: undefined + }) + }) + + it('skips post-fetch surface when folder path refresh is aborted', async () => { + const controller = new AbortController() + const confirmedCandidate = { + nodeId: '1', + nodeType: 'CheckpointLoaderSimple', + widgetName: 'ckpt_name', + name: 'missing.safetensors', + directory: 'checkpoints', + isMissing: true, + isAssetSupported: true + } satisfies MissingModelCandidate + let resolveFolderPaths!: (paths: Record<string, string[]>) => void + const folderPathsPromise = new Promise<Record<string, string[]>>( + (resolve) => { + resolveFolderPaths = resolve + } + ) + mockHandles.state.enrichedCandidates = [confirmedCandidate] + mockHandles.missingModelStore.createVerificationAbortController.mockReturnValueOnce( + controller + ) + mockHandles.api.getFolderPaths.mockReturnValueOnce(folderPathsPromise) + + await runMissingModelPipeline({ + graph: createGraph(), + graphData: createWorkflowGraphData(), + missingModelStore: mockHandles.missingModelStore + }) + + controller.abort() + resolveFolderPaths({ checkpoints: ['/models/checkpoints'] }) + await folderPathsPromise + // Settle both .then() and .finally() microtasks on getFolderPaths(). + await Promise.resolve() + await Promise.resolve() + + expect( + mockHandles.missingModelStore.setFolderPaths + ).not.toHaveBeenCalled() + expect( + mockHandles.executionErrorStore.surfaceMissingModels + ).not.toHaveBeenCalled() + }) + }) +}) diff --git a/src/platform/missingModel/missingModelPipeline.ts b/src/platform/missingModel/missingModelPipeline.ts new file mode 100644 index 0000000000..8ccb71018e --- /dev/null +++ b/src/platform/missingModel/missingModelPipeline.ts @@ -0,0 +1,260 @@ +import { st } from '@/i18n' +import type { LGraph } from '@/lib/litegraph/src/litegraph' +import { assetService } from '@/platform/assets/services/assetService' +import { isCloud } from '@/platform/distribution/types' +import { + enrichWithEmbeddedMetadata, + scanAllModelCandidates, + verifyAssetSupportedCandidates +} from '@/platform/missingModel/missingModelScan' +import type { MissingModelWorkflowData } from '@/platform/missingModel/missingModelScan' +import type { MissingModelCandidate } from '@/platform/missingModel/types' +import { useToastStore } from '@/platform/updates/common/toastStore' +import { updatePendingWarnings } from '@/platform/workflow/core/utils/pendingWarnings' +import type { ComfyWorkflow } from '@/platform/workflow/management/stores/comfyWorkflow' +import type { ModelFile } from '@/platform/workflow/validation/schemas/workflowSchema' +import { api } from '@/scripts/api' +import { useExecutionErrorStore } from '@/stores/executionErrorStore' +import { useModelStore } from '@/stores/modelStore' +import { useModelToNodeStore } from '@/stores/modelToNodeStore' +import { useWorkspaceStore } from '@/stores/workspaceStore' +import type { MissingNodeType } from '@/types/comfy' +import { + isAncestorPathActive, + isMissingCandidateActive +} from '@/utils/graphTraversalUtil' + +export interface MissingModelPipelineResult { + missingModels: ModelFile[] + confirmedCandidates: MissingModelCandidate[] +} + +interface MissingModelPipelineStore { + missingModelCandidates: MissingModelCandidate[] | null + createVerificationAbortController: () => AbortController + setFolderPaths: (paths: Record<string, string[]>) => void + setFileSize: (url: string, size: number) => void +} + +interface RunMissingModelPipelineOptions { + graph: LGraph + graphData: MissingModelWorkflowData + missingModelStore: MissingModelPipelineStore + missingNodeTypes?: MissingNodeType[] + silent?: boolean +} + +interface RefreshMissingModelPipelineOptions { + graph: LGraph + reloadNodeDefs: () => Promise<void> + missingModelStore: MissingModelPipelineStore + silent?: boolean +} + +type MissingModelCandidateWithDownloadMetadata = MissingModelCandidate & { + url: string + directory: string +} + +function cacheModelCandidates( + wf: Pick<ComfyWorkflow, 'pendingWarnings'> | null | undefined, + confirmed: MissingModelCandidate[] +) { + if (!wf) return + updatePendingWarnings(wf, { + missingModelCandidates: confirmed + }) +} + +function clearMissingModels( + wf: Pick<ComfyWorkflow, 'pendingWarnings'> | null | undefined, + silent: boolean +) { + useExecutionErrorStore().surfaceMissingModels([], { silent }) + cacheModelCandidates(wf, []) +} + +function hasDownloadMetadata( + candidate: MissingModelCandidate +): candidate is MissingModelCandidateWithDownloadMetadata { + return !!candidate.url && !!candidate.directory +} + +function toModelFile(candidate: MissingModelCandidateWithDownloadMetadata) { + return { + name: candidate.name, + url: candidate.url, + directory: candidate.directory, + hash: candidate.hash, + hash_type: candidate.hashType + } +} + +function getCurrentMissingModelMetadata( + missingModelStore: MissingModelPipelineStore +): ModelFile[] { + return ( + missingModelStore.missingModelCandidates + ?.filter(hasDownloadMetadata) + .map(toModelFile) ?? [] + ) +} + +export async function runMissingModelPipeline({ + graph, + graphData, + missingModelStore, + missingNodeTypes, + silent = false +}: RunMissingModelPipelineOptions): Promise<MissingModelPipelineResult> { + const controller = missingModelStore.createVerificationAbortController() + + const getDirectory = (nodeType: string) => + useModelToNodeStore().getCategoryForNodeType(nodeType) + const isAssetBrowserWidget = isCloud + ? assetService.shouldUseAssetBrowser + : () => false + + const candidates = scanAllModelCandidates( + graph, + isAssetBrowserWidget, + getDirectory + ) + + const modelStore = useModelStore() + await modelStore.loadModelFolders() + const enrichedAll = await enrichWithEmbeddedMetadata( + candidates, + graphData, + async (name, directory) => { + const folder = await modelStore.getLoadedModelFolder(directory) + const models = folder?.models + return !!( + models && Object.values(models).some((m) => m.file_name === name) + ) + }, + isCloud ? isAssetBrowserWidget : undefined + ) + + // Drop candidates whose enclosing subgraph is muted/bypassed. Per-node + // scans only checked each node's own mode; the cascade from an + // inactive container to its interior happens here. + // Asymmetric on purpose: a candidate dropped here is not resurrected if + // the user un-bypasses the container mid-verification. The realtime + // mode-change path (handleNodeModeChange → scanAndAddNodeErrors) is + // responsible for surfacing errors after an un-bypass. + const enrichedCandidates = enrichedAll.filter( + (c) => c.nodeId == null || isAncestorPathActive(graph, String(c.nodeId)) + ) + + const confirmedCandidates = enrichedCandidates.filter( + (c) => c.isMissing === true + ) + const downloadableCandidates = confirmedCandidates.filter(hasDownloadMetadata) + + const missingModels: ModelFile[] = downloadableCandidates.map(toModelFile) + + const activeWf = useWorkspaceStore().workflow.activeWorkflow + updatePendingWarnings(activeWf, { + ...(missingNodeTypes ? { missingNodeTypes } : {}), + missingModelCandidates: confirmedCandidates + }) + + if (enrichedCandidates.length) { + if (isCloud) { + void verifyAssetSupportedCandidates(enrichedCandidates, controller.signal) + .then(() => { + if (controller.signal.aborted) return + // Re-check ancestor: user may have bypassed a container + // while verification was in flight. + const confirmedAfterReverify = enrichedCandidates.filter((c) => + isMissingCandidateActive(graph, c) + ) + useExecutionErrorStore().surfaceMissingModels( + confirmedAfterReverify, + { silent } + ) + cacheModelCandidates(activeWf, confirmedAfterReverify) + }) + .catch((err) => { + if (controller.signal.aborted) return + console.warn( + '[Missing Model Pipeline] Asset verification failed:', + err + ) + useToastStore().add({ + severity: 'warn', + summary: st( + 'toastMessages.missingModelVerificationFailed', + 'Failed to verify missing models. Some models may not be shown in the Errors tab.' + ), + life: 5000 + }) + }) + } else { + if (!confirmedCandidates.length) { + clearMissingModels(activeWf, silent) + return { missingModels, confirmedCandidates } + } + + void api + .getFolderPaths() + .then((paths) => { + if (controller.signal.aborted) return + missingModelStore.setFolderPaths(paths) + }) + .catch((err) => { + console.warn( + '[Missing Model Pipeline] Failed to fetch folder paths:', + err + ) + }) + .finally(() => { + if (controller.signal.aborted) return + useExecutionErrorStore().surfaceMissingModels(confirmedCandidates, { + silent + }) + cacheModelCandidates(activeWf, confirmedCandidates) + }) + + const missingModelDownload = + import('@/platform/missingModel/missingModelDownload') + void Promise.allSettled( + downloadableCandidates.map(async (c) => { + const { fetchModelMetadata } = await missingModelDownload + const metadata = await fetchModelMetadata(c.url) + if (!controller.signal.aborted && metadata.fileSize !== null) { + missingModelStore.setFileSize(c.url, metadata.fileSize) + } + }) + ) + } + } else { + clearMissingModels(activeWf, silent) + } + + return { missingModels, confirmedCandidates } +} + +export async function refreshMissingModelPipeline({ + graph, + reloadNodeDefs, + missingModelStore, + silent = true +}: RefreshMissingModelPipelineOptions): Promise<MissingModelPipelineResult> { + await reloadNodeDefs() + const graphData: MissingModelWorkflowData = graph.serialize() + const activeWorkflowState = + useWorkspaceStore().workflow.activeWorkflow?.activeState + const currentModelMetadata = getCurrentMissingModelMetadata(missingModelStore) + const models = activeWorkflowState?.models?.length + ? activeWorkflowState.models + : currentModelMetadata + + return runMissingModelPipeline({ + graph, + graphData: models.length ? { ...graphData, models } : graphData, + missingModelStore, + silent + }) +} diff --git a/src/platform/missingModel/missingModelScan.test.ts b/src/platform/missingModel/missingModelScan.test.ts index cc26dbc609..05326f8bb0 100644 --- a/src/platform/missingModel/missingModelScan.test.ts +++ b/src/platform/missingModel/missingModelScan.test.ts @@ -19,6 +19,11 @@ import activeSubgraphUnmatchedModel from '@/platform/missingModel/__fixtures__/a import bypassedSubgraphUnmatchedModel from '@/platform/missingModel/__fixtures__/bypassedSubgraphUnmatchedModel.json' with { type: 'json' } import type { MissingModelCandidate } from '@/platform/missingModel/types' import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema' +import type * as AssetServiceModule from '@/platform/assets/services/assetService' + +const { mockCheckAssetHash } = vi.hoisted(() => ({ + mockCheckAssetHash: vi.fn() +})) vi.mock('@/utils/graphTraversalUtil', () => ({ collectAllNodes: (graph: { _testNodes: LGraphNode[] }) => graph._testNodes, @@ -28,6 +33,20 @@ vi.mock('@/utils/graphTraversalUtil', () => ({ ) => node._testExecutionId ?? String(node.id) })) +vi.mock('@/platform/assets/services/assetService', async () => { + const actual = await vi.importActual<typeof AssetServiceModule>( + '@/platform/assets/services/assetService' + ) + + return { + ...actual, + assetService: { + ...actual.assetService, + checkAssetHash: mockCheckAssetHash + } + } +}) + /** Helper: create a combo widget mock */ function makeComboWidget( name: string, @@ -43,7 +62,7 @@ function makeComboWidget( } /** Helper: create an asset widget mock (Cloud combo replacement) */ -function makeAssetWidget(name: string, value: string): IBaseWidget { +function makeAssetWidget(name: string, value: unknown): IBaseWidget { return fromAny<IBaseWidget, unknown>({ type: 'asset', name, @@ -551,6 +570,16 @@ describe('scanAllModelCandidates', () => { expect(result).toEqual([]) }) + it('should skip asset widgets with non-string values', () => { + const graph = makeGraph([ + makeNode(1, 'SomeNode', [makeAssetWidget('ckpt_name', 123)]) + ]) + + const result = scanAllModelCandidates(graph, noAssetSupport) + + expect(result).toEqual([]) + }) + it('should scan both combo and asset widgets on the same node', () => { const graph = makeGraph([ makeNode(1, 'DualLoaderNode', [ @@ -1411,6 +1440,7 @@ function makeAssetCandidate( describe('verifyAssetSupportedCandidates', () => { beforeEach(() => { vi.clearAllMocks() + mockCheckAssetHash.mockResolvedValue('missing') mockIsModelLoading.mockReturnValue(false) mockHasMore.mockReturnValue(false) mockGetAssets.mockReturnValue([]) @@ -1428,6 +1458,125 @@ describe('verifyAssetSupportedCandidates', () => { ) }) + it('should resolve isMissing=false when the blake3 hash endpoint finds the asset', async () => { + const hash = + '1111111111111111111111111111111111111111111111111111111111111111' + const candidates = [ + makeAssetCandidate('model.safetensors', { + hash, + hashType: 'blake3' + }) + ] + mockCheckAssetHash.mockResolvedValue('exists') + + await verifyAssetSupportedCandidates(candidates) + + expect(candidates[0].isMissing).toBe(false) + expect(mockCheckAssetHash).toHaveBeenCalledWith(`blake3:${hash}`, undefined) + expect(mockUpdateModelsForNodeType).not.toHaveBeenCalled() + }) + + it('should fall back to asset store matching when the blake3 hash is not found', async () => { + const hash = + '2222222222222222222222222222222222222222222222222222222222222222' + const candidates = [ + makeAssetCandidate('my_model.safetensors', { + hash, + hashType: 'blake3' + }) + ] + mockCheckAssetHash.mockResolvedValue('missing') + mockGetAssets.mockReturnValue([ + { + id: '1', + name: 'my_model.safetensors', + asset_hash: null, + metadata: { filename: 'my_model.safetensors' } + } + ]) + + await verifyAssetSupportedCandidates(candidates) + + expect(candidates[0].isMissing).toBe(false) + expect(mockUpdateModelsForNodeType).toHaveBeenCalledWith( + 'CheckpointLoaderSimple' + ) + }) + + it('should fall back to asset store matching when hash verification fails', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const hash = + '3333333333333333333333333333333333333333333333333333333333333333' + const candidates = [ + makeAssetCandidate('my_model.safetensors', { + hash, + hashType: 'blake3' + }) + ] + mockCheckAssetHash.mockRejectedValue(new Error('network failed')) + mockGetAssets.mockReturnValue([ + { + id: '1', + name: 'my_model.safetensors', + asset_hash: null, + metadata: { filename: 'my_model.safetensors' } + } + ]) + + await verifyAssetSupportedCandidates(candidates) + + expect(candidates[0].isMissing).toBe(false) + expect(mockUpdateModelsForNodeType).toHaveBeenCalledWith( + 'CheckpointLoaderSimple' + ) + expect(warn).toHaveBeenCalledOnce() + warn.mockRestore() + }) + + it('should skip malformed blake3 hashes and use asset store matching', async () => { + const candidates = [ + makeAssetCandidate('my_model.safetensors', { + hash: 'abc123', + hashType: 'blake3' + }) + ] + mockGetAssets.mockReturnValue([ + { + id: '1', + name: 'my_model.safetensors', + asset_hash: null, + metadata: { filename: 'my_model.safetensors' } + } + ]) + + await verifyAssetSupportedCandidates(candidates) + + expect(mockCheckAssetHash).not.toHaveBeenCalled() + expect(candidates[0].isMissing).toBe(false) + }) + + it('should not warn or fall back when hash verification is aborted', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const abortError = new Error('aborted') + abortError.name = 'AbortError' + const hash = + '4444444444444444444444444444444444444444444444444444444444444444' + const candidates = [ + makeAssetCandidate('my_model.safetensors', { + hash, + hashType: 'blake3' + }) + ] + mockCheckAssetHash.mockRejectedValue(abortError) + + await verifyAssetSupportedCandidates(candidates) + + expect(candidates[0].isMissing).toBeUndefined() + expect(mockUpdateModelsForNodeType).not.toHaveBeenCalled() + expect(warn).not.toHaveBeenCalled() + warn.mockRestore() + }) + it('should resolve isMissing=false when asset with matching hash exists', async () => { const candidates = [ makeAssetCandidate('model.safetensors', { @@ -1442,6 +1591,7 @@ describe('verifyAssetSupportedCandidates', () => { await verifyAssetSupportedCandidates(candidates) expect(candidates[0].isMissing).toBe(false) + expect(mockCheckAssetHash).not.toHaveBeenCalled() }) it('should resolve isMissing=false when asset with matching filename exists', async () => { diff --git a/src/platform/missingModel/missingModelScan.ts b/src/platform/missingModel/missingModelScan.ts index 54d64f4c83..bef803112a 100644 --- a/src/platform/missingModel/missingModelScan.ts +++ b/src/platform/missingModel/missingModelScan.ts @@ -1,9 +1,6 @@ -import type { - ComfyWorkflowJSON, - ModelFile, - NodeId -} from '@/platform/workflow/validation/schemas/workflowSchema' -import { flattenWorkflowNodes } from '@/platform/workflow/validation/schemas/workflowSchema' +import type { ModelFile } from '@/platform/workflow/validation/schemas/workflowSchema' +import type { FlattenableWorkflowGraph } from '@/platform/workflow/core/utils/workflowFlattening' +import { flattenWorkflowNodes } from '@/platform/workflow/core/utils/workflowFlattening' import type { MissingModelCandidate, MissingModelViewModel, @@ -27,6 +24,15 @@ import { } from '@/utils/graphTraversalUtil' import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums' import { resolveComboValues } from '@/utils/litegraphUtil' +import type { AssetHashStatus } from '@/platform/assets/services/assetService' +import { + assetService, + toBlake3AssetHash +} from '@/platform/assets/services/assetService' + +export type MissingModelWorkflowData = FlattenableWorkflowGraph & { + models?: ModelFile[] +} function isComboWidget(widget: IBaseWidget): widget is IComboWidget { return widget.type === 'combo' @@ -176,11 +182,11 @@ function scanAssetWidget( getDirectory: ((nodeType: string) => string | undefined) | undefined ): MissingModelCandidate | null { const value = widget.value - if (!value.trim()) return null + if (typeof value !== 'string' || !value.trim()) return null if (!isModelFileName(value)) return null return { - nodeId: executionId as NodeId, + nodeId: executionId, nodeType: node.type, widgetName: widget.name, isAssetSupported: true, @@ -206,7 +212,7 @@ function scanComboWidget( const inOptions = options.includes(value) return { - nodeId: executionId as NodeId, + nodeId: executionId, nodeType: node.type, widgetName: widget.name, isAssetSupported: nodeIsAssetSupported, @@ -218,7 +224,7 @@ function scanComboWidget( export async function enrichWithEmbeddedMetadata( candidates: readonly MissingModelCandidate[], - graphData: ComfyWorkflowJSON, + graphData: MissingModelWorkflowData, checkModelInstalled: (name: string, directory: string) => Promise<boolean>, isAssetSupported?: (nodeType: string, widgetName: string) => boolean ): Promise<MissingModelCandidate[]> { @@ -388,7 +394,7 @@ function isAncestorPathActiveInFlattened( function collectEmbeddedModelsWithSource( allNodes: ReturnType<typeof flattenWorkflowNodes>, - graphData: ComfyWorkflowJSON + graphData: MissingModelWorkflowData ): EmbeddedModelWithSource[] { const result: EmbeddedModelWithSource[] = [] @@ -399,9 +405,7 @@ function collectEmbeddedModelsWithSource( ) continue - const selected = getSelectedModelsMetadata( - node as Parameters<typeof getSelectedModelsMetadata>[0] - ) + const selected = getSelectedModelsMetadata(node) if (!selected?.length) continue for (const model of selected) { @@ -435,8 +439,7 @@ function findWidgetNameForModel( modelName: string ): string { if (Array.isArray(node.widgets_values) || !node.widgets_values) return '' - const wv = node.widgets_values as Record<string, unknown> - for (const [key, val] of Object.entries(wv)) { + for (const [key, val] of Object.entries(node.widgets_values)) { if (val === modelName) return key } return '' @@ -447,20 +450,68 @@ interface AssetVerifier { getAssets: (nodeType: string) => AssetItem[] | undefined } +type AssetHashVerifier = ( + assetHash: string, + signal?: AbortSignal +) => Promise<AssetHashStatus> + export async function verifyAssetSupportedCandidates( candidates: MissingModelCandidate[], signal?: AbortSignal, - assetsStore?: AssetVerifier + assetsStore?: AssetVerifier, + checkAssetHash: AssetHashVerifier = assetService.checkAssetHash ): Promise<void> { if (signal?.aborted) return + const pendingCandidates = candidates.filter( + (c) => c.isAssetSupported && c.isMissing === undefined + ) + if (pendingCandidates.length === 0) return + const pendingNodeTypes = new Set<string>() - for (const c of candidates) { - if (c.isAssetSupported && c.isMissing === undefined) { - pendingNodeTypes.add(c.nodeType) + const candidatesByHash = new Map<string, MissingModelCandidate[]>() + + for (const candidate of pendingCandidates) { + const assetHash = getBlake3AssetHash(candidate) + if (!assetHash) { + pendingNodeTypes.add(candidate.nodeType) + continue } + + const hashCandidates = candidatesByHash.get(assetHash) + if (hashCandidates) hashCandidates.push(candidate) + else candidatesByHash.set(assetHash, [candidate]) } + await Promise.all( + Array.from(candidatesByHash, async ([assetHash, hashCandidates]) => { + if (signal?.aborted) return + + try { + const status = await checkAssetHash(assetHash, signal) + if (signal?.aborted) return + + if (status === 'exists') { + for (const candidate of hashCandidates) { + candidate.isMissing = false + } + return + } + } catch (err) { + if (signal?.aborted || isAbortError(err)) return + console.warn( + '[Missing Model Pipeline] Failed to verify asset hash:', + err + ) + } + + for (const candidate of hashCandidates) { + pendingNodeTypes.add(candidate.nodeType) + } + }) + ) + + if (signal?.aborted) return if (pendingNodeTypes.size === 0) return const store = @@ -493,6 +544,20 @@ export async function verifyAssetSupportedCandidates( } } +function getBlake3AssetHash(candidate: MissingModelCandidate): string | null { + if (candidate.hashType?.toLowerCase() !== 'blake3') return null + return toBlake3AssetHash(candidate.hash) +} + +function isAbortError(err: unknown): boolean { + return ( + typeof err === 'object' && + err !== null && + 'name' in err && + err.name === 'AbortError' + ) +} + function normalizePath(path: string): string { return path.replace(/\\/g, '/') } diff --git a/src/platform/remoteConfig/types.ts b/src/platform/remoteConfig/types.ts index fbae538fdf..f2134aa513 100644 --- a/src/platform/remoteConfig/types.ts +++ b/src/platform/remoteConfig/types.ts @@ -23,6 +23,54 @@ type FirebaseRuntimeConfig = { measurementId?: string } +/** + * Server-driven onboarding survey schema. + * + * The backend ships the entire form definition so onboarding questions can + * be tweaked without a frontend release. Field types map 1:1 to a component + * in our internal UI library — see `DynamicSurveyField.vue`. + */ +export type OnboardingSurveyFieldType = 'single' | 'multi' | 'text' + +/** + * A translatable string. Either: + * - a single literal (treated as the fallback in any locale), or + * - a locale → text map, e.g. `{ en: 'Personal use', ko: '개인 용도' }`, + * so the backend can ship translations without a frontend release. + */ +export type LocalizedString = string | Record<string, string> + +export type OnboardingSurveyOption = { + value: string + label?: LocalizedString + labelKey?: string +} + +export type OnboardingSurveyFieldCondition = { + field: string + equals?: string | string[] +} + +export type OnboardingSurveyField = { + id: string + type: OnboardingSurveyFieldType + labelKey?: string + label?: LocalizedString + options?: OnboardingSurveyOption[] + required?: boolean + randomize?: boolean + allowOther?: boolean + otherFieldId?: string + placeholder?: string + showWhen?: OnboardingSurveyFieldCondition +} + +export type OnboardingSurvey = { + version: number + introKey?: string + fields: OnboardingSurveyField[] +} + /** * Remote configuration type * Configuration fetched from the server at runtime @@ -45,6 +93,7 @@ export type RemoteConfig = { asset_rename_enabled?: boolean private_models_enabled?: boolean onboarding_survey_enabled?: boolean + onboarding_survey?: OnboardingSurvey linear_toggle_enabled?: boolean team_workspaces_enabled?: boolean user_secrets_enabled?: boolean diff --git a/src/platform/secrets/components/SecretListItem.stories.ts b/src/platform/secrets/components/SecretListItem.stories.ts new file mode 100644 index 0000000000..931accd93d --- /dev/null +++ b/src/platform/secrets/components/SecretListItem.stories.ts @@ -0,0 +1,64 @@ +import type { Meta, StoryObj } from '@storybook/vue3-vite' + +import type { SecretMetadata } from '../types' +import SecretListItem from './SecretListItem.vue' + +const sampleSecret: SecretMetadata = { + id: 'secret-huggingface', + name: 'HuggingFace API Key', + provider: 'huggingface', + created_at: '2026-02-06T10:00:00Z', + updated_at: '2026-02-06T10:00:00Z', + last_used_at: '2026-04-17T10:00:00Z' +} + +const meta: Meta<typeof SecretListItem> = { + title: 'Platform/Secrets/SecretListItem', + component: SecretListItem, + parameters: { + layout: 'centered' + }, + decorators: [ + () => ({ + template: ` + <div class="w-[480px] bg-base-background p-8"> + <story /> + </div> + ` + }) + ], + args: { + secret: sampleSecret, + loading: false, + disabled: false + } +} + +export default meta +type Story = StoryObj<typeof meta> + +export const Default: Story = {} + +export const NeverUsed: Story = { + args: { + secret: { + ...sampleSecret, + id: 'secret-civitai', + name: 'Civitai Token', + provider: 'civitai', + last_used_at: undefined + } + } +} + +export const Loading: Story = { + args: { + loading: true + } +} + +export const Disabled: Story = { + args: { + disabled: true + } +} diff --git a/src/platform/secrets/components/SecretListItem.test.ts b/src/platform/secrets/components/SecretListItem.test.ts index 8c63188059..e1648ce14c 100644 --- a/src/platform/secrets/components/SecretListItem.test.ts +++ b/src/platform/secrets/components/SecretListItem.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest' import { render, screen } from '@testing-library/vue' import userEvent from '@testing-library/user-event' +import { createI18n } from 'vue-i18n' import type { SecretMetadata } from '../types' import SecretListItem from './SecretListItem.vue' @@ -15,6 +16,28 @@ vi.mock('../providers', () => ({ getProviderLogo: () => undefined })) +const i18n = createI18n({ + legacy: false, + locale: 'en', + escapeParameter: true, + messages: { + en: { + g: { + edit: 'Edit', + delete: 'Delete' + }, + secrets: { + createdAt: 'Created {date}', + lastUsed: 'Last used {date}' + } + } + } +}) + +function formatExpectedDate(dateString: string): string { + return i18n.global.d(new Date(dateString), { dateStyle: 'medium' }) +} + function createMockSecret( overrides: Partial<SecretMetadata> = {} ): SecretMetadata { @@ -36,6 +59,7 @@ function renderComponent(props: { return render(SecretListItem, { props, global: { + plugins: [i18n], stubs: { Button: { template: @@ -45,10 +69,6 @@ function renderComponent(props: { }, directives: { tooltip: () => {} - }, - mocks: { - $t: (key: string, params?: object) => - `${key}${params ? JSON.stringify(params) : ''}` } } }) @@ -89,21 +109,40 @@ describe('SecretListItem', () => { const secret = createMockSecret({ created_at: '2024-01-15T10:00:00Z' }) renderComponent({ secret }) - expect(screen.getByText(/secrets\.createdAt/)).toBeInTheDocument() + expect( + screen.getByText(`Created ${formatExpectedDate(secret.created_at)}`) + ).toBeInTheDocument() }) it('displays last used date when available', () => { const secret = createMockSecret({ last_used_at: '2024-01-20T10:00:00Z' }) renderComponent({ secret }) - expect(screen.getByText(/secrets\.lastUsed/)).toBeInTheDocument() + expect( + screen.getByText( + `Last used ${formatExpectedDate(secret.last_used_at!)}` + ) + ).toBeInTheDocument() }) it('hides last used when not available', () => { const secret = createMockSecret({ last_used_at: undefined }) renderComponent({ secret }) - expect(screen.queryByText(/secrets\.lastUsed/)).not.toBeInTheDocument() + expect(screen.queryByText(/^Last used /)).not.toBeInTheDocument() + }) + + it('renders formatted dates without escaped slash entities', () => { + const secret = createMockSecret({ + created_at: '2026-02-06T10:00:00Z', + last_used_at: '2026-04-17T10:00:00Z' + }) + renderComponent({ secret }) + + expect(screen.queryByText(///)).not.toBeInTheDocument() + expect( + screen.getByText(`Created ${formatExpectedDate(secret.created_at)}`) + ).toBeInTheDocument() }) it('renders created date for ISO string with 4-digit fractional seconds', () => { @@ -112,7 +151,9 @@ describe('SecretListItem', () => { }) renderComponent({ secret }) - expect(screen.getByText(/secrets\.createdAt/)).toBeInTheDocument() + expect( + screen.getByText(`Created ${formatExpectedDate(secret.created_at)}`) + ).toBeInTheDocument() expect(screen.queryByText(/Invalid Date/)).not.toBeInTheDocument() }) @@ -122,7 +163,9 @@ describe('SecretListItem', () => { }) renderComponent({ secret }) - expect(screen.getByText(/secrets\.createdAt/)).toBeInTheDocument() + expect( + screen.getByText(`Created ${formatExpectedDate(secret.created_at)}`) + ).toBeInTheDocument() expect(screen.queryByText(/Invalid Date/)).not.toBeInTheDocument() }) @@ -130,7 +173,7 @@ describe('SecretListItem', () => { const secret = createMockSecret({ created_at: 'not-a-date' }) renderComponent({ secret }) - expect(screen.queryByText(/secrets\.createdAt/)).not.toBeInTheDocument() + expect(screen.queryByText(/^Created /)).not.toBeInTheDocument() expect(screen.queryByText(/Invalid Date/)).not.toBeInTheDocument() }) @@ -138,7 +181,7 @@ describe('SecretListItem', () => { const secret = createMockSecret({ last_used_at: 'not-a-date' }) renderComponent({ secret }) - expect(screen.queryByText(/secrets\.lastUsed/)).not.toBeInTheDocument() + expect(screen.queryByText(/^Last used /)).not.toBeInTheDocument() expect(screen.queryByText(/Invalid Date/)).not.toBeInTheDocument() }) @@ -148,7 +191,11 @@ describe('SecretListItem', () => { }) renderComponent({ secret }) - expect(screen.getByText(/secrets\.lastUsed/)).toBeInTheDocument() + expect( + screen.getByText( + `Last used ${formatExpectedDate(secret.last_used_at!)}` + ) + ).toBeInTheDocument() expect(screen.queryByText(/Invalid Date/)).not.toBeInTheDocument() }) }) diff --git a/src/platform/secrets/components/SecretListItem.vue b/src/platform/secrets/components/SecretListItem.vue index 54fd474594..600dc77f86 100644 --- a/src/platform/secrets/components/SecretListItem.vue +++ b/src/platform/secrets/components/SecretListItem.vue @@ -19,11 +19,9 @@ </span> </div> <div class="flex gap-3 text-xs text-muted"> - <span v-if="createdDate"> - {{ $t('secrets.createdAt', { date: createdDate }) }} - </span> - <span v-if="lastUsedDate"> - {{ $t('secrets.lastUsed', { date: lastUsedDate }) }} + <span v-if="createdAtLabel">{{ createdAtLabel }}</span> + <span v-if="lastUsedLabel"> + {{ lastUsedLabel }} </span> </div> </div> @@ -31,20 +29,20 @@ <i v-if="loading" class="pi pi-spinner pi-spin text-muted" /> <template v-else> <Button - v-tooltip="{ value: $t('g.edit'), showDelay: 300 }" + v-tooltip="{ value: editLabel, showDelay: 300 }" variant="muted-textonly" size="icon-sm" - :aria-label="$t('g.edit')" + :aria-label="editLabel" :disabled="disabled" @click="emit('edit')" > <i class="pi pi-pen-to-square" /> </Button> <Button - v-tooltip="{ value: $t('g.delete'), showDelay: 300 }" + v-tooltip="{ value: deleteLabel, showDelay: 300 }" variant="muted-textonly" size="icon-sm" - :aria-label="$t('g.delete')" + :aria-label="deleteLabel" :disabled="disabled" @click="emit('delete')" > @@ -57,6 +55,7 @@ <script setup lang="ts"> import { computed } from 'vue' +import { useI18n } from 'vue-i18n' import Button from '@/components/ui/button/Button.vue' import { parseIsoDateSafe } from '@/utils/dateTimeUtil' @@ -79,14 +78,36 @@ const emit = defineEmits<{ delete: [] }>() +const { d, t } = useI18n() + const providerLabel = computed(() => getProviderLabel(secret.provider)) const providerLogo = computed(() => getProviderLogo(secret.provider)) function formatIsoDate(iso: string | undefined | null): string { const date = parseIsoDateSafe(iso) - return date ? date.toLocaleDateString() : '' + return date ? d(date, { dateStyle: 'medium' }) : '' } const createdDate = computed(() => formatIsoDate(secret.created_at)) const lastUsedDate = computed(() => formatIsoDate(secret.last_used_at)) +const createdAtLabel = computed(() => + createdDate.value + ? t( + 'secrets.createdAt', + { date: createdDate.value }, + { escapeParameter: false } + ) + : '' +) +const lastUsedLabel = computed(() => + lastUsedDate.value + ? t( + 'secrets.lastUsed', + { date: lastUsedDate.value }, + { escapeParameter: false } + ) + : '' +) +const editLabel = computed(() => t('g.edit')) +const deleteLabel = computed(() => t('g.delete')) </script> diff --git a/src/platform/settings/constants/coreSettings.ts b/src/platform/settings/constants/coreSettings.ts index 0407adfa1a..f805a4e067 100644 --- a/src/platform/settings/constants/coreSettings.ts +++ b/src/platform/settings/constants/coreSettings.ts @@ -68,6 +68,16 @@ export const CORE_SETTINGS: SettingParams[] = [ type: 'boolean', defaultValue: true }, + { + id: 'Comfy.NodeSearchBoxImpl.FollowCursor', + category: ['Comfy', 'Node Search Box', 'FollowCursor'], + name: 'Added nodes follow the cursor', + tooltip: + 'When enabled, nodes added from the search box follow the cursor until clicked to place. Only applies to the default implementation.', + type: 'boolean', + defaultValue: true, + versionAdded: '1.44.4' + }, { id: 'Comfy.NodeSearchBoxImpl.ShowCategory', category: ['Comfy', 'Node Search Box', 'ShowCategory'], diff --git a/src/platform/surveys/surveyRegistry.ts b/src/platform/surveys/surveyRegistry.ts index a98b300eec..1923e12bc3 100644 --- a/src/platform/surveys/surveyRegistry.ts +++ b/src/platform/surveys/surveyRegistry.ts @@ -11,6 +11,12 @@ export const FEATURE_SURVEYS: Record<string, FeatureSurveyConfig> = { triggerThreshold: 3, delayMs: 5000 }, + 'queue-progress-overlay': { + featureId: 'queue-progress-overlay', + typeformId: 'HZ5saxry', + triggerThreshold: 16, + delayMs: 5000 + }, 'error-panel': { featureId: 'error-panel', typeformId: 'iFp4p4mV', diff --git a/src/platform/tasks/services/taskService.ts b/src/platform/tasks/services/taskService.ts index 7dc1e62db5..4b45198016 100644 --- a/src/platform/tasks/services/taskService.ts +++ b/src/platform/tasks/services/taskService.ts @@ -43,8 +43,16 @@ const zTaskResponse = z.object({ export type TaskResponse = z.infer<typeof zTaskResponse> +/** + * Identifier for a background task tracked by the `/tasks` API. + * + * Backed by `TaskResponse.id` which is `z.string().uuid()`. This alias names + * that primitive at use sites without changing structural typing. + */ +export type TaskId = string + function createTaskService() { - async function getTask(taskId: string): Promise<TaskResponse> { + async function getTask(taskId: TaskId): Promise<TaskResponse> { const res = await api.fetchApi(`${TASKS_ENDPOINT}/${taskId}`) if (!res.ok) { diff --git a/src/platform/telemetry/types.ts b/src/platform/telemetry/types.ts index 2bdfbf0955..e606379eb5 100644 --- a/src/platform/telemetry/types.ts +++ b/src/platform/telemetry/types.ts @@ -40,6 +40,11 @@ export interface SurveyResponses { industry?: string useCase?: string making?: string[] + role?: string + teamSize?: string + source?: string + usage?: string + intent?: string[] } export interface SurveyResponsesNormalized extends SurveyResponses { diff --git a/src/platform/workflow/core/utils/workflowFlattening.test.ts b/src/platform/workflow/core/utils/workflowFlattening.test.ts new file mode 100644 index 0000000000..ad432859d2 --- /dev/null +++ b/src/platform/workflow/core/utils/workflowFlattening.test.ts @@ -0,0 +1,197 @@ +import { describe, expect, it } from 'vitest' + +import type { FlattenableWorkflowNode } from '@/platform/workflow/core/utils/workflowFlattening' +import { + buildSubgraphExecutionPaths, + flattenWorkflowNodes +} from '@/platform/workflow/core/utils/workflowFlattening' + +function node(id: number, type: string): FlattenableWorkflowNode { + return { id, type } +} + +function subgraphDef( + id: string, + nodes: FlattenableWorkflowNode[], + nestedDefs: unknown[] = [] +) { + return { + id, + name: id, + nodes, + definitions: { subgraphs: nestedDefs }, + inputNode: {}, + outputNode: {} + } +} + +describe('buildSubgraphExecutionPaths', () => { + it('returns empty map when there are no subgraph definitions', () => { + expect(buildSubgraphExecutionPaths([node(5, 'SomeNode')], [])).toEqual( + new Map() + ) + }) + + it('returns empty map when no root node matches a subgraph type', () => { + const def = subgraphDef('def-A', []) + expect( + buildSubgraphExecutionPaths([node(5, 'UnrelatedNode')], [def]) + ).toEqual(new Map()) + }) + + it('skips malformed subgraph definitions', () => { + const malformedDef = { + id: 'def-A', + name: 'def-A', + nodes: [null], + inputNode: {}, + outputNode: {} + } + + expect( + buildSubgraphExecutionPaths([node(5, 'def-A')], [malformedDef]) + ).toEqual(new Map()) + }) + + it('maps a single subgraph instance to its execution path', () => { + const def = subgraphDef('def-A', []) + const result = buildSubgraphExecutionPaths([node(5, 'def-A')], [def]) + expect(result.get('def-A')).toEqual(['5']) + }) + + it('collects multiple instances of the same subgraph type', () => { + const def = subgraphDef('def-A', []) + const result = buildSubgraphExecutionPaths( + [node(5, 'def-A'), node(10, 'def-A')], + [def] + ) + expect(result.get('def-A')).toEqual(['5', '10']) + }) + + it('builds nested execution paths for subgraphs within subgraphs', () => { + const innerDef = subgraphDef('def-B', []) + const outerDef = subgraphDef('def-A', [node(70, 'def-B')]) + const result = buildSubgraphExecutionPaths( + [node(5, 'def-A')], + [outerDef, innerDef] + ) + expect(result.get('def-A')).toEqual(['5']) + expect(result.get('def-B')).toEqual(['5:70']) + }) + + it('does not recurse infinitely on self-referential subgraph definitions', () => { + const cyclicDef = subgraphDef('def-A', [node(70, 'def-A')]) + const result = buildSubgraphExecutionPaths([node(5, 'def-A')], [cyclicDef]) + expect(result.get('def-A')).toEqual(['5']) + }) + + it('does not recurse infinitely on mutually cyclic subgraph definitions', () => { + const defA = subgraphDef('def-A', [node(70, 'def-B')]) + const defB = subgraphDef('def-B', [node(80, 'def-A')]) + const result = buildSubgraphExecutionPaths([node(5, 'def-A')], [defA, defB]) + expect(result.get('def-A')).toEqual(['5']) + expect(result.get('def-B')).toEqual(['5:70']) + }) +}) + +describe('flattenWorkflowNodes', () => { + it('returns root nodes when no subgraphs exist', () => { + const result = flattenWorkflowNodes({ + nodes: [node(1, 'KSampler'), node(2, 'CLIPLoader')] + }) + + expect(result).toHaveLength(2) + expect(result.map((n) => n.id)).toEqual([1, 2]) + }) + + it('returns empty array when nodes is undefined', () => { + const result = flattenWorkflowNodes({}) + expect(result).toEqual([]) + }) + + it('includes subgraph nodes with prefixed IDs', () => { + const result = flattenWorkflowNodes({ + nodes: [node(5, 'def-A')], + definitions: { + subgraphs: [ + subgraphDef('def-A', [node(10, 'Inner'), node(20, 'Inner2')]) + ] + } + }) + + expect(result).toHaveLength(3) + expect(result.map((n) => n.id)).toEqual([5, '5:10', '5:20']) + }) + + it('skips malformed subgraph definitions', () => { + const result = flattenWorkflowNodes({ + nodes: [node(5, 'def-A')], + definitions: { + subgraphs: [ + { + id: 'def-A', + name: 'def-A', + nodes: [null], + inputNode: {}, + outputNode: {} + } + ] + } + }) + + expect(result.map((n) => n.id)).toEqual([5]) + }) + + it('skips malformed nested subgraph definitions', () => { + const outerDef = { + ...subgraphDef('def-A', [node(10, 'def-B')]), + definitions: { subgraphs: { length: 1 } } + } + const result = flattenWorkflowNodes({ + nodes: [node(5, 'def-A')], + definitions: { + subgraphs: [outerDef] + } + }) + + expect(result.map((n) => n.id)).toEqual([5, '5:10']) + }) + + it('prefixes nested subgraph nodes with full execution path', () => { + const innerDef = subgraphDef('def-B', [node(3, 'Leaf')]) + const outerDef = subgraphDef('def-A', [node(10, 'def-B')], [innerDef]) + const result = flattenWorkflowNodes({ + nodes: [node(5, 'def-A')], + definitions: { + subgraphs: [outerDef] + } + }) + + expect(result.map((n) => n.id)).toEqual([5, '5:10', '5:10:3']) + }) + + it('does not clone phantom nodes from self-referential subgraphs', () => { + const cyclicDef = subgraphDef('def-A', [node(70, 'def-A')]) + const result = flattenWorkflowNodes({ + nodes: [node(5, 'def-A')], + definitions: { + subgraphs: [cyclicDef] + } + }) + + expect(result.map((n) => n.id)).toEqual([5, '5:70']) + }) + + it('does not clone phantom nodes from mutually cyclic subgraphs', () => { + const defA = subgraphDef('def-A', [node(70, 'def-B')]) + const defB = subgraphDef('def-B', [node(80, 'def-A')]) + const result = flattenWorkflowNodes({ + nodes: [node(5, 'def-A')], + definitions: { + subgraphs: [defA, defB] + } + }) + + expect(result.map((n) => n.id)).toEqual([5, '5:70', '5:70:80']) + }) +}) diff --git a/src/platform/workflow/core/utils/workflowFlattening.ts b/src/platform/workflow/core/utils/workflowFlattening.ts new file mode 100644 index 0000000000..4a7c3d31fd --- /dev/null +++ b/src/platform/workflow/core/utils/workflowFlattening.ts @@ -0,0 +1,166 @@ +import type { NodeId } from '@/lib/litegraph/src/litegraph' + +export interface FlattenableWorkflowNode { + id: NodeId + type: string + mode?: number + widgets_values?: readonly unknown[] | Record<string, unknown> + properties?: Record<string, unknown> +} + +export interface FlattenableWorkflowGraph { + nodes?: readonly FlattenableWorkflowNode[] + definitions?: { + subgraphs?: readonly unknown[] + } +} + +interface FlattenableSubgraphDefinition { + id: string + name: string + nodes: FlattenableWorkflowNode[] + definitions?: { + subgraphs?: readonly unknown[] + } + inputNode: unknown + outputNode: unknown +} + +function isFlattenableWorkflowNode( + obj: unknown +): obj is FlattenableWorkflowNode { + if (obj === null || typeof obj !== 'object') return false + + const candidate = obj as Record<string, unknown> + return ( + (typeof candidate.id === 'string' || typeof candidate.id === 'number') && + typeof candidate.type === 'string' + ) +} + +/** + * Type guard to check if an object is a subgraph definition. + * This helps TypeScript understand the type when recursive definitions are unknown. + */ +function isSubgraphDefinition( + obj: unknown +): obj is FlattenableSubgraphDefinition { + if (obj === null || typeof obj !== 'object') return false + + const candidate = obj as Record<string, unknown> + return ( + typeof candidate.id === 'string' && + typeof candidate.name === 'string' && + Array.isArray(candidate.nodes) && + candidate.nodes.every(isFlattenableWorkflowNode) && + 'inputNode' in candidate && + 'outputNode' in candidate + ) +} + +/** + * Builds a map from subgraph definition ID to all execution path prefixes + * where that definition is instantiated in the workflow. + * + * "def-A" -> ["5", "10"] for each container node instantiating that subgraph definition. + */ +export function buildSubgraphExecutionPaths( + rootNodes: readonly FlattenableWorkflowNode[], + allSubgraphDefs: readonly unknown[] +): Map<string, string[]> { + const subgraphDefMap = new Map( + allSubgraphDefs.filter(isSubgraphDefinition).map((s) => [s.id, s]) + ) + const pathMap = new Map<string, string[]>() + const visited = new Set<string>() + + function build( + nodes: readonly FlattenableWorkflowNode[], + parentPrefix: string + ) { + for (const n of nodes ?? []) { + if (typeof n.type !== 'string' || !subgraphDefMap.has(n.type)) continue + if (visited.has(n.type)) continue + + const path = parentPrefix ? `${parentPrefix}:${n.id}` : String(n.id) + const existing = pathMap.get(n.type) + if (existing) { + existing.push(path) + } else { + pathMap.set(n.type, [path]) + } + + visited.add(n.type) + + const innerDef = subgraphDefMap.get(n.type) + if (innerDef) { + build(innerDef.nodes, path) + } + + visited.delete(n.type) + } + } + + build(rootNodes, '') + return pathMap +} + +/** + * Recursively collect all subgraph definitions from root and nested levels. + */ +export function collectSubgraphDefinitions( + rootDefs: readonly unknown[] +): FlattenableSubgraphDefinition[] { + const result: FlattenableSubgraphDefinition[] = [] + const seen = new Set<string>() + + function collect(defs: readonly unknown[]) { + for (const def of defs) { + if (!isSubgraphDefinition(def)) continue + if (seen.has(def.id)) continue + seen.add(def.id) + result.push(def) + + const nestedSubgraphs = def.definitions?.subgraphs + if (!Array.isArray(nestedSubgraphs) || nestedSubgraphs.length === 0) { + continue + } + collect(nestedSubgraphs) + } + } + + collect(rootDefs) + return result +} + +/** + * Flatten all workflow nodes (root + subgraphs) into a single array. + * Each node's `id` is prefixed with its execution path (e.g. node "3" inside container "11" -> "11:3"). + */ +export function flattenWorkflowNodes( + graphData: FlattenableWorkflowGraph +): Readonly<FlattenableWorkflowNode>[] { + const rootNodes = graphData.nodes ?? [] + const allDefs = collectSubgraphDefinitions( + graphData.definitions?.subgraphs ?? [] + ) + const pathMap = buildSubgraphExecutionPaths(rootNodes, allDefs) + + const allNodes: FlattenableWorkflowNode[] = [...rootNodes] + + const subgraphDefMap = new Map(allDefs.map((s) => [s.id, s])) + for (const [defId, paths] of pathMap.entries()) { + const def = subgraphDefMap.get(defId) + if (!def?.nodes) continue + for (const prefix of paths) { + for (const node of def.nodes) { + allNodes.push({ + ...node, + id: `${prefix}:${node.id}` + }) + } + } + } + + return allNodes +} diff --git a/src/platform/workflow/sharing/components/publish/ComfyHubDescribeStep.test.ts b/src/platform/workflow/sharing/components/publish/ComfyHubDescribeStep.test.ts index 7b4d84cc43..c7d2561455 100644 --- a/src/platform/workflow/sharing/components/publish/ComfyHubDescribeStep.test.ts +++ b/src/platform/workflow/sharing/components/publish/ComfyHubDescribeStep.test.ts @@ -94,7 +94,7 @@ describe('ComfyHubDescribeStep', () => { ) await flushPromises() - const nameInput = screen.getByTestId('name-input') + const nameInput = screen.getByTestId('publish-name-input') const descInput = screen.getByTestId('description-input') await userEvent.clear(nameInput) diff --git a/src/platform/workflow/sharing/components/publish/ComfyHubDescribeStep.vue b/src/platform/workflow/sharing/components/publish/ComfyHubDescribeStep.vue index 623a9f4a71..e6d6ce5060 100644 --- a/src/platform/workflow/sharing/components/publish/ComfyHubDescribeStep.vue +++ b/src/platform/workflow/sharing/components/publish/ComfyHubDescribeStep.vue @@ -1,10 +1,14 @@ <template> - <div class="flex min-h-0 flex-1 flex-col gap-6 px-6 py-4"> + <div + data-testid="publish-describe-step" + class="flex min-h-0 flex-1 flex-col gap-6 px-6 py-4" + > <label class="flex flex-col gap-2"> <span class="text-sm text-base-foreground"> {{ $t('comfyHubPublish.workflowName') }} </span> <Input + data-testid="publish-name-input" :model-value="name" :placeholder="$t('comfyHubPublish.workflowNamePlaceholder')" @update:model-value="$emit('update:name', String($event))" @@ -29,6 +33,7 @@ </span> <TagsInput v-slot="{ isEmpty }" + data-testid="publish-tags-input" always-editing class="bg-secondary-background select-none" :model-value="tags" diff --git a/src/platform/workflow/sharing/components/publish/ComfyHubFinishStep.vue b/src/platform/workflow/sharing/components/publish/ComfyHubFinishStep.vue index ca8d015157..a86b4e602b 100644 --- a/src/platform/workflow/sharing/components/publish/ComfyHubFinishStep.vue +++ b/src/platform/workflow/sharing/components/publish/ComfyHubFinishStep.vue @@ -1,5 +1,8 @@ <template> - <div class="flex min-h-0 flex-1 flex-col gap-8 px-6 py-4"> + <div + data-testid="publish-finish-step" + class="flex min-h-0 flex-1 flex-col gap-8 px-6 py-4" + > <section class="flex flex-col gap-4"> <span class="text-sm text-base-foreground"> {{ $t('comfyHubPublish.shareAs') }} diff --git a/src/platform/workflow/sharing/components/publish/ComfyHubProfilePromptPanel.vue b/src/platform/workflow/sharing/components/publish/ComfyHubProfilePromptPanel.vue index a818569c53..459fdb9405 100644 --- a/src/platform/workflow/sharing/components/publish/ComfyHubProfilePromptPanel.vue +++ b/src/platform/workflow/sharing/components/publish/ComfyHubProfilePromptPanel.vue @@ -1,5 +1,8 @@ <template> - <div class="flex min-h-0 flex-1 flex-col gap-4 px-6 py-4"> + <div + data-testid="publish-profile-prompt" + class="flex min-h-0 flex-1 flex-col gap-4 px-6 py-4" + > <p class="text-sm text-base-foreground"> {{ $t('comfyHubPublish.createProfileToPublish') }} </p> diff --git a/src/platform/workflow/sharing/components/publish/ComfyHubPublishDialog.vue b/src/platform/workflow/sharing/components/publish/ComfyHubPublishDialog.vue index 1a4102f407..bf5f8a6c54 100644 --- a/src/platform/workflow/sharing/components/publish/ComfyHubPublishDialog.vue +++ b/src/platform/workflow/sharing/components/publish/ComfyHubPublishDialog.vue @@ -21,7 +21,11 @@ <template #header /> <template #content> - <div v-if="needsSave" class="flex flex-col gap-4 p-6"> + <div + v-if="needsSave" + data-testid="publish-save-prompt" + class="flex flex-col gap-4 p-6" + > <p class="m-0 text-sm text-muted-foreground"> {{ $t('comfyHubPublish.unsavedDescription') }} </p> diff --git a/src/platform/workflow/sharing/components/publish/ComfyHubPublishFooter.vue b/src/platform/workflow/sharing/components/publish/ComfyHubPublishFooter.vue index 2b9a8a95a6..d8aa998b0e 100644 --- a/src/platform/workflow/sharing/components/publish/ComfyHubPublishFooter.vue +++ b/src/platform/workflow/sharing/components/publish/ComfyHubPublishFooter.vue @@ -1,5 +1,6 @@ <template> <footer + data-testid="publish-footer" class="flex shrink items-center justify-end gap-4 border-t border-border-default px-6 py-4" > <Button v-if="!isFirstStep" size="lg" @click="$emit('back')"> diff --git a/src/platform/workflow/sharing/components/publish/ComfyHubPublishNav.vue b/src/platform/workflow/sharing/components/publish/ComfyHubPublishNav.vue index a4d9d57c28..1921da5b1d 100644 --- a/src/platform/workflow/sharing/components/publish/ComfyHubPublishNav.vue +++ b/src/platform/workflow/sharing/components/publish/ComfyHubPublishNav.vue @@ -1,5 +1,5 @@ <template> - <nav class="flex flex-col gap-6 px-3 py-4"> + <nav data-testid="publish-nav" class="flex flex-col gap-6 px-3 py-4"> <ol class="flex list-none flex-col p-0"> <li v-for="step in steps" diff --git a/src/platform/workflow/sharing/types/shareTypes.ts b/src/platform/workflow/sharing/types/shareTypes.ts index 1cbcda26a8..f9c994731b 100644 --- a/src/platform/workflow/sharing/types/shareTypes.ts +++ b/src/platform/workflow/sharing/types/shareTypes.ts @@ -1,5 +1,8 @@ import type { AssetInfo } from '@/schemas/apiSchema' -import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema' +import type { + ComfyWorkflowJSON, + WorkflowId +} from '@/platform/workflow/validation/schemas/workflowSchema' import type { ThumbnailType } from '@/platform/workflow/sharing/types/comfyHubTypes' export interface WorkflowPublishResult { @@ -37,6 +40,6 @@ export interface SharedWorkflowPayload { name: string publishedAt: Date | null shareId: string - workflowId: string + workflowId: WorkflowId workflowJson: ComfyWorkflowJSON } diff --git a/src/platform/workflow/validation/schemas/workflowSchema.test.ts b/src/platform/workflow/validation/schemas/workflowSchema.test.ts index b3ef7827fe..e6c608de47 100644 --- a/src/platform/workflow/validation/schemas/workflowSchema.test.ts +++ b/src/platform/workflow/validation/schemas/workflowSchema.test.ts @@ -1,16 +1,7 @@ -import { fromPartial } from '@total-typescript/shoehorn' import fs from 'fs' import { describe, expect, it } from 'vitest' -import { - buildSubgraphExecutionPaths, - flattenWorkflowNodes, - validateComfyWorkflow -} from '@/platform/workflow/validation/schemas/workflowSchema' -import type { - ComfyNode, - ComfyWorkflowJSON -} from '@/platform/workflow/validation/schemas/workflowSchema' +import { validateComfyWorkflow } from '@/platform/workflow/validation/schemas/workflowSchema' import { defaultGraph } from '@/scripts/defaultGraph' const WORKFLOW_DIR = 'src/platform/workflow/validation/schemas/__fixtures__' @@ -278,116 +269,3 @@ describe('parseComfyWorkflow', () => { }) }) }) - -function node(id: number, type: string): ComfyNode { - return { id, type } as ComfyNode -} - -function subgraphDef(id: string, nodes: ComfyNode[]) { - return { id, name: id, nodes, inputNode: {}, outputNode: {} } -} - -describe('buildSubgraphExecutionPaths', () => { - it('returns empty map when there are no subgraph definitions', () => { - expect(buildSubgraphExecutionPaths([node(5, 'SomeNode')], [])).toEqual( - new Map() - ) - }) - - it('returns empty map when no root node matches a subgraph type', () => { - const def = subgraphDef('def-A', []) - expect( - buildSubgraphExecutionPaths([node(5, 'UnrelatedNode')], [def]) - ).toEqual(new Map()) - }) - - it('maps a single subgraph instance to its execution path', () => { - const def = subgraphDef('def-A', []) - const result = buildSubgraphExecutionPaths([node(5, 'def-A')], [def]) - expect(result.get('def-A')).toEqual(['5']) - }) - - it('collects multiple instances of the same subgraph type', () => { - const def = subgraphDef('def-A', []) - const result = buildSubgraphExecutionPaths( - [node(5, 'def-A'), node(10, 'def-A')], - [def] - ) - expect(result.get('def-A')).toEqual(['5', '10']) - }) - - it('builds nested execution paths for subgraphs within subgraphs', () => { - const innerDef = subgraphDef('def-B', []) - const outerDef = subgraphDef('def-A', [node(70, 'def-B')]) - const result = buildSubgraphExecutionPaths( - [node(5, 'def-A')], - [outerDef, innerDef] - ) - expect(result.get('def-A')).toEqual(['5']) - expect(result.get('def-B')).toEqual(['5:70']) - }) - - it('does not recurse infinitely on self-referential subgraph definitions', () => { - const cyclicDef = subgraphDef('def-A', [node(70, 'def-A')]) - expect(() => - buildSubgraphExecutionPaths([node(5, 'def-A')], [cyclicDef]) - ).not.toThrow() - }) - - it('does not recurse infinitely on mutually cyclic subgraph definitions', () => { - const defA = subgraphDef('def-A', [node(70, 'def-B')]) - const defB = subgraphDef('def-B', [node(80, 'def-A')]) - expect(() => - buildSubgraphExecutionPaths([node(5, 'def-A')], [defA, defB]) - ).not.toThrow() - }) -}) - -describe('flattenWorkflowNodes', () => { - it('returns root nodes when no subgraphs exist', () => { - const result = flattenWorkflowNodes({ - nodes: [node(1, 'KSampler'), node(2, 'CLIPLoader')] - } as ComfyWorkflowJSON) - - expect(result).toHaveLength(2) - expect(result.map((n) => n.id)).toEqual([1, 2]) - }) - - it('returns empty array when nodes is undefined', () => { - const result = flattenWorkflowNodes({} as ComfyWorkflowJSON) - expect(result).toEqual([]) - }) - - it('includes subgraph nodes with prefixed IDs', () => { - const result = flattenWorkflowNodes( - fromPartial<ComfyWorkflowJSON>({ - nodes: [node(5, 'def-A')], - definitions: { - subgraphs: [ - subgraphDef('def-A', [node(10, 'Inner'), node(20, 'Inner2')]) - ] - } - }) - ) - - expect(result).toHaveLength(3) // 1 root + 2 subgraph - expect(result.map((n) => n.id)).toEqual([5, '5:10', '5:20']) - }) - - it('prefixes nested subgraph nodes with full execution path', () => { - const result = flattenWorkflowNodes( - fromPartial<ComfyWorkflowJSON>({ - nodes: [node(5, 'def-A')], - definitions: { - subgraphs: [ - subgraphDef('def-A', [node(10, 'def-B')]), - subgraphDef('def-B', [node(3, 'Leaf')]) - ] - } - }) - ) - - // root:5, def-A inner: 5:10, def-B inner: 5:10:3 - expect(result.map((n) => n.id)).toEqual([5, '5:10', '5:10:3']) - }) -}) diff --git a/src/platform/workflow/validation/schemas/workflowSchema.ts b/src/platform/workflow/validation/schemas/workflowSchema.ts index aa905155dc..1f1dc3fefc 100644 --- a/src/platform/workflow/validation/schemas/workflowSchema.ts +++ b/src/platform/workflow/validation/schemas/workflowSchema.ts @@ -15,6 +15,15 @@ const zRendererType = z.enum([ export const zNodeId = z.union([z.number().int(), z.string()]) const zNodeInputName = z.string() export type NodeId = z.infer<typeof zNodeId> + +/** + * UUID identifier for a saved workflow. + * + * Workflow files persist their identity as `id: z.string().uuid()` on the + * graph schema. This alias names that primitive at use sites (services, + * stores, share types) without changing structural typing. + */ +export type WorkflowId = string const zSlotIndex = z.union([ z.number().int(), z @@ -495,24 +504,6 @@ export type WorkflowJSON04 = z.infer<typeof zComfyWorkflow> export type ComfyWorkflowJSON = z.infer< typeof zComfyWorkflow | typeof zComfyWorkflow1 > -type SubgraphDefinition = z.infer<typeof zSubgraphDefinition> - -/** - * Type guard to check if an object is a SubgraphDefinition. - * This helps TypeScript understand the type when z.lazy() breaks inference. - */ -export function isSubgraphDefinition(obj: unknown): obj is SubgraphDefinition { - return ( - obj !== null && - typeof obj === 'object' && - 'id' in obj && - 'name' in obj && - 'nodes' in obj && - Array.isArray((obj as SubgraphDefinition).nodes) && - 'inputNode' in obj && - 'outputNode' in obj - ) -} const zWorkflowVersion = z.object({ version: z.number() @@ -565,99 +556,3 @@ const zNodeData = z.object({ const zComfyApiWorkflow = z.record(zNodeId, zNodeData) export type ComfyApiWorkflow = z.infer<typeof zComfyApiWorkflow> - -/** - * Builds a map from subgraph definition ID to all execution path prefixes - * where that definition is instantiated in the workflow. - * - * "def-A" → ["5", "10"] for each container node instantiating that subgraph definition. - */ -export function buildSubgraphExecutionPaths( - rootNodes: ComfyNode[], - allSubgraphDefs: unknown[] -): Map<string, string[]> { - const subgraphDefMap = new Map( - allSubgraphDefs.filter(isSubgraphDefinition).map((s) => [s.id, s]) - ) - const pathMap = new Map<string, string[]>() - const visited = new Set<string>() - - const build = (nodes: ComfyNode[], parentPrefix: string) => { - for (const n of nodes ?? []) { - if (typeof n.type !== 'string' || !subgraphDefMap.has(n.type)) continue - const path = parentPrefix ? `${parentPrefix}:${n.id}` : String(n.id) - const existing = pathMap.get(n.type) - if (existing) { - existing.push(path) - } else { - pathMap.set(n.type, [path]) - } - - if (visited.has(n.type)) continue - visited.add(n.type) - - const innerDef = subgraphDefMap.get(n.type) - if (innerDef) { - build(innerDef.nodes, path) - } - - visited.delete(n.type) - } - } - - build(rootNodes, '') - return pathMap -} - -/** - * Recursively collect all subgraph definitions from root and nested levels. - */ -function collectAllSubgraphDefs(rootDefs: unknown[]): SubgraphDefinition[] { - const result: SubgraphDefinition[] = [] - const seen = new Set<string>() - - function collect(defs: unknown[]) { - for (const def of defs) { - if (!isSubgraphDefinition(def)) continue - if (seen.has(def.id)) continue - seen.add(def.id) - result.push(def) - if (def.definitions?.subgraphs?.length) { - collect(def.definitions.subgraphs) - } - } - } - - collect(rootDefs) - return result -} - -/** - * Flatten all workflow nodes (root + subgraphs) into a single array. - * Each node's `id` is prefixed with its execution path (e.g. node "3" inside container "11" → "11:3"). - */ -export function flattenWorkflowNodes( - graphData: ComfyWorkflowJSON -): Readonly<ComfyNode>[] { - const rootNodes = graphData.nodes ?? [] - const allDefs = collectAllSubgraphDefs(graphData.definitions?.subgraphs ?? []) - const pathMap = buildSubgraphExecutionPaths(rootNodes, allDefs) - - const allNodes: ComfyNode[] = [...rootNodes] - - const subgraphDefMap = new Map(allDefs.map((s) => [s.id, s])) - for (const [defId, paths] of pathMap.entries()) { - const def = subgraphDefMap.get(defId) - if (!def?.nodes) continue - for (const prefix of paths) { - for (const node of def.nodes) { - allNodes.push({ - ...node, - id: `${prefix}:${node.id}` - }) - } - } - } - - return allNodes -} diff --git a/src/platform/workspace/api/workspaceApi.ts b/src/platform/workspace/api/workspaceApi.ts index d8fdaa816d..9a881e61d6 100644 --- a/src/platform/workspace/api/workspaceApi.ts +++ b/src/platform/workspace/api/workspaceApi.ts @@ -1,14 +1,19 @@ import axios from 'axios' import type { SubscriptionTier } from '@/platform/cloud/subscription/constants/tierPricing' +import type { + WorkspaceId, + WorkspaceInviteId +} from '@/platform/workspace/workspaceTypes' import { api } from '@/scripts/api' import { useAuthStore } from '@/stores/authStore' +import type { UserId } from '@/types/authTypes' export type WorkspaceType = 'personal' | 'team' export type WorkspaceRole = 'owner' | 'member' interface Workspace { - id: string + id: WorkspaceId name: string type: WorkspaceType created_at: string @@ -21,7 +26,7 @@ export interface WorkspaceWithRole extends Workspace { } export interface Member { - id: string + id: UserId name: string email: string joined_at: string @@ -45,7 +50,7 @@ export interface ListMembersParams { } export interface PendingInvite { - id: string + id: WorkspaceInviteId email: string token: string invited_at: string @@ -61,7 +66,7 @@ interface CreateInviteRequest { } interface AcceptInviteResponse { - workspace_id: string + workspace_id: WorkspaceId workspace_name: string } @@ -336,7 +341,7 @@ export const workspaceApi = { * PATCH /api/workspaces/:id */ async update( - workspaceId: string, + workspaceId: WorkspaceId, payload: UpdateWorkspacePayload ): Promise<WorkspaceWithRole> { const headers = await getAuthHeaderOrThrow() @@ -356,7 +361,7 @@ export const workspaceApi = { * Delete a workspace (owner only) * DELETE /api/workspaces/:id */ - async delete(workspaceId: string): Promise<void> { + async delete(workspaceId: WorkspaceId): Promise<void> { const headers = await getAuthHeaderOrThrow() try { await workspaceApiClient.delete( @@ -406,7 +411,7 @@ export const workspaceApi = { * Remove a member from the workspace. * DELETE /api/workspace/members/:userId */ - async removeMember(userId: string): Promise<void> { + async removeMember(userId: UserId): Promise<void> { const headers = await getAuthHeaderOrThrow() try { await workspaceApiClient.delete( @@ -457,7 +462,7 @@ export const workspaceApi = { * Revoke a pending invite. * DELETE /api/workspace/invites/:inviteId */ - async revokeInvite(inviteId: string): Promise<void> { + async revokeInvite(inviteId: WorkspaceInviteId): Promise<void> { const headers = await getAuthHeaderOrThrow() try { await workspaceApiClient.delete( diff --git a/src/platform/workspace/components/SubscriptionRequiredDialogContentWorkspace.test.ts b/src/platform/workspace/components/SubscriptionRequiredDialogContentWorkspace.test.ts new file mode 100644 index 0000000000..680af8da60 --- /dev/null +++ b/src/platform/workspace/components/SubscriptionRequiredDialogContentWorkspace.test.ts @@ -0,0 +1,198 @@ +import { createTestingPinia } from '@pinia/testing' +import { render, screen } from '@testing-library/vue' +import userEvent from '@testing-library/user-event' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ref } from 'vue' +import { createI18n } from 'vue-i18n' + +import type { SubscriptionDialogReason } from '@/platform/cloud/subscription/composables/useSubscriptionDialog' + +import SubscriptionRequiredDialogContentWorkspace from './SubscriptionRequiredDialogContentWorkspace.vue' + +const mockHandleSubscribeClick = vi.fn() +const mockHandleBackToPricing = vi.fn() +const mockHandleAddCreditCard = vi.fn() +const mockHandleConfirmTransition = vi.fn() +const mockHandleResubscribe = vi.fn() +const mockCheckoutStep = ref<'pricing' | 'preview'>('pricing') +const mockPreviewData = ref<{ transition_type: string } | null>(null) + +vi.mock('@/platform/workspace/composables/useSubscriptionCheckout', () => ({ + useSubscriptionCheckout: () => ({ + checkoutStep: mockCheckoutStep, + isLoadingPreview: ref(false), + loadingTier: ref(null), + isSubscribing: ref(false), + isResubscribing: ref(false), + previewData: mockPreviewData, + selectedTierKey: ref('standard'), + selectedBillingCycle: ref('yearly'), + isPolling: ref(false), + handleSubscribeClick: mockHandleSubscribeClick, + handleBackToPricing: mockHandleBackToPricing, + handleAddCreditCard: mockHandleAddCreditCard, + handleConfirmTransition: mockHandleConfirmTransition, + handleResubscribe: mockHandleResubscribe + }) +})) + +const i18n = createI18n({ + legacy: false, + locale: 'en', + messages: { + en: { + g: { back: 'Back', close: 'Close' }, + subscription: { + plansForWorkspace: 'Plans for {workspace}', + teamWorkspace: 'Team' + }, + credits: { + topUp: { + insufficientTitle: 'Insufficient Credits', + insufficientMessage: 'You have run out of credits.' + } + } + } + } +}) + +const PricingTableStub = { + name: 'PricingTableWorkspace', + template: `<div data-testid="pricing-table"> + <button data-testid="subscribe-btn" @click="$emit('subscribe', { tierKey: 'standard', billingCycle: 'yearly' })">Subscribe</button> + <button data-testid="resubscribe-btn" @click="$emit('resubscribe')">Resubscribe</button> + </div>` +} + +const AddPaymentPreviewStub = { + name: 'SubscriptionAddPaymentPreviewWorkspace', + template: `<div data-testid="add-payment-preview"> + <button data-testid="add-card-btn" @click="$emit('addCreditCard')">Add Card</button> + </div>` +} + +const TransitionPreviewStub = { + name: 'SubscriptionTransitionPreviewWorkspace', + template: `<div data-testid="transition-preview"> + <button data-testid="confirm-btn" @click="$emit('confirm')">Confirm</button> + </div>` +} + +function renderComponent( + props: { onClose?: () => void; reason?: SubscriptionDialogReason } = {} +) { + return render(SubscriptionRequiredDialogContentWorkspace, { + props: { + onClose: props.onClose ?? vi.fn(), + ...(props.reason ? { reason: props.reason } : {}) + }, + global: { + plugins: [ + createTestingPinia({ createSpy: vi.fn, stubActions: false }), + i18n + ], + stubs: { + PricingTableWorkspace: PricingTableStub, + SubscriptionAddPaymentPreviewWorkspace: AddPaymentPreviewStub, + SubscriptionTransitionPreviewWorkspace: TransitionPreviewStub + } + } + }) +} + +describe('SubscriptionRequiredDialogContentWorkspace', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckoutStep.value = 'pricing' + mockPreviewData.value = null + }) + + it('shows pricing table on pricing step', () => { + renderComponent() + expect(screen.getByTestId('pricing-table')).toBeInTheDocument() + expect(screen.queryByTestId('add-payment-preview')).not.toBeInTheDocument() + expect(screen.queryByTestId('transition-preview')).not.toBeInTheDocument() + }) + + it('shows close button and hides back button on pricing step', () => { + renderComponent() + expect(screen.getByLabelText('Close')).toBeInTheDocument() + expect(screen.queryByLabelText('Back')).not.toBeInTheDocument() + }) + + it('calls onClose when close button is clicked', async () => { + const user = userEvent.setup() + const onClose = vi.fn() + renderComponent({ onClose }) + + await user.click(screen.getByLabelText('Close')) + + expect(onClose).toHaveBeenCalledOnce() + }) + + it('shows back button on preview step', () => { + mockCheckoutStep.value = 'preview' + mockPreviewData.value = { transition_type: 'new_subscription' } + renderComponent() + expect(screen.getByLabelText('Back')).toBeInTheDocument() + }) + + it('shows insufficient credits message when reason is out_of_credits', () => { + renderComponent({ reason: 'out_of_credits' }) + expect(screen.getByText('Insufficient Credits')).toBeInTheDocument() + expect(screen.getByText('You have run out of credits.')).toBeInTheDocument() + }) + + it('does not show insufficient credits message without reason', () => { + renderComponent() + expect(screen.queryByText('Insufficient Credits')).not.toBeInTheDocument() + }) + + it('shows new subscription preview when transition_type is new_subscription', () => { + mockCheckoutStep.value = 'preview' + mockPreviewData.value = { transition_type: 'new_subscription' } + renderComponent() + expect(screen.getByTestId('add-payment-preview')).toBeInTheDocument() + expect(screen.queryByTestId('transition-preview')).not.toBeInTheDocument() + }) + + it('shows transition preview when transition_type is upgrade', () => { + mockCheckoutStep.value = 'preview' + mockPreviewData.value = { transition_type: 'upgrade' } + renderComponent() + expect(screen.getByTestId('transition-preview')).toBeInTheDocument() + expect(screen.queryByTestId('add-payment-preview')).not.toBeInTheDocument() + }) + + it('wires subscribe event to handleSubscribeClick', async () => { + const user = userEvent.setup() + renderComponent() + + await user.click(screen.getByTestId('subscribe-btn')) + + expect(mockHandleSubscribeClick).toHaveBeenCalledWith({ + tierKey: 'standard', + billingCycle: 'yearly' + }) + }) + + it('wires resubscribe event to handleResubscribe', async () => { + const user = userEvent.setup() + renderComponent() + + await user.click(screen.getByTestId('resubscribe-btn')) + + expect(mockHandleResubscribe).toHaveBeenCalled() + }) + + it('wires back button to handleBackToPricing', async () => { + const user = userEvent.setup() + mockCheckoutStep.value = 'preview' + mockPreviewData.value = { transition_type: 'new_subscription' } + renderComponent() + + await user.click(screen.getByLabelText('Back')) + + expect(mockHandleBackToPricing).toHaveBeenCalled() + }) +}) diff --git a/src/platform/workspace/components/SubscriptionRequiredDialogContentWorkspace.vue b/src/platform/workspace/components/SubscriptionRequiredDialogContentWorkspace.vue index bbceecd5b4..f8a815ac37 100644 --- a/src/platform/workspace/components/SubscriptionRequiredDialogContentWorkspace.vue +++ b/src/platform/workspace/components/SubscriptionRequiredDialogContentWorkspace.vue @@ -18,7 +18,7 @@ variant="muted-textonly" class="absolute top-2.5 right-2.5 shrink-0 rounded-full text-text-secondary hover:bg-white/10" :aria-label="$t('g.close')" - @click="handleClose" + @click="onClose" > <i class="pi pi-times text-xl" /> </Button> @@ -94,28 +94,14 @@ </template> <script setup lang="ts"> -import { useToast } from 'primevue/usetoast' -import { computed, ref } from 'vue' -import { useI18n } from 'vue-i18n' - import Button from '@/components/ui/button/Button.vue' -import { useBillingContext } from '@/composables/billing/useBillingContext' -import { getComfyPlatformBaseUrl } from '@/config/comfyApi' -import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing' -import { useTelemetry } from '@/platform/telemetry' -import type { BillingCycle } from '@/platform/cloud/subscription/utils/subscriptionTierRank' -import type { PreviewSubscribeResponse } from '@/platform/workspace/api/workspaceApi' -import { workspaceApi } from '@/platform/workspace/api/workspaceApi' -import { useBillingOperationStore } from '@/platform/workspace/stores/billingOperationStore' import type { SubscriptionDialogReason } from '@/platform/cloud/subscription/composables/useSubscriptionDialog' +import { useSubscriptionCheckout } from '@/platform/workspace/composables/useSubscriptionCheckout' import PricingTableWorkspace from './PricingTableWorkspace.vue' import SubscriptionAddPaymentPreviewWorkspace from './SubscriptionAddPaymentPreviewWorkspace.vue' import SubscriptionTransitionPreviewWorkspace from './SubscriptionTransitionPreviewWorkspace.vue' -type CheckoutStep = 'pricing' | 'preview' -type CheckoutTierKey = Exclude<TierKey, 'free' | 'founder'> - const { onClose, reason } = defineProps<{ onClose: () => void reason?: SubscriptionDialogReason @@ -125,227 +111,22 @@ const emit = defineEmits<{ close: [subscribed: boolean] }>() -const { t } = useI18n() -const toast = useToast() -const { subscribe, previewSubscribe, plans, fetchStatus, fetchBalance } = - useBillingContext() -const telemetry = useTelemetry() -const billingOperationStore = useBillingOperationStore() -const isPolling = computed(() => billingOperationStore.hasPendingOperations) - -const checkoutStep = ref<CheckoutStep>('pricing') -const isLoadingPreview = ref(false) -const loadingTier = ref<CheckoutTierKey | null>(null) -const isSubscribing = ref(false) -const isResubscribing = ref(false) -const previewData = ref<PreviewSubscribeResponse | null>(null) -const selectedTierKey = ref<CheckoutTierKey | null>(null) -const selectedBillingCycle = ref<BillingCycle>('yearly') - -function getApiPlanSlug( - tierKey: CheckoutTierKey, - billingCycle: BillingCycle -): string | null { - const apiDuration = billingCycle === 'yearly' ? 'ANNUAL' : 'MONTHLY' - const apiTier = tierKey.toUpperCase() - const plan = plans.value.find( - (p) => p.tier === apiTier && p.duration === apiDuration - ) - return plan?.slug ?? null -} - -async function handleSubscribeClick(payload: { - tierKey: CheckoutTierKey - billingCycle: BillingCycle -}) { - const { tierKey, billingCycle } = payload - - isLoadingPreview.value = true - loadingTier.value = tierKey - selectedTierKey.value = tierKey - selectedBillingCycle.value = billingCycle - - try { - const planSlug = getApiPlanSlug(tierKey, billingCycle) - if (!planSlug) { - toast.add({ - severity: 'error', - summary: 'Unable to subscribe', - detail: 'This plan is not available' - }) - return - } - const response = await previewSubscribe(planSlug) - - if (!response || !response.allowed) { - toast.add({ - severity: 'error', - summary: 'Unable to subscribe', - detail: response?.reason || 'This plan is not available' - }) - return - } - - previewData.value = response - checkoutStep.value = 'preview' - } catch (error) { - const message = - error instanceof Error - ? error.message - : 'Failed to load subscription preview' - toast.add({ - severity: 'error', - summary: 'Error', - detail: message - }) - } finally { - isLoadingPreview.value = false - loadingTier.value = null - } -} - -function handleBackToPricing() { - checkoutStep.value = 'pricing' - previewData.value = null -} - -async function handleAddCreditCard() { - if (!selectedTierKey.value) return - - isSubscribing.value = true - try { - const planSlug = getApiPlanSlug( - selectedTierKey.value, - selectedBillingCycle.value - ) - if (!planSlug) return - const response = await subscribe( - planSlug, - `${getComfyPlatformBaseUrl()}/payment/success`, - `${getComfyPlatformBaseUrl()}/payment/failed` - ) - - if (!response) return - - if (response.status === 'subscribed') { - telemetry?.trackMonthlySubscriptionSucceeded() - toast.add({ - severity: 'success', - summary: t('subscription.required.pollingSuccess'), - life: 5000 - }) - await Promise.all([fetchStatus(), fetchBalance()]) - emit('close', true) - } else if ( - response.status === 'needs_payment_method' && - response.payment_method_url - ) { - window.open(response.payment_method_url, '_blank') - billingOperationStore.startOperation( - response.billing_op_id, - 'subscription' - ) - } else if (response.status === 'pending_payment') { - billingOperationStore.startOperation( - response.billing_op_id, - 'subscription' - ) - } - } catch (error) { - const message = - error instanceof Error ? error.message : 'Failed to subscribe' - toast.add({ - severity: 'error', - summary: 'Error', - detail: message - }) - } finally { - isSubscribing.value = false - } -} - -async function handleConfirmTransition() { - if (!selectedTierKey.value) return - - isSubscribing.value = true - try { - const planSlug = getApiPlanSlug( - selectedTierKey.value, - selectedBillingCycle.value - ) - if (!planSlug) return - const response = await subscribe( - planSlug, - `${getComfyPlatformBaseUrl()}/payment/success`, - `${getComfyPlatformBaseUrl()}/payment/failed` - ) - - if (!response) return - - if (response.status === 'subscribed') { - telemetry?.trackMonthlySubscriptionSucceeded() - toast.add({ - severity: 'success', - summary: t('subscription.required.pollingSuccess'), - life: 5000 - }) - await Promise.all([fetchStatus(), fetchBalance()]) - emit('close', true) - } else if ( - response.status === 'needs_payment_method' && - response.payment_method_url - ) { - window.open(response.payment_method_url, '_blank') - billingOperationStore.startOperation( - response.billing_op_id, - 'subscription' - ) - } else if (response.status === 'pending_payment') { - billingOperationStore.startOperation( - response.billing_op_id, - 'subscription' - ) - } - } catch (error) { - const message = - error instanceof Error ? error.message : 'Failed to update subscription' - toast.add({ - severity: 'error', - summary: 'Error', - detail: message - }) - } finally { - isSubscribing.value = false - } -} - -async function handleResubscribe() { - isResubscribing.value = true - try { - await workspaceApi.resubscribe() - toast.add({ - severity: 'success', - summary: t('subscription.resubscribeSuccess'), - life: 5000 - }) - await Promise.all([fetchStatus(), fetchBalance()]) - emit('close', true) - } catch (error) { - const message = - error instanceof Error ? error.message : 'Failed to resubscribe' - toast.add({ - severity: 'error', - summary: 'Error', - detail: message - }) - } finally { - isResubscribing.value = false - } -} - -function handleClose() { - onClose() -} +const { + checkoutStep, + isLoadingPreview, + loadingTier, + isSubscribing, + isResubscribing, + previewData, + selectedTierKey, + selectedBillingCycle, + isPolling, + handleSubscribeClick, + handleBackToPricing, + handleAddCreditCard, + handleConfirmTransition, + handleResubscribe +} = useSubscriptionCheckout(emit) </script> <style scoped> diff --git a/src/platform/workspace/composables/useSubscriptionCheckout.test.ts b/src/platform/workspace/composables/useSubscriptionCheckout.test.ts new file mode 100644 index 0000000000..6f03bf551c --- /dev/null +++ b/src/platform/workspace/composables/useSubscriptionCheckout.test.ts @@ -0,0 +1,369 @@ +import { createTestingPinia } from '@pinia/testing' +import { setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { computed } from 'vue' + +import type { Plan } from '@/platform/workspace/api/workspaceApi' + +import { findPlanSlug } from './useSubscriptionCheckout' + +function makeStandardYearly(): Plan { + return { + slug: 'standard-yearly', + tier: 'STANDARD', + duration: 'ANNUAL', + price_cents: 1600, + credits_cents: 4200, + max_seats: 1, + availability: { available: true }, + seat_summary: { + seat_count: 1, + total_cost_cents: 1600, + total_credits_cents: 4200 + } + } +} + +function makeCreatorMonthly(): Plan { + return { + slug: 'creator-monthly', + tier: 'CREATOR', + duration: 'MONTHLY', + price_cents: 3500, + credits_cents: 7400, + max_seats: 5, + availability: { available: true }, + seat_summary: { + seat_count: 1, + total_cost_cents: 3500, + total_credits_cents: 7400 + } + } +} + +function allPlans(): Plan[] { + return [makeStandardYearly(), makeCreatorMonthly()] +} + +describe('findPlanSlug', () => { + it('finds an annual plan by tier key and yearly billing cycle', () => { + expect(findPlanSlug(allPlans(), 'standard', 'yearly')).toBe( + 'standard-yearly' + ) + }) + + it('finds a monthly plan by tier key and monthly billing cycle', () => { + expect(findPlanSlug(allPlans(), 'creator', 'monthly')).toBe( + 'creator-monthly' + ) + }) + + it('returns null when no plan matches', () => { + expect(findPlanSlug(allPlans(), 'standard', 'monthly')).toBeNull() + }) + + it('returns null for empty plans', () => { + expect(findPlanSlug([], 'standard', 'yearly')).toBeNull() + }) +}) + +const { + mockSubscribe, + mockPreviewSubscribe, + mockFetchStatus, + mockFetchBalance, + mockPlans, + mockResubscribe, + mockToastAdd +} = vi.hoisted(() => ({ + mockSubscribe: vi.fn(), + mockPreviewSubscribe: vi.fn(), + mockFetchStatus: vi.fn(), + mockFetchBalance: vi.fn(), + mockPlans: { value: [] as Plan[] }, + mockResubscribe: vi.fn(), + mockToastAdd: vi.fn() +})) + +vi.mock('@/composables/billing/useBillingContext', () => ({ + useBillingContext: () => ({ + subscribe: mockSubscribe, + previewSubscribe: mockPreviewSubscribe, + plans: computed(() => mockPlans.value), + fetchStatus: mockFetchStatus, + fetchBalance: mockFetchBalance + }) +})) + +vi.mock('@/platform/workspace/api/workspaceApi', () => ({ + workspaceApi: { resubscribe: mockResubscribe } +})) + +vi.mock('@/config/comfyApi', () => ({ + getComfyPlatformBaseUrl: () => 'https://platform.comfy.org' +})) + +vi.mock('primevue/usetoast', () => ({ + useToast: () => ({ add: mockToastAdd }) +})) + +vi.mock('@/platform/telemetry', () => ({ + useTelemetry: () => ({ trackMonthlySubscriptionSucceeded: vi.fn() }) +})) + +vi.mock('vue-i18n', async (importOriginal) => { + const actual = await importOriginal() + return { + ...(actual as Record<string, unknown>), + useI18n: () => ({ + t: (key: string) => key + }) + } +}) + +describe('useSubscriptionCheckout', () => { + let emit: ReturnType<typeof vi.fn> + + async function setup() { + const { useSubscriptionCheckout } = + await import('./useSubscriptionCheckout') + return useSubscriptionCheckout(emit as never) + } + + beforeEach(() => { + setActivePinia(createTestingPinia({ stubActions: false })) + vi.clearAllMocks() + mockPlans.value = allPlans() + emit = vi.fn() + }) + + describe('handleSubscribeClick', () => { + it('transitions to preview on successful preview', async () => { + const checkout = await setup() + const preview = { + allowed: true, + transition_type: 'new_subscription' as const, + effective_at: '2025-01-01', + is_immediate: true, + cost_today_cents: 1600, + cost_next_period_cents: 1600, + credits_today_cents: 4200, + credits_next_period_cents: 4200, + new_plan: makeStandardYearly().seat_summary + } + mockPreviewSubscribe.mockResolvedValueOnce(preview) + + await checkout.handleSubscribeClick({ + tierKey: 'standard', + billingCycle: 'yearly' + }) + + expect(checkout.checkoutStep.value).toBe('preview') + expect(checkout.previewData.value).toStrictEqual(preview) + }) + + it('shows error toast when preview is disallowed', async () => { + const checkout = await setup() + mockPreviewSubscribe.mockResolvedValueOnce({ + allowed: false, + reason: 'Not allowed' + }) + + await checkout.handleSubscribeClick({ + tierKey: 'standard', + billingCycle: 'yearly' + }) + + expect(checkout.checkoutStep.value).toBe('pricing') + expect(mockToastAdd).toHaveBeenCalledWith( + expect.objectContaining({ + severity: 'error', + detail: 'Not allowed' + }) + ) + }) + + it('shows error toast when plan slug is not found', async () => { + const checkout = await setup() + mockPlans.value = [] + + await checkout.handleSubscribeClick({ + tierKey: 'standard', + billingCycle: 'yearly' + }) + + expect(mockToastAdd).toHaveBeenCalledWith( + expect.objectContaining({ + severity: 'error', + detail: 'This plan is not available' + }) + ) + }) + + it('shows error toast on network failure', async () => { + const checkout = await setup() + mockPreviewSubscribe.mockRejectedValueOnce(new Error('Network error')) + + await checkout.handleSubscribeClick({ + tierKey: 'standard', + billingCycle: 'yearly' + }) + + expect(mockToastAdd).toHaveBeenCalledWith( + expect.objectContaining({ + severity: 'error', + detail: 'Network error' + }) + ) + }) + + it('resolves monthly billing cycle to correct plan slug', async () => { + const checkout = await setup() + mockPreviewSubscribe.mockResolvedValueOnce({ + allowed: true, + transition_type: 'new_subscription' + }) + + await checkout.handleSubscribeClick({ + tierKey: 'creator', + billingCycle: 'monthly' + }) + + expect(mockPreviewSubscribe).toHaveBeenCalledWith('creator-monthly') + }) + }) + + describe('handleBackToPricing', () => { + it('resets to pricing step and clears preview data', async () => { + const checkout = await setup() + checkout.checkoutStep.value = 'preview' + checkout.previewData.value = {} as never + + checkout.handleBackToPricing() + + expect(checkout.checkoutStep.value).toBe('pricing') + expect(checkout.previewData.value).toBeNull() + }) + }) + + describe('handleAddCreditCard', () => { + it('emits close on subscribed status', async () => { + const checkout = await setup() + checkout.selectedTierKey.value = 'standard' + checkout.selectedBillingCycle.value = 'yearly' + mockSubscribe.mockResolvedValueOnce({ + status: 'subscribed', + billing_op_id: 'op-1' + }) + mockFetchStatus.mockResolvedValueOnce(undefined) + mockFetchBalance.mockResolvedValueOnce(undefined) + + await checkout.handleAddCreditCard() + + expect(mockSubscribe).toHaveBeenCalledWith( + 'standard-yearly', + 'https://platform.comfy.org/payment/success', + 'https://platform.comfy.org/payment/failed' + ) + expect(emit).toHaveBeenCalledWith('close', true) + }) + + it('opens payment URL when needs_payment_method', async () => { + const checkout = await setup() + checkout.selectedTierKey.value = 'standard' + checkout.selectedBillingCycle.value = 'yearly' + mockSubscribe.mockResolvedValueOnce({ + status: 'needs_payment_method', + billing_op_id: 'op-2', + payment_method_url: 'https://stripe.com/pay' + }) + + const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null) + await checkout.handleAddCreditCard() + + expect(openSpy).toHaveBeenCalledWith('https://stripe.com/pay', '_blank') + openSpy.mockRestore() + }) + + it('shows error toast on subscribe failure', async () => { + const checkout = await setup() + checkout.selectedTierKey.value = 'standard' + checkout.selectedBillingCycle.value = 'yearly' + mockSubscribe.mockRejectedValueOnce(new Error('Payment failed')) + + await checkout.handleAddCreditCard() + + expect(mockToastAdd).toHaveBeenCalledWith( + expect.objectContaining({ + severity: 'error', + detail: 'Payment failed' + }) + ) + }) + }) + + describe('handleConfirmTransition', () => { + it('emits close on subscribed status', async () => { + const checkout = await setup() + checkout.selectedTierKey.value = 'standard' + checkout.selectedBillingCycle.value = 'yearly' + mockSubscribe.mockResolvedValueOnce({ + status: 'subscribed', + billing_op_id: 'op-3' + }) + mockFetchStatus.mockResolvedValueOnce(undefined) + mockFetchBalance.mockResolvedValueOnce(undefined) + + await checkout.handleConfirmTransition() + + expect(emit).toHaveBeenCalledWith('close', true) + }) + + it('shows error toast on failure', async () => { + const checkout = await setup() + checkout.selectedTierKey.value = 'standard' + checkout.selectedBillingCycle.value = 'yearly' + mockSubscribe.mockRejectedValueOnce(new Error('Transition error')) + + await checkout.handleConfirmTransition() + + expect(mockToastAdd).toHaveBeenCalledWith( + expect.objectContaining({ + severity: 'error', + detail: 'Transition error' + }) + ) + }) + }) + + describe('handleResubscribe', () => { + it('emits close on success', async () => { + const checkout = await setup() + mockResubscribe.mockResolvedValueOnce({ + billing_op_id: 'op-4', + status: 'active' + }) + mockFetchStatus.mockResolvedValueOnce(undefined) + mockFetchBalance.mockResolvedValueOnce(undefined) + + await checkout.handleResubscribe() + + expect(mockResubscribe).toHaveBeenCalled() + expect(emit).toHaveBeenCalledWith('close', true) + }) + + it('shows error toast on failure', async () => { + const checkout = await setup() + mockResubscribe.mockRejectedValueOnce(new Error('Resubscribe failed')) + + await checkout.handleResubscribe() + + expect(mockToastAdd).toHaveBeenCalledWith( + expect.objectContaining({ + severity: 'error', + detail: 'Resubscribe failed' + }) + ) + }) + }) +}) diff --git a/src/platform/workspace/composables/useSubscriptionCheckout.ts b/src/platform/workspace/composables/useSubscriptionCheckout.ts new file mode 100644 index 0000000000..ab8a18b07b --- /dev/null +++ b/src/platform/workspace/composables/useSubscriptionCheckout.ts @@ -0,0 +1,210 @@ +import { useToast } from 'primevue/usetoast' +import { computed, ref } from 'vue' +import { useI18n } from 'vue-i18n' + +import { useBillingContext } from '@/composables/billing/useBillingContext' +import { getComfyPlatformBaseUrl } from '@/config/comfyApi' +import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing' +import type { BillingCycle } from '@/platform/cloud/subscription/utils/subscriptionTierRank' +import { useTelemetry } from '@/platform/telemetry' +import type { + Plan, + PreviewSubscribeResponse +} from '@/platform/workspace/api/workspaceApi' +import { workspaceApi } from '@/platform/workspace/api/workspaceApi' +import { useBillingOperationStore } from '@/platform/workspace/stores/billingOperationStore' + +type CheckoutStep = 'pricing' | 'preview' +type CheckoutTierKey = Exclude<TierKey, 'free' | 'founder'> + +export function findPlanSlug( + plans: Plan[], + tierKey: CheckoutTierKey, + billingCycle: BillingCycle +): string | null { + const apiDuration = billingCycle === 'yearly' ? 'ANNUAL' : 'MONTHLY' + const apiTier = tierKey.toUpperCase() + const plan = plans.find( + (p) => p.tier === apiTier && p.duration === apiDuration + ) + return plan?.slug ?? null +} + +export function useSubscriptionCheckout(emit: { + (e: 'close', subscribed: boolean): void +}) { + const { t } = useI18n() + const toast = useToast() + const { subscribe, previewSubscribe, plans, fetchStatus, fetchBalance } = + useBillingContext() + const telemetry = useTelemetry() + const billingOperationStore = useBillingOperationStore() + + const checkoutStep = ref<CheckoutStep>('pricing') + const isLoadingPreview = ref(false) + const loadingTier = ref<CheckoutTierKey | null>(null) + const isSubscribing = ref(false) + const isResubscribing = ref(false) + const previewData = ref<PreviewSubscribeResponse | null>(null) + const selectedTierKey = ref<CheckoutTierKey | null>(null) + const selectedBillingCycle = ref<BillingCycle>('yearly') + const isPolling = computed(() => billingOperationStore.hasPendingOperations) + + function getApiPlanSlug( + tierKey: CheckoutTierKey, + billingCycle: BillingCycle + ): string | null { + return findPlanSlug(plans.value, tierKey, billingCycle) + } + + async function handleSubscribeClick(payload: { + tierKey: CheckoutTierKey + billingCycle: BillingCycle + }) { + const { tierKey, billingCycle } = payload + + isLoadingPreview.value = true + loadingTier.value = tierKey + selectedTierKey.value = tierKey + selectedBillingCycle.value = billingCycle + + try { + const planSlug = getApiPlanSlug(tierKey, billingCycle) + if (!planSlug) { + toast.add({ + severity: 'error', + summary: 'Unable to subscribe', + detail: 'This plan is not available' + }) + return + } + const response = await previewSubscribe(planSlug) + + if (!response || !response.allowed) { + toast.add({ + severity: 'error', + summary: 'Unable to subscribe', + detail: response?.reason || 'This plan is not available' + }) + return + } + + previewData.value = response + checkoutStep.value = 'preview' + } catch (error) { + const message = + error instanceof Error + ? error.message + : 'Failed to load subscription preview' + toast.add({ + severity: 'error', + summary: 'Error', + detail: message + }) + } finally { + isLoadingPreview.value = false + loadingTier.value = null + } + } + + function handleBackToPricing() { + checkoutStep.value = 'pricing' + previewData.value = null + } + + async function handleSubscription() { + if (!selectedTierKey.value) return + + isSubscribing.value = true + try { + const planSlug = getApiPlanSlug( + selectedTierKey.value, + selectedBillingCycle.value + ) + if (!planSlug) return + const response = await subscribe( + planSlug, + `${getComfyPlatformBaseUrl()}/payment/success`, + `${getComfyPlatformBaseUrl()}/payment/failed` + ) + + if (!response) return + + if (response.status === 'subscribed') { + telemetry?.trackMonthlySubscriptionSucceeded() + toast.add({ + severity: 'success', + summary: t('subscription.required.pollingSuccess'), + life: 5000 + }) + await Promise.all([fetchStatus(), fetchBalance()]) + emit('close', true) + } else if ( + response.status === 'needs_payment_method' && + response.payment_method_url + ) { + window.open(response.payment_method_url, '_blank') + billingOperationStore.startOperation( + response.billing_op_id, + 'subscription' + ) + } else if (response.status === 'pending_payment') { + billingOperationStore.startOperation( + response.billing_op_id, + 'subscription' + ) + } + } catch (error) { + const message = + error instanceof Error ? error.message : 'Failed to subscribe' + toast.add({ + severity: 'error', + summary: 'Error', + detail: message + }) + } finally { + isSubscribing.value = false + } + } + + async function handleResubscribe() { + isResubscribing.value = true + try { + await workspaceApi.resubscribe() + toast.add({ + severity: 'success', + summary: t('subscription.resubscribeSuccess'), + life: 5000 + }) + await Promise.all([fetchStatus(), fetchBalance()]) + emit('close', true) + } catch (error) { + const message = + error instanceof Error ? error.message : 'Failed to resubscribe' + toast.add({ + severity: 'error', + summary: 'Error', + detail: message + }) + } finally { + isResubscribing.value = false + } + } + + return { + checkoutStep, + isLoadingPreview, + loadingTier, + isSubscribing, + isResubscribing, + previewData, + selectedTierKey, + selectedBillingCycle, + isPolling, + handleSubscribeClick, + handleBackToPricing, + handleAddCreditCard: handleSubscription, + handleConfirmTransition: handleSubscription, + handleResubscribe + } +} diff --git a/src/platform/workspace/stores/useWorkspaceAuth.test.ts b/src/platform/workspace/stores/useWorkspaceAuth.test.ts index 97cb05e899..74728daa08 100644 --- a/src/platform/workspace/stores/useWorkspaceAuth.test.ts +++ b/src/platform/workspace/stores/useWorkspaceAuth.test.ts @@ -669,4 +669,239 @@ describe('useWorkspaceAuthStore', () => { expect(isLoading.value).toBe(false) }) }) + + describe('refreshToken retry/race paths', () => { + // NOTE: This test documents the CURRENT behavior — exhausted refresh + // retries clear the workspace context unconditionally, even when the + // existing workspace token is still within its expiry window. That is a + // UX gap (transient backend outage manifests as forced logout) and the + // store should preserve a still-valid token across transient + // TOKEN_EXCHANGE_FAILED errors. Update the assertion alongside any source + // change that tracks token expiry to skip the context clear. + it('retries up to 3 times with exponential backoff on TOKEN_EXCHANGE_FAILED, then clears context', async () => { + mockGetIdToken.mockResolvedValue('firebase-token-xyz') + + // Initial successful switchWorkspace establishes context. + const mockFetch = vi.fn().mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockTokenResponse) + }) + vi.stubGlobal('fetch', mockFetch) + + const store = useWorkspaceAuthStore() + const { currentWorkspace } = storeToRefs(store) + + await store.switchWorkspace('workspace-123') + expect(currentWorkspace.value).not.toBeNull() + + // Subsequent refresh attempts all fail with 500 (TOKEN_EXCHANGE_FAILED). + mockFetch.mockResolvedValue({ + ok: false, + status: 500, + statusText: 'Internal Server Error', + json: () => Promise.resolve({ message: 'Server error' }) + }) + + const consoleErrorSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}) + const consoleWarnSpy = vi + .spyOn(console, 'warn') + .mockImplementation(() => {}) + + const refreshPromise = store.refreshToken() + + // Drain the four attempts (initial + 3 retries) and their backoff delays. + await vi.runAllTimersAsync() + await refreshPromise + + // 1 initial switchWorkspace + 4 refresh attempts = 5 total fetch calls. + expect(mockFetch).toHaveBeenCalledTimes(5) + // Backoff: 1s + 2s + 4s = 7s of cumulative warn-logged delays. + expect( + consoleWarnSpy.mock.calls.some((c) => + /retrying in 1000ms/.test(String(c[0])) + ) + ).toBe(true) + expect( + consoleWarnSpy.mock.calls.some((c) => + /retrying in 2000ms/.test(String(c[0])) + ) + ).toBe(true) + expect( + consoleWarnSpy.mock.calls.some((c) => + /retrying in 4000ms/.test(String(c[0])) + ) + ).toBe(true) + + // After the final failure the context is cleared. + expect(currentWorkspace.value).toBeNull() + + consoleErrorSpy.mockRestore() + consoleWarnSpy.mockRestore() + }) + + it('clears context immediately on INVALID_FIREBASE_TOKEN without retrying', async () => { + mockGetIdToken.mockResolvedValue('firebase-token-xyz') + const mockFetch = vi.fn().mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockTokenResponse) + }) + vi.stubGlobal('fetch', mockFetch) + + const store = useWorkspaceAuthStore() + const { currentWorkspace } = storeToRefs(store) + + await store.switchWorkspace('workspace-123') + expect(currentWorkspace.value).not.toBeNull() + + // Permanent error: 401 → INVALID_FIREBASE_TOKEN. + mockFetch.mockResolvedValue({ + ok: false, + status: 401, + statusText: 'Unauthorized', + json: () => Promise.resolve({ message: 'Invalid token' }) + }) + + const consoleErrorSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}) + + await store.refreshToken() + + // Initial + exactly one refresh attempt; no retries on permanent errors. + expect(mockFetch).toHaveBeenCalledTimes(2) + expect(currentWorkspace.value).toBeNull() + + consoleErrorSpy.mockRestore() + }) + + // KNOWN BUG (.fails): when an in-flight refresh's switchWorkspace call is + // already past its requestId-staleness check and awaiting the token-exchange + // fetch, switchWorkspace has no post-await commit guard. If the user + // switches workspaces and the stale refresh's fetch resolves AFTER the new + // switch has committed, the stale response will overwrite the new + // workspace's currentWorkspace/workspaceToken/sessionStorage. Mark this + // expected-fail until switchWorkspace gains a commit-time staleness check + // (e.g. compare captured requestId or expected workspaceId before + // assigning state). Removing `.fails` once fixed will catch regressions. + it.fails('the new workspace wins when the stale refresh resolves last', async () => { + mockGetIdToken.mockResolvedValue('firebase-token-xyz') + + const mockFetch = vi.fn().mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockTokenResponse) + }) + vi.stubGlobal('fetch', mockFetch) + + const store = useWorkspaceAuthStore() + const { currentWorkspace, workspaceToken } = storeToRefs(store) + + await store.switchWorkspace('workspace-123') + + // Hang the next fetch — this is the refresh's switchWorkspace fetch. + let resolveRefreshFetch: (value: unknown) => void = () => {} + const refreshFetchPromise = new Promise((resolve) => { + resolveRefreshFetch = resolve + }) + mockFetch.mockReturnValueOnce(refreshFetchPromise) + + const refreshPromise = store.refreshToken() + + // User switches workspace AND its fetch resolves first. + const newWorkspace = { ...mockWorkspace, id: 'workspace-other' } + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + ...mockTokenResponse, + token: 'new-workspace-token', + workspace: newWorkspace + }) + }) + await store.switchWorkspace('workspace-other') + + // New workspace is committed at this point. + expect(currentWorkspace.value?.id).toBe('workspace-other') + expect(workspaceToken.value).toBe('new-workspace-token') + + // Now resolve the stale refresh fetch — it carries an OLD-workspace + // token, and the source has no commit-time staleness check, so it + // clobbers the new workspace state. + resolveRefreshFetch({ + ok: true, + json: () => + Promise.resolve({ ...mockTokenResponse, token: 'stale-token' }) + }) + await refreshPromise + + // Once the source-side guard is added, both of these become true + // (the test stops failing) and `.fails` should be dropped. + expect(currentWorkspace.value?.id).toBe('workspace-other') + expect(workspaceToken.value).toBe('new-workspace-token') + }) + }) + + describe('persistToSession resilience', () => { + it('updates store state even when sessionStorage.setItem throws', async () => { + mockGetIdToken.mockResolvedValue('firebase-token-xyz') + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockTokenResponse) + }) + ) + + const setItemSpy = vi + .spyOn(sessionStorage, 'setItem') + .mockImplementation(() => { + throw new Error('QuotaExceededError') + }) + const consoleWarnSpy = vi + .spyOn(console, 'warn') + .mockImplementation(() => {}) + + const store = useWorkspaceAuthStore() + const { workspaceToken } = storeToRefs(store) + + await store.switchWorkspace('workspace-123') + + expect(workspaceToken.value).toBe('workspace-token-abc') + expect(consoleWarnSpy).toHaveBeenCalledWith( + 'Failed to persist workspace context to sessionStorage' + ) + + setItemSpy.mockRestore() + consoleWarnSpy.mockRestore() + }) + }) + + describe('Zod validation on token response', () => { + it('throws TOKEN_EXCHANGE_FAILED when the response is missing required fields', async () => { + mockGetIdToken.mockResolvedValue('firebase-token-xyz') + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ + token: 'token-only', + // missing expires_at, workspace, role, permissions + role: 'owner' + }) + }) + ) + + const store = useWorkspaceAuthStore() + const { error } = storeToRefs(store) + + await expect(store.switchWorkspace('workspace-123')).rejects.toThrow( + WorkspaceAuthError + ) + expect((error.value as WorkspaceAuthError).code).toBe( + 'TOKEN_EXCHANGE_FAILED' + ) + }) + }) }) diff --git a/src/platform/workspace/workspaceTypes.ts b/src/platform/workspace/workspaceTypes.ts index 30774aef3b..8da24adf02 100644 --- a/src/platform/workspace/workspaceTypes.ts +++ b/src/platform/workspace/workspaceTypes.ts @@ -1,5 +1,22 @@ +/** + * Identifier for a workspace. + * + * Backed by the `id` field returned by the workspace REST API. This alias + * names that primitive at use sites (api, types, stores) without changing + * structural typing. + */ +export type WorkspaceId = string + +/** + * Identifier for a pending workspace invite. + * + * Backed by the `id` field on `PendingInvite`. This alias names that + * primitive at use sites without changing structural typing. + */ +export type WorkspaceInviteId = string + export interface WorkspaceWithRole { - id: string + id: WorkspaceId name: string type: 'personal' | 'team' role: 'owner' | 'member' diff --git a/src/renderer/core/layout/operations/layoutMutations.test.ts b/src/renderer/core/layout/operations/layoutMutations.test.ts new file mode 100644 index 0000000000..316dd7fc0b --- /dev/null +++ b/src/renderer/core/layout/operations/layoutMutations.test.ts @@ -0,0 +1,192 @@ +import { describe, beforeEach, expect, it } from 'vitest' + +import { layoutStore } from '@/renderer/core/layout/store/layoutStore' + +import { useLayoutMutations } from './layoutMutations' + +beforeEach(() => { + layoutStore.initializeFromLiteGraph([ + { id: '1', pos: [10, 20], size: [200, 100] }, + { id: '2', pos: [300, 400], size: [150, 80] } + ]) +}) + +describe('moveNode', () => { + it('does nothing when node does not exist', () => { + const { moveNode } = useLayoutMutations() + const before1 = { ...layoutStore.getNodeLayoutRef('1').value } + const before2 = { ...layoutStore.getNodeLayoutRef('2').value } + moveNode('999', { x: 100, y: 200 }) + expect(layoutStore.getNodeLayoutRef('999').value).toBeNull() + expect(layoutStore.getNodeLayoutRef('1').value).toEqual(before1) + expect(layoutStore.getNodeLayoutRef('2').value).toEqual(before2) + }) + + it('updates node position', () => { + const { moveNode } = useLayoutMutations() + moveNode('1', { x: 100, y: 200 }) + expect(layoutStore.getNodeLayoutRef('1').value?.position).toEqual({ + x: 100, + y: 200 + }) + }) + + it('normalizes numeric node ID to string', () => { + const { moveNode } = useLayoutMutations() + moveNode(1, { x: 50, y: 60 }) + expect(layoutStore.getNodeLayoutRef('1').value?.position).toEqual({ + x: 50, + y: 60 + }) + }) +}) + +describe('resizeNode', () => { + it('does nothing when node does not exist', () => { + const { resizeNode } = useLayoutMutations() + const before1 = { ...layoutStore.getNodeLayoutRef('1').value } + const before2 = { ...layoutStore.getNodeLayoutRef('2').value } + resizeNode('999', { width: 400, height: 200 }) + expect(layoutStore.getNodeLayoutRef('999').value).toBeNull() + expect(layoutStore.getNodeLayoutRef('1').value).toEqual(before1) + expect(layoutStore.getNodeLayoutRef('2').value).toEqual(before2) + }) + + it('updates node size', () => { + const { resizeNode } = useLayoutMutations() + resizeNode('1', { width: 400, height: 200 }) + expect(layoutStore.getNodeLayoutRef('1').value?.size).toEqual({ + width: 400, + height: 200 + }) + }) +}) + +describe('setNodeZIndex', () => { + it('does nothing when node does not exist', () => { + const { setNodeZIndex } = useLayoutMutations() + const before1 = { ...layoutStore.getNodeLayoutRef('1').value } + const before2 = { ...layoutStore.getNodeLayoutRef('2').value } + setNodeZIndex('999', 10) + expect(layoutStore.getNodeLayoutRef('999').value).toBeNull() + expect(layoutStore.getNodeLayoutRef('1').value).toEqual(before1) + expect(layoutStore.getNodeLayoutRef('2').value).toEqual(before2) + }) + + it('updates node z-index', () => { + const { setNodeZIndex } = useLayoutMutations() + setNodeZIndex('1', 42) + expect(layoutStore.getNodeLayoutRef('1').value?.zIndex).toBe(42) + }) +}) + +describe('createNode', () => { + it('makes node accessible via getNodeLayoutRef', () => { + const { createNode } = useLayoutMutations() + createNode('99', { + position: { x: 50, y: 60 }, + size: { width: 300, height: 150 } + }) + const layout = layoutStore.getNodeLayoutRef('99').value + expect(layout?.position).toEqual({ x: 50, y: 60 }) + expect(layout?.size).toEqual({ width: 300, height: 150 }) + }) +}) + +describe('deleteNode', () => { + it('does nothing when node does not exist', () => { + const { deleteNode } = useLayoutMutations() + const before1 = { ...layoutStore.getNodeLayoutRef('1').value } + const before2 = { ...layoutStore.getNodeLayoutRef('2').value } + deleteNode('999') + expect(layoutStore.getNodeLayoutRef('999').value).toBeNull() + expect(layoutStore.getNodeLayoutRef('1').value).toEqual(before1) + expect(layoutStore.getNodeLayoutRef('2').value).toEqual(before2) + }) + + it('removes node from the store', () => { + const { deleteNode } = useLayoutMutations() + deleteNode('1') + expect(layoutStore.getNodeLayoutRef('1').value).toBeNull() + }) +}) + +describe('batchMoveNodes', () => { + it('does nothing when updates array is empty', () => { + const { batchMoveNodes } = useLayoutMutations() + const before1 = { ...layoutStore.getNodeLayoutRef('1').value } + const before2 = { ...layoutStore.getNodeLayoutRef('2').value } + batchMoveNodes([]) + expect(layoutStore.getNodeLayoutRef('1').value).toEqual(before1) + expect(layoutStore.getNodeLayoutRef('2').value).toEqual(before2) + }) + + it('updates positions for all found nodes', () => { + const { batchMoveNodes } = useLayoutMutations() + batchMoveNodes([ + { nodeId: '1', position: { x: 50, y: 60 } }, + { nodeId: '2', position: { x: 70, y: 80 } } + ]) + expect(layoutStore.getNodeLayoutRef('1').value?.position).toEqual({ + x: 50, + y: 60 + }) + expect(layoutStore.getNodeLayoutRef('2').value?.position).toEqual({ + x: 70, + y: 80 + }) + }) + + it('preserves existing node size when moving', () => { + const { batchMoveNodes } = useLayoutMutations() + batchMoveNodes([{ nodeId: '1', position: { x: 50, y: 60 } }]) + expect(layoutStore.getNodeLayoutRef('1').value?.size).toEqual({ + width: 200, + height: 100 + }) + }) + + it('skips nodes not found in the store', () => { + const { batchMoveNodes } = useLayoutMutations() + batchMoveNodes([ + { nodeId: '999', position: { x: 0, y: 0 } }, + { nodeId: '1', position: { x: 50, y: 60 } } + ]) + expect(layoutStore.getNodeLayoutRef('999').value).toBeNull() + expect(layoutStore.getNodeLayoutRef('1').value?.position).toEqual({ + x: 50, + y: 60 + }) + }) +}) + +describe('bringNodeToFront', () => { + it('gives the node a higher z-index than all other nodes', () => { + const { setNodeZIndex, bringNodeToFront } = useLayoutMutations() + setNodeZIndex('2', 10) + bringNodeToFront('1') + const z1 = layoutStore.getNodeLayoutRef('1').value?.zIndex ?? 0 + const z2 = layoutStore.getNodeLayoutRef('2').value?.zIndex ?? 0 + expect(z1).toBeGreaterThan(z2) + }) + + it('gives the node a higher z-index when all nodes start at the same level', () => { + const { setNodeZIndex, bringNodeToFront } = useLayoutMutations() + setNodeZIndex('1', 5) + setNodeZIndex('2', 5) + bringNodeToFront('1') + const z1 = layoutStore.getNodeLayoutRef('1').value?.zIndex ?? 0 + const z2 = layoutStore.getNodeLayoutRef('2').value?.zIndex ?? 0 + expect(z1).toBeGreaterThan(z2) + }) + + it('remains frontmost when the already-leading node is brought to front again', () => { + const { setNodeZIndex, bringNodeToFront } = useLayoutMutations() + setNodeZIndex('1', 20) + setNodeZIndex('2', 5) + bringNodeToFront('1') + const z1 = layoutStore.getNodeLayoutRef('1').value?.zIndex ?? 0 + const z2 = layoutStore.getNodeLayoutRef('2').value?.zIndex ?? 0 + expect(z1).toBeGreaterThan(z2) + }) +}) diff --git a/src/renderer/extensions/linearMode/LinearControls.vue b/src/renderer/extensions/linearMode/LinearControls.vue index 0cf86dcf3a..0dcea3ee83 100644 --- a/src/renderer/extensions/linearMode/LinearControls.vue +++ b/src/renderer/extensions/linearMode/LinearControls.vue @@ -71,8 +71,8 @@ async function runButtonClick(e: Event) { pendingJobQueues.value -= 1 } } -function handleDragDrop(e: DragEvent) { - return widgetListRef.value?.handleDragDrop(e) +function handleDragDrop() { + return widgetListRef.value?.handleDragDrop() } </script> <template> diff --git a/src/renderer/extensions/linearMode/Preview3d.test.ts b/src/renderer/extensions/linearMode/Preview3d.test.ts index 6629942be6..21d484dca8 100644 --- a/src/renderer/extensions/linearMode/Preview3d.test.ts +++ b/src/renderer/extensions/linearMode/Preview3d.test.ts @@ -1,11 +1,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { nextTick } from 'vue' -import { render } from '@testing-library/vue' +import { render, screen } from '@testing-library/vue' const initializeStandaloneViewer = vi.fn() const cleanup = vi.fn() +const viewerOverrides: Record<string, unknown> = {} + vi.mock('@/composables/useLoad3dViewer', () => ({ useLoad3dViewer: () => ({ initializeStandaloneViewer, @@ -16,20 +18,45 @@ vi.mock('@/composables/useLoad3dViewer', () => ({ handleBackgroundImageUpdate: vi.fn(), exportModel: vi.fn(), handleSeek: vi.fn(), - isSplatModel: false, - isPlyModel: false, + canUseGizmo: true, + canUseLighting: true, + canExport: true, + materialModes: ['original', 'normal', 'wireframe'], hasSkeleton: false, animations: [], playing: false, selectedSpeed: 1, selectedAnimation: 0, animationProgress: 0, - animationDuration: 0 + animationDuration: 0, + ...viewerOverrides }) })) vi.mock('@/components/load3d/Load3DControls.vue', () => ({ - default: { template: '<div />' } + default: { + name: 'Load3DControlsStub', + props: [ + 'sceneConfig', + 'modelConfig', + 'cameraConfig', + 'lightConfig', + 'canUseGizmo', + 'canUseLighting', + 'canExport', + 'materialModes', + 'hasSkeleton' + ], + template: ` + <div data-testid="load3d-controls" + :data-can-use-gizmo="canUseGizmo" + :data-can-use-lighting="canUseLighting" + :data-can-export="canExport" + :data-has-skeleton="hasSkeleton" + :data-material-modes="JSON.stringify(materialModes)" + /> + ` + } })) vi.mock('@/components/load3d/controls/AnimationControls.vue', () => ({ @@ -39,6 +66,7 @@ vi.mock('@/components/load3d/controls/AnimationControls.vue', () => ({ describe('Preview3d', () => { beforeEach(() => { vi.clearAllMocks() + for (const k of Object.keys(viewerOverrides)) delete viewerOverrides[k] }) afterEach(() => { @@ -102,6 +130,27 @@ describe('Preview3d', () => { expect(cleanup).toHaveBeenCalledOnce() }) + it('forwards the viewer capability flags to Load3DControls', async () => { + Object.assign(viewerOverrides, { + canUseGizmo: false, + canUseLighting: false, + canExport: false, + materialModes: [], + hasSkeleton: true + }) + + const { unmount } = await renderPreview3d() + + const controls = await screen.findByTestId('load3d-controls') + expect(controls.getAttribute('data-can-use-gizmo')).toBe('false') + expect(controls.getAttribute('data-can-use-lighting')).toBe('false') + expect(controls.getAttribute('data-can-export')).toBe('false') + expect(controls.getAttribute('data-has-skeleton')).toBe('true') + expect(controls.getAttribute('data-material-modes')).toBe('[]') + + unmount() + }) + it('reinitializes when modelUrl changes on existing instance', async () => { const result = await renderPreview3d( 'http://localhost/view?filename=model-a.glb' diff --git a/src/renderer/extensions/linearMode/Preview3d.vue b/src/renderer/extensions/linearMode/Preview3d.vue index bc670132d1..89836295cc 100644 --- a/src/renderer/extensions/linearMode/Preview3d.vue +++ b/src/renderer/extensions/linearMode/Preview3d.vue @@ -44,8 +44,10 @@ onUnmounted(() => { v-model:model-config="viewer" v-model:camera-config="viewer" v-model:light-config="viewer" - :is-splat-model="viewer.isSplatModel" - :is-ply-model="viewer.isPlyModel" + :can-use-gizmo="viewer.canUseGizmo" + :can-use-lighting="viewer.canUseLighting" + :can-export="viewer.canExport" + :material-modes="viewer.materialModes" :has-skeleton="viewer.hasSkeleton" @update-background-image="viewer.handleBackgroundImageUpdate" @export-model="viewer.exportModel" diff --git a/src/renderer/extensions/linearMode/linearOutputStore.ts b/src/renderer/extensions/linearMode/linearOutputStore.ts index 292d5b256e..6971b980eb 100644 --- a/src/renderer/extensions/linearMode/linearOutputStore.ts +++ b/src/renderer/extensions/linearMode/linearOutputStore.ts @@ -6,7 +6,7 @@ import { useWorkflowStore } from '@/platform/workflow/management/stores/workflow import { flattenNodeOutput } from '@/renderer/extensions/linearMode/flattenNodeOutput' import type { InProgressItem } from '@/renderer/extensions/linearMode/linearModeTypes' import type { ResultItemImpl } from '@/stores/queueStore' -import type { ExecutedWsMessage } from '@/schemas/apiSchema' +import type { ExecutedWsMessage, JobId } from '@/schemas/apiSchema' import { api } from '@/scripts/api' import { useAppModeStore } from '@/stores/appModeStore' import { useExecutionStore } from '@/stores/executionStore' @@ -23,8 +23,8 @@ export const useLinearOutputStore = defineStore('linearOutput', () => { const resolvedOutputsCache = new Map<string, ResultItemImpl[]>() const selectedId = ref<string | null>(null) const isFollowing = ref(true) - const trackedJobId = ref<string | null>(null) - const pendingResolve = ref(new Set<string>()) + const trackedJobId = ref<JobId | null>(null) + const pendingResolve = ref(new Set<JobId>()) const executedNodeIds = new Set<string>() const activeWorkflowInProgressItems = computed(() => { @@ -38,7 +38,7 @@ export const useLinearOutputStore = defineStore('linearOutput', () => { let nextSeq = 0 - function makeItemId(jobId: string): string { + function makeItemId(jobId: JobId): string { return `job-${jobId}-${nextSeq++}` } @@ -55,7 +55,7 @@ export const useLinearOutputStore = defineStore('linearOutput', () => { const currentSkeletonId = shallowRef<string | null>(null) - function onJobStart(jobId: string) { + function onJobStart(jobId: JobId) { executedNodeIds.clear() const item: InProgressItem = { @@ -71,7 +71,7 @@ export const useLinearOutputStore = defineStore('linearOutput', () => { } let raf: number | null = null - function onLatentPreview(jobId: string, url: string, nodeId?: string) { + function onLatentPreview(jobId: JobId, url: string, nodeId?: string) { if (nodeId && executedNodeIds.has(nodeId)) return // Issue in Firefox where it doesnt seem to always re-render, wrapping in RAF fixes it @@ -107,7 +107,7 @@ export const useLinearOutputStore = defineStore('linearOutput', () => { }) } - function onNodeExecuted(jobId: string, detail: ExecutedWsMessage) { + function onNodeExecuted(jobId: JobId, detail: ExecutedWsMessage) { const nodeId = String(detail.display_node || detail.node) executedNodeIds.add(nodeId) if (raf) { @@ -166,7 +166,7 @@ export const useLinearOutputStore = defineStore('linearOutput', () => { inProgressItems.value = [...newItems, ...inProgressItems.value] } - function onJobComplete(jobId: string) { + function onJobComplete(jobId: JobId) { // On any job complete, remove all pending resolve items. if (pendingResolve.value.size > 0) { for (const oldJobId of pendingResolve.value) { @@ -199,7 +199,7 @@ export const useLinearOutputStore = defineStore('linearOutput', () => { } } - function removeJobItems(jobId: string) { + function removeJobItems(jobId: JobId) { const removed = inProgressItems.value.filter((i) => i.jobId === jobId) inProgressItems.value = inProgressItems.value.filter( (i) => i.jobId !== jobId @@ -213,7 +213,7 @@ export const useLinearOutputStore = defineStore('linearOutput', () => { } } - function resolveIfReady(jobId: string, historyLoaded: boolean) { + function resolveIfReady(jobId: JobId, historyLoaded: boolean) { if (!pendingResolve.value.has(jobId)) return if (!historyLoaded) return @@ -234,14 +234,14 @@ export const useLinearOutputStore = defineStore('linearOutput', () => { isFollowing.value = true } - function isJobForActiveWorkflow(jobId: string): boolean { + function isJobForActiveWorkflow(jobId: JobId): boolean { return ( executionStore.jobIdToSessionWorkflowPath.get(jobId) === workflowStore.activeWorkflow?.path ) } - function autoSelect(slotId: string, jobId: string) { + function autoSelect(slotId: string, jobId: JobId) { // Only auto-select if the job belongs to the active workflow if (!isJobForActiveWorkflow(jobId)) return diff --git a/src/renderer/extensions/vueNodes/components/LGraphNode.test.ts b/src/renderer/extensions/vueNodes/components/LGraphNode.test.ts index e142f3bac3..340df2b41e 100644 --- a/src/renderer/extensions/vueNodes/components/LGraphNode.test.ts +++ b/src/renderer/extensions/vueNodes/components/LGraphNode.test.ts @@ -1,5 +1,6 @@ import { createTestingPinia } from '@pinia/testing' import { render, screen } from '@testing-library/vue' +import { setActivePinia } from 'pinia' import { beforeEach, describe, expect, it, vi } from 'vitest' import { computed } from 'vue' import type { ComponentProps } from 'vue-component-type-helpers' @@ -10,7 +11,8 @@ import { TitleMode } from '@/lib/litegraph/src/types/globalEnums' import LGraphNode from '@/renderer/extensions/vueNodes/components/LGraphNode.vue' import { useVueElementTracking } from '@/renderer/extensions/vueNodes/composables/useVueNodeResizeTracking' import { useCanvasStore } from '@/renderer/core/canvas/canvasStore' -import { setActivePinia } from 'pinia' +import { useSettingStore } from '@/platform/settings/settingStore' +import { app } from '@/scripts/app' const mockData = vi.hoisted(() => ({ mockExecuting: false, @@ -114,6 +116,13 @@ const i18n = createI18n({ locale: 'en', messages: { en: { + g: { + error: 'Error' + }, + rightSidePanel: { + showAdvancedShort: 'Show Advanced', + showAdvancedInputsButton: 'Show Advanced Inputs' + }, 'Node Render Error': 'Node Render Error' } } @@ -171,6 +180,12 @@ describe('LGraphNode', () => { setActivePinia(pinia) const canvasStore = useCanvasStore() canvasStore.selectedNodeIds.clear() + const settingStore = useSettingStore(pinia) + vi.mocked(settingStore.get).mockImplementation((key) => { + if (key === 'Comfy.RightSidePanel.ShowErrorsTab') return true + if (key === 'Comfy.Node.AlwaysShowAdvancedWidgets') return false + if (key === 'Comfy.Node.Opacity') return 1 + }) }) it('should call resize tracking composable with node ID', () => { @@ -256,6 +271,48 @@ describe('LGraphNode', () => { expect(root.style.getPropertyValue('--node-height-x')).toBe('') }) + it('should hide advanced footer button while the node is collapsed', () => { + renderLGraphNode({ + nodeData: { + ...mockNodeData, + flags: { collapsed: true }, + widgets: [ + { + name: 'advancedWidget', + type: 'number', + options: { advanced: true } + } + ] + } + }) + + expect( + screen.queryByRole('button', { name: /show advanced/i }) + ).not.toBeInTheDocument() + }) + + it('should show error-only footer for collapsed nodes with advanced widgets', () => { + renderLGraphNode({ + nodeData: { + ...mockNodeData, + flags: { collapsed: true }, + hasErrors: true, + widgets: [ + { + name: 'advancedWidget', + type: 'number', + options: { advanced: true } + } + ] + } + }) + + expect(screen.getByRole('button', { name: 'Error' })).toBeInTheDocument() + expect( + screen.queryByRole('button', { name: /show advanced/i }) + ).not.toBeInTheDocument() + }) + describe('Reroute node sizing', () => { it('should not enforce minimum width for reroute nodes', () => { const { container: rerouteContainer } = renderLGraphNode({ @@ -306,24 +363,7 @@ describe('LGraphNode', () => { }) describe('handleDrop', () => { - it('should call onDragDrop with claimEvent=true so the handler can claim the event sync', async () => { - const onDragDrop = vi.fn().mockResolvedValue(true) - mockData.mockLgraphNode = { - onDragDrop, - onDragOver: vi.fn(), - isSubgraphNode: () => false - } - - const { container } = renderLGraphNode({ nodeData: mockNodeData }) - const nodeEl = getNodeRoot(container) - - const dropEvent = new Event('drop', { bubbles: true, cancelable: true }) - nodeEl.dispatchEvent(dropEvent) - - expect(onDragDrop).toHaveBeenCalledWith(dropEvent, true) - }) - - it('should not stop propagation when node has no onDragDrop handler', async () => { + it('should set app.dragOverNode and let event bubble', async () => { mockData.mockLgraphNode = { onDragOver: vi.fn(), isSubgraphNode: () => false @@ -343,6 +383,7 @@ describe('LGraphNode', () => { ) expect(parentListener).toHaveBeenCalled() + expect(app.dragOverNode).toBe(mockData.mockLgraphNode) }) }) }) diff --git a/src/renderer/extensions/vueNodes/components/LGraphNode.vue b/src/renderer/extensions/vueNodes/components/LGraphNode.vue index a7d8c6238f..3933314f20 100644 --- a/src/renderer/extensions/vueNodes/components/LGraphNode.vue +++ b/src/renderer/extensions/vueNodes/components/LGraphNode.vue @@ -8,6 +8,7 @@ tabindex="0" :data-node-id="nodeData.id" :data-collapsed="isCollapsed || undefined" + :data-ghost="nodeData.flags?.ghost || undefined" :class=" cn( 'group/node lg-node absolute isolate text-sm', @@ -389,7 +390,7 @@ const muted = computed((): boolean => nodeData.mode === LGraphEventMode.NEVER) const nodeOpacity = computed(() => { const globalOpacity = settingStore.get('Comfy.Node.Opacity') ?? 1 - if (nodeData.flags?.ghost) return globalOpacity * 0.3 + if (nodeData.flags?.ghost) return globalOpacity * 0.6 // For muted/bypassed nodes, apply the 0.5 multiplier on top of global opacity if (bypassed.value || muted.value) { @@ -722,6 +723,7 @@ useGLSLPreview(lgraphNode) const showAdvancedInputsButton = computed(() => { const node = lgraphNode.value if (!node) return false + if (isCollapsed.value) return false // For subgraph nodes: check for unpromoted widgets if (node instanceof SubgraphNode) { @@ -816,12 +818,8 @@ function handleDragLeave() { isDraggingOver.value = false } -async function handleDrop(event: DragEvent) { +function handleDrop() { isDraggingOver.value = false - - const node = lgraphNode.value - if (!node?.onDragDrop) return - - await node.onDragDrop(event, true) + app.dragOverNode = lgraphNode.value } </script> diff --git a/src/renderer/extensions/vueNodes/components/SlotConnectionDot.vue b/src/renderer/extensions/vueNodes/components/SlotConnectionDot.vue index ca191a9bea..5f8d332cf8 100644 --- a/src/renderer/extensions/vueNodes/components/SlotConnectionDot.vue +++ b/src/renderer/extensions/vueNodes/components/SlotConnectionDot.vue @@ -46,7 +46,7 @@ const isListShape = computed(() => props.slotData?.shape === RenderShape.GRID) const slotClass = computed(() => cn( - 'slot-dot bg-slate-300', + 'slot-dot bg-ink-100', isListShape.value ? 'rounded-[1px]' : 'rounded-full', 'transition-all duration-150', 'border border-solid border-node-component-slot-dot-outline', diff --git a/src/renderer/extensions/vueNodes/widgets/components/WidgetLegacy.vue b/src/renderer/extensions/vueNodes/widgets/components/WidgetLegacy.vue index 00cb708c36..7a102140ef 100644 --- a/src/renderer/extensions/vueNodes/widgets/components/WidgetLegacy.vue +++ b/src/renderer/extensions/vueNodes/widgets/components/WidgetLegacy.vue @@ -92,6 +92,7 @@ function draw() { // @ts-expect-error canvasHeight is a custom property used by some extensions node.canvasHeight = height widgetInstance.y = 0 + widgetInstance.width = width canvasEl.value.height = (height + 2) * scaleFactor canvasEl.value.width = width * scaleFactor const ctx = canvasEl.value?.getContext('2d') diff --git a/src/renderer/extensions/vueNodes/widgets/components/WidgetMarkdown.test.ts b/src/renderer/extensions/vueNodes/widgets/components/WidgetMarkdown.test.ts index 5e04f258e9..2da54fcda0 100644 --- a/src/renderer/extensions/vueNodes/widgets/components/WidgetMarkdown.test.ts +++ b/src/renderer/extensions/vueNodes/widgets/components/WidgetMarkdown.test.ts @@ -2,8 +2,6 @@ /* eslint-disable testing-library/prefer-user-event */ import { fireEvent, render, screen } from '@testing-library/vue' import userEvent from '@testing-library/user-event' -import PrimeVue from 'primevue/config' -import Textarea from 'primevue/textarea' import { describe, expect, it, vi } from 'vitest' import { nextTick } from 'vue' import { createI18n } from 'vue-i18n' @@ -58,8 +56,7 @@ describe('WidgetMarkdown Dual Mode Display', () => { return render(WidgetMarkdown, { global: { - plugins: [PrimeVue, i18n], - components: { Textarea } + plugins: [i18n] }, props: { widget, diff --git a/src/renderer/extensions/vueNodes/widgets/components/WidgetMarkdown.vue b/src/renderer/extensions/vueNodes/widgets/components/WidgetMarkdown.vue index e8af8be950..ee2b72a300 100644 --- a/src/renderer/extensions/vueNodes/widgets/components/WidgetMarkdown.vue +++ b/src/renderer/extensions/vueNodes/widgets/components/WidgetMarkdown.vue @@ -1,28 +1,21 @@ <template> <div class="widget-markdown relative w-full" @dblclick="startEditing"> - <!-- Display mode: Rendered markdown --> <div class="comfy-markdown-content size-full min-h-[60px] overflow-y-auto rounded-lg text-sm" - :class="isEditing === false ? 'visible' : 'invisible'" + :class="isEditing ? 'invisible' : 'visible'" tabindex="0" data-capture-wheel="true" v-html="renderedHtml" /> - <!-- Edit mode: Textarea --> <Textarea v-show="isEditing" ref="textareaRef" v-model="modelValue" :aria-label="`${$t('g.edit')} ${widget.name || $t('g.markdown')} ${$t('g.content')}`" - class="absolute inset-0 min-h-[60px] w-full resize-none" - :pt="{ - root: { - class: 'text-sm w-full h-full', - onBlur: handleBlur - } - }" + class="absolute inset-0 min-h-[60px] w-full resize-none text-sm" data-capture-wheel="true" + @blur="handleBlur" @pointerdown.capture.stop @pointermove.capture.stop @pointerup.capture.stop @@ -33,9 +26,10 @@ </template> <script setup lang="ts"> -import Textarea from 'primevue/textarea' import { computed, nextTick, ref } from 'vue' +import Textarea from '@/components/ui/textarea/Textarea.vue' + import type { SimplifiedWidget } from '@/types/simplifiedWidget' import { renderMarkdownToHtml } from '@/utils/markdownRendererUtil' @@ -45,28 +39,23 @@ const { widget } = defineProps<{ const modelValue = defineModel<string>({ default: '' }) -// State const isEditing = ref(false) -const textareaRef = ref<InstanceType<typeof Textarea> | undefined>() +const textareaRef = ref<InstanceType<typeof Textarea>>() -// Computed -const renderedHtml = computed(() => { - return renderMarkdownToHtml(modelValue.value || '') -}) +const renderedHtml = computed(() => + renderMarkdownToHtml(modelValue.value || '') +) -// Methods -const startEditing = async () => { +async function startEditing() { if (isEditing.value || widget.options?.read_only) return isEditing.value = true await nextTick() - // Focus the textarea - // @ts-expect-error - $el is an internal property of the Textarea component - textareaRef.value?.$el?.focus() + textareaRef.value?.focus() } -const handleBlur = () => { +function handleBlur() { isEditing.value = false } </script> diff --git a/src/renderer/extensions/vueNodes/widgets/components/WidgetRecordAudio.vue b/src/renderer/extensions/vueNodes/widgets/components/WidgetRecordAudio.vue index 6152e911f5..f03f9d0bd0 100644 --- a/src/renderer/extensions/vueNodes/widgets/components/WidgetRecordAudio.vue +++ b/src/renderer/extensions/vueNodes/widgets/components/WidgetRecordAudio.vue @@ -35,7 +35,7 @@ <div v-for="(bar, index) in waveformBars" :key="index" - class="max-h-8 min-h-1 w-0.75 rounded-[1.5px] bg-slate-100 transition-all duration-100" + class="max-h-8 min-h-1 w-0.75 rounded-[1.5px] bg-text-secondary transition-all duration-100" :style="{ height: bar.height + 'px' }" :title="`Bar ${index + 1}: ${bar.height}px`" /> diff --git a/src/renderer/extensions/vueNodes/widgets/composables/audio/useAudioRecorder.ts b/src/renderer/extensions/vueNodes/widgets/composables/audio/useAudioRecorder.ts index ca0cc3d851..2393ccdd48 100644 --- a/src/renderer/extensions/vueNodes/widgets/composables/audio/useAudioRecorder.ts +++ b/src/renderer/extensions/vueNodes/widgets/composables/audio/useAudioRecorder.ts @@ -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 } diff --git a/src/renderer/extensions/vueNodes/widgets/composables/domWidgetTestUtils.ts b/src/renderer/extensions/vueNodes/widgets/composables/domWidgetTestUtils.ts new file mode 100644 index 0000000000..b7736eee9e --- /dev/null +++ b/src/renderer/extensions/vueNodes/widgets/composables/domWidgetTestUtils.ts @@ -0,0 +1,60 @@ +import { fromAny } from '@total-typescript/shoehorn' +import { vi } from 'vitest' + +import type { LGraphNode } from '@/lib/litegraph/src/litegraph' + +interface FakeDOMWidget { + name: string + type: string + element: HTMLElement + options: Record<string, unknown> + value: string + callback?: (value: string) => void + onRemove?: () => void + serialize?: boolean + serializeValue?: () => unknown +} + +interface FakeMediaWidget { + name: string + element: HTMLElement + onRemove?: () => void + serialize?: boolean + serializeValue?: () => unknown + computeLayoutSize?: () => { minHeight: number; minWidth: number } +} + +type NodeOverrides = Record<string, unknown> & { widgets?: never } + +export function createMockDOMWidgetNode(overrides: NodeOverrides = {}) { + const widgets: FakeDOMWidget[] = [] + return fromAny<LGraphNode & { widgets: FakeDOMWidget[] }, unknown>({ + id: 1, + widgets, + addDOMWidget: vi.fn((name: string, type: string, element: HTMLElement) => { + const widget: FakeDOMWidget = { + name, + type, + element, + options: {}, + value: '' + } + widgets.push(widget) + return widget + }), + ...overrides + }) +} + +export function createMockMediaNode(overrides: NodeOverrides = {}) { + const widgets: FakeMediaWidget[] = [] + return fromAny<LGraphNode & { widgets: FakeMediaWidget[] }, unknown>({ + widgets, + addDOMWidget: vi.fn((name: string, _type: string, element: HTMLElement) => { + const widget: FakeMediaWidget = { name, element } + widgets.push(widget) + return widget + }), + ...overrides + }) +} diff --git a/src/renderer/extensions/vueNodes/widgets/composables/useImagePreviewWidget.test.ts b/src/renderer/extensions/vueNodes/widgets/composables/useImagePreviewWidget.test.ts new file mode 100644 index 0000000000..ba1add92e1 --- /dev/null +++ b/src/renderer/extensions/vueNodes/widgets/composables/useImagePreviewWidget.test.ts @@ -0,0 +1,553 @@ +import { fromAny, fromPartial } from '@total-typescript/shoehorn' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { LiteGraph } from '@/lib/litegraph/src/litegraph' +import type { CanvasPointer, LGraphNode } from '@/lib/litegraph/src/litegraph' +import type { BaseWidget } from '@/lib/litegraph/src/widgets/BaseWidget' +import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2' + +const mockSettingStore = vi.hoisted(() => ({ + get: vi.fn(() => false) +})) + +const mockCanvas = vi.hoisted(() => ({ + graph_mouse: [0, 0] as [number, number], + pointer_is_down: false, + canvas: { style: { cursor: '' } }, + setDirty: vi.fn() +})) + +vi.mock('@/platform/settings/settingStore', () => ({ + useSettingStore: () => mockSettingStore +})) + +vi.mock('@/renderer/core/canvas/canvasStore', () => ({ + useCanvasStore: () => ({ + getCanvas: () => mockCanvas + }) +})) + +vi.mock('@/scripts/app', () => ({ + app: { + canvas: { + emitBeforeChange: vi.fn(), + emitAfterChange: vi.fn(), + isDragging: false, + processSelect: vi.fn(), + graph: { + beforeChange: vi.fn(), + afterChange: vi.fn(), + snapToGrid: vi.fn() + }, + setDirty: vi.fn() + } + } +})) + +vi.mock('@/scripts/ui/imagePreview', () => ({ + calculateImageGrid: vi.fn(() => ({ + cellWidth: 100, + cellHeight: 100, + cols: 2, + rows: 1, + shiftX: 0 + })) +})) + +vi.mock('@/utils/imageUtil', () => ({ + is_all_same_aspect_ratio: vi.fn(() => true) +})) + +import { calculateImageGrid } from '@/scripts/ui/imagePreview' +import { is_all_same_aspect_ratio } from '@/utils/imageUtil' + +import { useImagePreviewWidget } from './useImagePreviewWidget' + +// TODO(PR #11394): The CanvasRenderingContext2D / LGraphNode surface is too +// large to migrate to shoehorn fromPartial here without dragging in mountains +// of unused properties. Leave the `as unknown as` casts in these factories; +// migrate when the SUT is refactored to depend on a smaller render port. +function createMockCtx(): CanvasRenderingContext2D { + const transform = new DOMMatrix() + return { + save: vi.fn(), + restore: vi.fn(), + beginPath: vi.fn(), + arc: vi.fn(), + stroke: vi.fn(), + fill: vi.fn(), + fillText: vi.fn(), + strokeRect: vi.fn(), + roundRect: vi.fn(), + drawImage: vi.fn(), + getTransform: vi.fn(() => transform), + setTransform: vi.fn(), + fillStyle: '', + strokeStyle: '', + lineWidth: 1, + lineCap: 'butt', + textAlign: 'left', + font: '', + filter: 'none' + } as unknown as CanvasRenderingContext2D +} + +function createMockNode(overrides: Record<string, unknown> = {}): LGraphNode { + return { + id: 1, + size: [300, 400], + pos: [0, 0], + imgs: [], + imageIndex: null, + pointerDown: null, + overIndex: null, + imageRects: [], + isUploading: false, + widgets: [], + graph: { setDirtyCanvas: vi.fn(), rootGraph: { id: 'test-graph' } }, + addCustomWidget: vi.fn((w) => w), + ...overrides + } as unknown as LGraphNode +} + +function createMockImage(width: number, height: number): HTMLImageElement { + return { + naturalWidth: width, + naturalHeight: height, + width, + height + } as HTMLImageElement +} + +function getWidget(node: LGraphNode): BaseWidget { + expect(node.addCustomWidget).toHaveBeenCalledTimes(1) + return vi.mocked(node.addCustomWidget).mock.calls[0][0] as BaseWidget +} + +const defaultInputSpec = fromPartial<InputSpec>({ + name: 'preview', + type: 'CUSTOM' +}) + +describe('useImagePreviewWidget', () => { + beforeEach(() => { + vi.clearAllMocks() + // clearAllMocks does not reset mockReturnValue — restore defaults explicitly + mockSettingStore.get.mockReturnValue(false) + vi.mocked(is_all_same_aspect_ratio).mockReturnValue(true) + mockCanvas.graph_mouse = [0, 0] + mockCanvas.pointer_is_down = false + mockCanvas.canvas.style.cursor = '' + }) + + // Restore real timers unconditionally so a thrown assertion in any + // useFakeTimers() test does not leak fake timers into later tests. + // Idempotent when timers are already real. + afterEach(() => { + vi.useRealTimers() + }) + + describe('widget construction', () => { + it('returns a widget constructor function', () => { + const constructor = useImagePreviewWidget() + expect(typeof constructor).toBe('function') + }) + + it('creates a widget with correct name and type', () => { + const constructor = useImagePreviewWidget() + const node = createMockNode() + constructor(node, defaultInputSpec) + + const widget = getWidget(node) + expect(widget.name).toBe('preview') + expect(widget.type).toBe('custom') + }) + + it('widget options include serialize false and canvasOnly', () => { + const constructor = useImagePreviewWidget() + const node = createMockNode() + constructor(node, defaultInputSpec) + + const widget = getWidget(node) + expect(widget.options).toMatchObject({ + serialize: false, + canvasOnly: true + }) + }) + }) + + describe('computeLayoutSize', () => { + it('returns minHeight 220 and minWidth 1', () => { + const constructor = useImagePreviewWidget() + const node = createMockNode() + constructor(node, defaultInputSpec) + + const widget = getWidget(node) + const size = widget.computeLayoutSize!(node) + expect(size).toEqual({ minHeight: 220, minWidth: 1 }) + }) + }) + + describe('createCopyForNode', () => { + it('creates a copy bound to a new node', () => { + const constructor = useImagePreviewWidget() + const node1 = createMockNode({ id: 1 }) + constructor(node1, defaultInputSpec) + + const widget = getWidget(node1) + const node2 = createMockNode({ id: 2 }) + const copy = widget.createCopyForNode!(node2) + + expect(copy.name).toBe('preview') + expect(copy.type).toBe('custom') + }) + }) + + describe('drawWidget — upload spinner', () => { + it('renders spinner when node.isUploading is true', () => { + vi.useFakeTimers() + vi.setSystemTime(500) + + const constructor = useImagePreviewWidget() + const node = createMockNode({ isUploading: true }) + constructor(node, defaultInputSpec) + + const widget = getWidget(node) + const ctx = createMockCtx() + + widget.drawWidget(ctx, { width: 300 }) + + expect(ctx.save).toHaveBeenCalled() + expect(ctx.beginPath).toHaveBeenCalled() + expect(ctx.arc).toHaveBeenCalled() + expect(ctx.stroke).toHaveBeenCalled() + expect(ctx.restore).toHaveBeenCalled() + expect(node.graph!.setDirtyCanvas).toHaveBeenCalledWith(true) + }) + + it('uses LiteGraph.NODE_TEXT_COLOR for spinner stroke', () => { + vi.useFakeTimers() + vi.setSystemTime(0) + + const constructor = useImagePreviewWidget() + const node = createMockNode({ isUploading: true }) + constructor(node, defaultInputSpec) + + const widget = getWidget(node) + const ctx = createMockCtx() + + widget.drawWidget(ctx, { width: 300 }) + + expect(ctx.strokeStyle).toBe(LiteGraph.NODE_TEXT_COLOR) + }) + }) + + describe('drawWidget — single image', () => { + it('draws a single image when imageIndex is 0', async () => { + const constructor = useImagePreviewWidget() + const img = createMockImage(200, 100) + const node = createMockNode({ + imgs: [img], + imageIndex: 0 + }) + constructor(node, defaultInputSpec) + + const widget = getWidget(node) + widget.computedHeight = 220 + const ctx = createMockCtx() + + widget.drawWidget(ctx, { width: 300 }) + + // Deferred render: flush microtask queue + await vi.waitFor(() => { + expect(ctx.drawImage).toHaveBeenCalled() + }) + }) + + it('auto-sets imageIndex to 0 for single image with null index', () => { + const constructor = useImagePreviewWidget() + const img = createMockImage(200, 100) + const node = createMockNode({ + imgs: [img], + imageIndex: null + }) + constructor(node, defaultInputSpec) + + const widget = getWidget(node) + widget.computedHeight = 220 + const ctx = createMockCtx() + + widget.drawWidget(ctx, { width: 300 }) + + expect(node.imageIndex).toBe(0) + }) + + // Regression lock for SUT line 109: `if (numImages === 1 && !imageIndex)`. + // The falsy `!imageIndex` branch is also taken when imageIndex === 0, but + // because the assignment is `imageIndex = 0` it is harmless today. If the + // SUT is later changed to `imageIndex == null` (the safer check), this + // test should keep passing — it pins observable behavior, not the bug. + // TODO: follow-up to harden SUT to `imageIndex == null`; out of scope for + // PR #11394 (test-only PR). + it('keeps imageIndex at 0 for single image when index is already 0', () => { + const constructor = useImagePreviewWidget() + const img = createMockImage(200, 100) + const node = createMockNode({ + imgs: [img], + imageIndex: 0 + }) + constructor(node, defaultInputSpec) + + const widget = getWidget(node) + widget.computedHeight = 220 + const ctx = createMockCtx() + + widget.drawWidget(ctx, { width: 300 }) + + expect(node.imageIndex).toBe(0) + }) + + it('does not draw when imgs is empty', () => { + const constructor = useImagePreviewWidget() + const node = createMockNode({ imgs: [], imageIndex: null }) + constructor(node, defaultInputSpec) + + const widget = getWidget(node) + widget.computedHeight = 220 + const ctx = createMockCtx() + + widget.drawWidget(ctx, { width: 300 }) + + expect(ctx.drawImage).not.toHaveBeenCalled() + }) + + it('does not draw when node.size is undefined', () => { + const constructor = useImagePreviewWidget() + const node = createMockNode({ + size: undefined as unknown as [number, number], + imgs: [createMockImage(100, 100)], + imageIndex: 0 + }) + constructor(node, defaultInputSpec) + + const widget = getWidget(node) + const ctx = createMockCtx() + + widget.drawWidget(ctx, { width: 300 }) + + expect(ctx.drawImage).not.toHaveBeenCalled() + }) + }) + + describe('drawWidget — image size text', () => { + it('draws image size text when setting is enabled', () => { + mockSettingStore.get.mockReturnValue(true) + + const constructor = useImagePreviewWidget() + const img = createMockImage(512, 768) + const node = createMockNode({ + imgs: [img], + imageIndex: 0 + }) + constructor(node, defaultInputSpec) + + const widget = getWidget(node) + widget.computedHeight = 235 + const ctx = createMockCtx() + + widget.drawWidget(ctx, { width: 300 }) + + expect(ctx.fillText).toHaveBeenCalledWith( + '512 × 768', + expect.any(Number), + expect.any(Number) + ) + }) + + it('does not draw image size text when setting is disabled', () => { + mockSettingStore.get.mockReturnValue(false) + + const constructor = useImagePreviewWidget() + const img = createMockImage(512, 768) + const node = createMockNode({ + imgs: [img], + imageIndex: 0 + }) + constructor(node, defaultInputSpec) + + const widget = getWidget(node) + widget.computedHeight = 220 + const ctx = createMockCtx() + + widget.drawWidget(ctx, { width: 300 }) + + expect(ctx.fillText).not.toHaveBeenCalled() + }) + }) + + describe('drawWidget — multi-image thumbnail grid', () => { + it('renders thumbnail grid when imageIndex is null', () => { + vi.mocked(is_all_same_aspect_ratio).mockReturnValue(true) + + const constructor = useImagePreviewWidget() + const imgs = [createMockImage(100, 100), createMockImage(100, 100)] + const node = createMockNode({ imgs, imageIndex: null }) + constructor(node, defaultInputSpec) + + const widget = getWidget(node) + widget.computedHeight = 220 + const ctx = createMockCtx() + + widget.drawWidget(ctx, { width: 300 }) + + expect(calculateImageGrid).toHaveBeenCalledWith(imgs, 300, 220) + expect(node.imageRects).toHaveLength(2) + }) + + it('uses non-compact mode for mixed aspect ratios', () => { + vi.mocked(is_all_same_aspect_ratio).mockReturnValue(false) + + const constructor = useImagePreviewWidget() + const imgs = [createMockImage(200, 100), createMockImage(100, 200)] + const node = createMockNode({ imgs, imageIndex: null }) + constructor(node, defaultInputSpec) + + const widget = getWidget(node) + widget.computedHeight = 220 + const ctx = createMockCtx() + + widget.drawWidget(ctx, { width: 300 }) + + // Non-compact mode draws stroke rects for cell borders + expect(ctx.strokeRect).toHaveBeenCalled() + }) + + it('does not draw cell borders in compact mode', () => { + vi.mocked(is_all_same_aspect_ratio).mockReturnValue(true) + + const constructor = useImagePreviewWidget() + const imgs = [createMockImage(100, 100), createMockImage(100, 100)] + const node = createMockNode({ imgs, imageIndex: null }) + constructor(node, defaultInputSpec) + + const widget = getWidget(node) + widget.computedHeight = 220 + const ctx = createMockCtx() + + widget.drawWidget(ctx, { width: 300 }) + + expect(ctx.strokeRect).not.toHaveBeenCalled() + }) + }) + + describe('drawWidget — pointer interaction', () => { + it('clears pointerDown when pointer is released without movement', () => { + const constructor = useImagePreviewWidget() + const imgs = [createMockImage(100, 100)] + const node = createMockNode({ + imgs, + imageIndex: 0, + pointerDown: { index: 0, pos: [50, 50] } + }) + constructor(node, defaultInputSpec) + + mockCanvas.pointer_is_down = false + mockCanvas.graph_mouse = [50, 50] + + const widget = getWidget(node) + widget.computedHeight = 220 + const ctx = createMockCtx() + + widget.drawWidget(ctx, { width: 300 }) + + expect(node.imageIndex).toBe(0) + expect(node.pointerDown).toBeNull() + }) + + it('resets overIndex when no thumbnail is hovered', () => { + vi.mocked(is_all_same_aspect_ratio).mockReturnValue(true) + + // Place mouse far outside any cell + mockCanvas.graph_mouse = [9999, 9999] + + const constructor = useImagePreviewWidget() + const imgs = [createMockImage(100, 100), createMockImage(100, 100)] + const node = createMockNode({ + imgs, + imageIndex: null, + overIndex: 1 + }) + constructor(node, defaultInputSpec) + + const widget = getWidget(node) + widget.computedHeight = 220 + const ctx = createMockCtx() + + widget.drawWidget(ctx, { width: 300 }) + + expect(node.overIndex).toBeNull() + expect(node.pointerDown).toBeNull() + }) + }) + + describe('drawWidget — previewImages override', () => { + it('uses previewImages from options when provided', async () => { + const constructor = useImagePreviewWidget() + const nodeImg = createMockImage(100, 100) + const previewImg = createMockImage(200, 200) + const node = createMockNode({ + imgs: [nodeImg], + imageIndex: 0 + }) + constructor(node, defaultInputSpec) + + const widget = getWidget(node) + widget.computedHeight = 220 + const ctx = createMockCtx() + + widget.drawWidget(ctx, { + width: 300, + previewImages: [previewImg] + }) + + await vi.waitFor(() => { + expect(ctx.drawImage).toHaveBeenCalled() + }) + + const drawCall = vi.mocked(ctx.drawImage).mock.calls[0] + expect(drawCall[0]).toBe(previewImg) + }) + }) + + describe('onPointerDown', () => { + it('returns true to indicate the event is handled', () => { + const constructor = useImagePreviewWidget() + const node = createMockNode() + constructor(node, defaultInputSpec) + + const widget = getWidget(node) + const pointer = fromPartial<CanvasPointer>({ + eDown: new MouseEvent('pointerdown') + }) + + const result = widget.onPointerDown!(pointer, node, fromAny(mockCanvas)) + expect(result).toBe(true) + }) + + it('sets up drag handlers on the pointer', () => { + const constructor = useImagePreviewWidget() + const node = createMockNode() + constructor(node, defaultInputSpec) + + const widget = getWidget(node) + const pointer = fromPartial<CanvasPointer>({ + eDown: new MouseEvent('pointerdown') + }) + + widget.onPointerDown!(pointer, node, fromAny(mockCanvas)) + + expect(pointer.onDragStart).toBeTypeOf('function') + expect(pointer.onDragEnd).toBeTypeOf('function') + }) + }) +}) diff --git a/src/renderer/extensions/vueNodes/widgets/composables/useImageUploadWidget.ts b/src/renderer/extensions/vueNodes/widgets/composables/useImageUploadWidget.ts index 9fa509118c..3c117007ce 100644 --- a/src/renderer/extensions/vueNodes/widgets/composables/useImageUploadWidget.ts +++ b/src/renderer/extensions/vueNodes/widgets/composables/useImageUploadWidget.ts @@ -3,7 +3,7 @@ import { useNodeImageUpload } from '@/composables/node/useNodeImageUpload' import { t } from '@/i18n' import type { LGraphNode } from '@/lib/litegraph/src/litegraph' import type { IComboWidget } from '@/lib/litegraph/src/types/widgets' -import type { ResultItemType } from '@/schemas/apiSchema' +import type { ResultItem, ResultItemType } from '@/schemas/apiSchema' import type { InputSpec } from '@/schemas/nodeDefSchema' import type { ComfyWidgetConstructor } from '@/scripts/widgets' import { useNodeOutputStore } from '@/stores/nodeOutputStore' @@ -52,7 +52,7 @@ export const useImageUploadWidget = () => { if (!fileComboWidget) { throw new Error(`Widget "${imageInputName}" not found on node`) } - const formatPath = (value: string) => + const formatPath = (value: string | ResultItem) => createAnnotatedPath(value, { rootFolder: image_folder }) // Setup file upload handling diff --git a/src/renderer/extensions/vueNodes/widgets/composables/useMarkdownWidget.test.ts b/src/renderer/extensions/vueNodes/widgets/composables/useMarkdownWidget.test.ts new file mode 100644 index 0000000000..b5d5796f0d --- /dev/null +++ b/src/renderer/extensions/vueNodes/widgets/composables/useMarkdownWidget.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it, onTestFinished, vi } from 'vitest' + +import type * as Litegraph from '@/lib/litegraph/src/litegraph' +import type { LGraphNode } from '@/lib/litegraph/src/litegraph' +import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2' +import type { DOMWidget } from '@/scripts/domWidget' +import { useMarkdownWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useMarkdownWidget' +import { createMockDOMWidgetNode } from '@/renderer/extensions/vueNodes/widgets/composables/domWidgetTestUtils' + +const { canvasMock } = vi.hoisted(() => ({ + canvasMock: { + processMouseDown: vi.fn(), + processMouseMove: vi.fn(), + processMouseUp: vi.fn() + } +})) + +vi.mock('@/scripts/app', () => ({ + app: { rootGraph: { id: 'root' }, canvas: canvasMock } +})) +vi.mock('@/lib/litegraph/src/litegraph', async (importOriginal) => { + const actual = await importOriginal<typeof Litegraph>() + return { ...actual, resolveNodeRootGraphId: vi.fn(() => 'root') } +}) +vi.mock('@/stores/widgetValueStore', () => ({ + useWidgetValueStore: () => ({ getWidget: () => undefined }) +})) + +function createMarkdownWidget(node: LGraphNode) { + const inputSpec: InputSpec = { + type: 'MARKDOWN', + name: 'note', + default: '' + } + return useMarkdownWidget()(node, inputSpec) as DOMWidget<HTMLElement, string> +} + +describe('useMarkdownWidget', () => { + function setup() { + vi.clearAllMocks() + const node = createMockDOMWidgetNode() + const widget = createMarkdownWidget(node) + const callback = vi.fn<(value: string) => void>() + widget.callback = callback + const inputEl = widget.element + const textarea = inputEl.querySelector('textarea')! + const parentKeydown = vi.fn<(ev: KeyboardEvent) => void>() + document.body.append(inputEl) + document.body.addEventListener('keydown', parentKeydown) + onTestFinished(() => { + document.body.removeEventListener('keydown', parentKeydown) + inputEl.remove() + }) + return { widget, inputEl, textarea, callback, parentKeydown } + } + + it('fires the widget callback on textarea input and change', () => { + const { textarea, callback } = setup() + textarea.value = 'hello' + textarea.dispatchEvent(new Event('input', { bubbles: true })) + textarea.dispatchEvent(new Event('change', { bubbles: true })) + expect(callback).toHaveBeenCalledTimes(2) + }) + + it('toggles editing on dblclick/blur and stops keydown propagation', () => { + const { inputEl, textarea, parentKeydown } = setup() + inputEl.dispatchEvent(new Event('dblclick', { bubbles: true })) + expect(inputEl.classList.contains('editing')).toBe(true) + + textarea.dispatchEvent(new Event('blur')) + expect(inputEl.classList.contains('editing')).toBe(false) + + inputEl.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true })) + expect(parentKeydown).not.toHaveBeenCalled() + }) + + it('forwards middle-click pointer events to the canvas while alive', () => { + const { inputEl } = setup() + inputEl.dispatchEvent(new PointerEvent('pointerdown', { button: 1 })) + inputEl.dispatchEvent(new PointerEvent('pointermove', { buttons: 4 })) + inputEl.dispatchEvent(new PointerEvent('pointerup', { button: 1 })) + + expect(canvasMock.processMouseDown).toHaveBeenCalledTimes(1) + expect(canvasMock.processMouseMove).toHaveBeenCalledTimes(1) + expect(canvasMock.processMouseUp).toHaveBeenCalledTimes(1) + }) + + it('detaches every listener and lets keydown bubble after removal', () => { + const { widget, inputEl, textarea, callback, parentKeydown } = setup() + widget.onRemove?.() + + textarea.value = 'after' + textarea.dispatchEvent(new Event('input', { bubbles: true })) + textarea.dispatchEvent(new Event('change', { bubbles: true })) + inputEl.dispatchEvent(new Event('dblclick', { bubbles: true })) + inputEl.dispatchEvent(new PointerEvent('pointerdown', { button: 1 })) + inputEl.dispatchEvent(new PointerEvent('pointermove', { buttons: 4 })) + inputEl.dispatchEvent(new PointerEvent('pointerup', { button: 1 })) + inputEl.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true })) + + expect(callback).not.toHaveBeenCalled() + expect(canvasMock.processMouseDown).not.toHaveBeenCalled() + expect(canvasMock.processMouseMove).not.toHaveBeenCalled() + expect(canvasMock.processMouseUp).not.toHaveBeenCalled() + expect(inputEl.classList.contains('editing')).toBe(false) + // keydown listener (which called stopPropagation) is gone, so the event + // now bubbles to the parent. + expect(parentKeydown).toHaveBeenCalledTimes(1) + }) + + it('survives onRemove being invoked twice', () => { + const { widget } = setup() + widget.onRemove?.() + expect(() => widget.onRemove?.()).not.toThrow() + }) +}) diff --git a/src/renderer/extensions/vueNodes/widgets/composables/useMarkdownWidget.ts b/src/renderer/extensions/vueNodes/widgets/composables/useMarkdownWidget.ts index 694baf86e4..a04323770b 100644 --- a/src/renderer/extensions/vueNodes/widgets/composables/useMarkdownWidget.ts +++ b/src/renderer/extensions/vueNodes/widgets/composables/useMarkdownWidget.ts @@ -7,6 +7,7 @@ import TiptapTableRow from '@tiptap/extension-table-row' import TiptapStarterKit from '@tiptap/starter-kit' import { Markdown as TiptapMarkdown } from 'tiptap-markdown' +import { useChainCallback } from '@/composables/functional/useChainCallback' import { resolveNodeRootGraphId } from '@/lib/litegraph/src/litegraph' import type { LGraphNode } from '@/lib/litegraph/src/litegraph' import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2' @@ -64,49 +65,73 @@ function addMarkdownWidget( widget.element = inputEl widget.options.minNodeSize = [400, 200] - inputEl.addEventListener('input', (event) => { - if (event.target instanceof HTMLTextAreaElement) { - widget.value = event.target.value - } - widget.callback?.(widget.value) + const controller = new AbortController() + const { signal } = controller + + inputEl.addEventListener( + 'input', + (event) => { + if (event.target instanceof HTMLTextAreaElement) { + widget.value = event.target.value + } + widget.callback?.(widget.value) + }, + { signal } + ) + + inputEl.addEventListener( + 'dblclick', + () => { + inputEl.classList.add('editing') + setTimeout(() => textarea.focus(), 0) + }, + { signal } + ) + + textarea.addEventListener('blur', () => inputEl.classList.remove('editing'), { + signal }) - inputEl.addEventListener('dblclick', () => { - inputEl.classList.add('editing') - setTimeout(() => { - textarea.focus() - }, 0) + textarea.addEventListener( + 'change', + () => { + editor.commands.setContent(textarea.value) + widget.callback?.(widget.value) + }, + { signal } + ) + + inputEl.addEventListener('keydown', (event) => event.stopPropagation(), { + signal }) - textarea.addEventListener('blur', () => { - inputEl.classList.remove('editing') - }) + inputEl.addEventListener( + 'pointerdown', + (event) => { + if (event.button === 1) app.canvas.processMouseDown(event) + }, + { signal } + ) - textarea.addEventListener('change', () => { - editor.commands.setContent(textarea.value) - widget.callback?.(widget.value) - }) + inputEl.addEventListener( + 'pointermove', + (event) => { + if ((event.buttons & 4) === 4) app.canvas.processMouseMove(event) + }, + { signal } + ) - inputEl.addEventListener('keydown', (event: KeyboardEvent) => { - event.stopPropagation() - }) + inputEl.addEventListener( + 'pointerup', + (event) => { + if (event.button === 1) app.canvas.processMouseUp(event) + }, + { signal } + ) - inputEl.addEventListener('pointerdown', (event: PointerEvent) => { - if (event.button === 1) { - app.canvas.processMouseDown(event) - } - }) - - inputEl.addEventListener('pointermove', (event: PointerEvent) => { - if ((event.buttons & 4) === 4) { - app.canvas.processMouseMove(event) - } - }) - - inputEl.addEventListener('pointerup', (event: PointerEvent) => { - if (event.button === 1) { - app.canvas.processMouseUp(event) - } + widget.onRemove = useChainCallback(widget.onRemove, () => { + controller.abort() + if (!editor.isDestroyed) editor.destroy() }) return widget diff --git a/src/renderer/extensions/vueNodes/widgets/composables/useStringWidget.test.ts b/src/renderer/extensions/vueNodes/widgets/composables/useStringWidget.test.ts new file mode 100644 index 0000000000..2c7258f37c --- /dev/null +++ b/src/renderer/extensions/vueNodes/widgets/composables/useStringWidget.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it, onTestFinished, vi } from 'vitest' + +import type * as Litegraph from '@/lib/litegraph/src/litegraph' +import type { LGraphNode } from '@/lib/litegraph/src/litegraph' +import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2' +import type { DOMWidget } from '@/scripts/domWidget' +import { useStringWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useStringWidget' +import { createMockDOMWidgetNode } from '@/renderer/extensions/vueNodes/widgets/composables/domWidgetTestUtils' + +const { canvasMock } = vi.hoisted(() => ({ + canvasMock: { + processMouseDown: vi.fn(), + processMouseMove: vi.fn(), + processMouseUp: vi.fn(), + processMouseWheel: vi.fn() + } +})) + +vi.mock('@/scripts/app', () => ({ + app: { rootGraph: { id: 'root' }, canvas: canvasMock } +})) +vi.mock('@/lib/litegraph/src/litegraph', async (importOriginal) => { + const actual = await importOriginal<typeof Litegraph>() + return { ...actual, resolveNodeRootGraphId: vi.fn(() => 'root') } +}) +vi.mock('@/stores/widgetValueStore', () => ({ + useWidgetValueStore: () => ({ getWidget: () => undefined }) +})) +vi.mock('@/platform/settings/settingStore', () => ({ + useSettingStore: () => ({ get: () => false }) +})) + +function createStringWidget(node: LGraphNode) { + const inputSpec: InputSpec = { + type: 'STRING', + name: 'prompt', + default: '', + multiline: true + } + return useStringWidget()(node, inputSpec) as DOMWidget< + HTMLTextAreaElement, + string + > +} + +describe('useStringWidget (multiline)', () => { + function setup() { + vi.clearAllMocks() + const node = createMockDOMWidgetNode() + const widget = createStringWidget(node) + const callback = vi.fn<(value: string) => void>() + widget.callback = callback + const inputEl = widget.element + document.body.append(inputEl) + onTestFinished(() => inputEl.remove()) + return { widget, inputEl, callback } + } + + it('fires the widget callback on input', () => { + const { inputEl, callback } = setup() + inputEl.value = 'hello' + inputEl.dispatchEvent(new Event('input', { bubbles: true })) + expect(callback).toHaveBeenCalledTimes(1) + }) + + it('forwards middle-click pointer events and ctrl+wheel to the canvas while alive', () => { + const { inputEl } = setup() + inputEl.dispatchEvent(new PointerEvent('pointerdown', { button: 1 })) + inputEl.dispatchEvent(new PointerEvent('pointermove', { buttons: 4 })) + inputEl.dispatchEvent(new PointerEvent('pointerup', { button: 1 })) + inputEl.dispatchEvent(new WheelEvent('wheel', { ctrlKey: true })) + + expect(canvasMock.processMouseDown).toHaveBeenCalledTimes(1) + expect(canvasMock.processMouseMove).toHaveBeenCalledTimes(1) + expect(canvasMock.processMouseUp).toHaveBeenCalledTimes(1) + expect(canvasMock.processMouseWheel).toHaveBeenCalledTimes(1) + }) + + it('detaches every listener when the widget is removed', () => { + const { widget, inputEl, callback } = setup() + widget.onRemove?.() + + inputEl.value = 'after' + inputEl.dispatchEvent(new Event('input', { bubbles: true })) + inputEl.dispatchEvent(new PointerEvent('pointerdown', { button: 1 })) + inputEl.dispatchEvent(new PointerEvent('pointermove', { buttons: 4 })) + inputEl.dispatchEvent(new PointerEvent('pointerup', { button: 1 })) + inputEl.dispatchEvent(new WheelEvent('wheel', { ctrlKey: true })) + + expect(callback).not.toHaveBeenCalled() + expect(canvasMock.processMouseDown).not.toHaveBeenCalled() + expect(canvasMock.processMouseMove).not.toHaveBeenCalled() + expect(canvasMock.processMouseUp).not.toHaveBeenCalled() + expect(canvasMock.processMouseWheel).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/extensions/vueNodes/widgets/composables/useStringWidget.ts b/src/renderer/extensions/vueNodes/widgets/composables/useStringWidget.ts index d7f914dac6..74de999997 100644 --- a/src/renderer/extensions/vueNodes/widgets/composables/useStringWidget.ts +++ b/src/renderer/extensions/vueNodes/widgets/composables/useStringWidget.ts @@ -1,3 +1,4 @@ +import { useChainCallback } from '@/composables/functional/useChainCallback' import type { LGraphNode } from '@/lib/litegraph/src/litegraph' import { resolveNodeRootGraphId } from '@/lib/litegraph/src/litegraph' import { defineDeprecatedProperty } from '@/lib/litegraph/src/utils/feedback' @@ -51,81 +52,102 @@ function addMultilineWidget( ) widget.options.minNodeSize = [400, 200] - inputEl.addEventListener('input', (event) => { - if (event.target instanceof HTMLTextAreaElement) { - widget.value = event.target.value - } - widget.callback?.(widget.value) - }) + const controller = new AbortController() + const { signal } = controller + + inputEl.addEventListener( + 'input', + (event) => { + if (event.target instanceof HTMLTextAreaElement) { + widget.value = event.target.value + } + widget.callback?.(widget.value) + }, + { signal } + ) // Allow middle mouse button panning - inputEl.addEventListener('pointerdown', (event: PointerEvent) => { - if (event.button === 1) { - app.canvas.processMouseDown(event) - } - }) + inputEl.addEventListener( + 'pointerdown', + (event: PointerEvent) => { + if (event.button === 1) app.canvas.processMouseDown(event) + }, + { signal } + ) - inputEl.addEventListener('pointermove', (event: PointerEvent) => { - if ((event.buttons & 4) === 4) { - app.canvas.processMouseMove(event) - } - }) + inputEl.addEventListener( + 'pointermove', + (event: PointerEvent) => { + if ((event.buttons & 4) === 4) app.canvas.processMouseMove(event) + }, + { signal } + ) - inputEl.addEventListener('pointerup', (event: PointerEvent) => { - if (event.button === 1) { - app.canvas.processMouseUp(event) - } - }) + inputEl.addEventListener( + 'pointerup', + (event: PointerEvent) => { + if (event.button === 1) app.canvas.processMouseUp(event) + }, + { signal } + ) - inputEl.addEventListener('wheel', (event: WheelEvent) => { - const gesturesEnabled = useSettingStore().get( - 'LiteGraph.Pointer.TrackpadGestures' - ) - const deltaX = event.deltaX - const deltaY = event.deltaY + inputEl.addEventListener( + 'wheel', + (event: WheelEvent) => { + const gesturesEnabled = useSettingStore().get( + 'LiteGraph.Pointer.TrackpadGestures' + ) + const deltaX = event.deltaX + const deltaY = event.deltaY - const canScrollY = inputEl.scrollHeight > inputEl.clientHeight - const isHorizontal = Math.abs(deltaX) > Math.abs(deltaY) + const canScrollY = inputEl.scrollHeight > inputEl.clientHeight + const isHorizontal = Math.abs(deltaX) > Math.abs(deltaY) - // Prevent pinch zoom from zooming the page - if (event.ctrlKey) { + // Prevent pinch zoom from zooming the page + if (event.ctrlKey) { + event.preventDefault() + event.stopPropagation() + app.canvas.processMouseWheel(event) + return + } + + // Detect if this is likely a trackpad gesture vs mouse wheel + // Trackpads usually have deltaX or smaller deltaY values (< TRACKPAD_DETECTION_THRESHOLD) + // Mouse wheels typically have larger discrete deltaY values (>= TRACKPAD_DETECTION_THRESHOLD) + const isLikelyTrackpad = + Math.abs(deltaX) > 0 || Math.abs(deltaY) < TRACKPAD_DETECTION_THRESHOLD + + // Trackpad gestures: when enabled, trackpad panning goes to canvas + if (gesturesEnabled && isLikelyTrackpad) { + event.preventDefault() + event.stopPropagation() + app.canvas.processMouseWheel(event) + return + } + + // When gestures disabled: horizontal always goes to canvas (no horizontal scroll in textarea) + if (isHorizontal) { + event.preventDefault() + event.stopPropagation() + app.canvas.processMouseWheel(event) + return + } + + // Vertical scrolling when gestures disabled: let textarea scroll if scrollable + if (canScrollY) { + event.stopPropagation() + return + } + + // If textarea can't scroll vertically, pass to canvas event.preventDefault() - event.stopPropagation() app.canvas.processMouseWheel(event) - return - } + }, + { signal } + ) - // Detect if this is likely a trackpad gesture vs mouse wheel - // Trackpads usually have deltaX or smaller deltaY values (< TRACKPAD_DETECTION_THRESHOLD) - // Mouse wheels typically have larger discrete deltaY values (>= TRACKPAD_DETECTION_THRESHOLD) - const isLikelyTrackpad = - Math.abs(deltaX) > 0 || Math.abs(deltaY) < TRACKPAD_DETECTION_THRESHOLD - - // Trackpad gestures: when enabled, trackpad panning goes to canvas - if (gesturesEnabled && isLikelyTrackpad) { - event.preventDefault() - event.stopPropagation() - app.canvas.processMouseWheel(event) - return - } - - // When gestures disabled: horizontal always goes to canvas (no horizontal scroll in textarea) - if (isHorizontal) { - event.preventDefault() - event.stopPropagation() - app.canvas.processMouseWheel(event) - return - } - - // Vertical scrolling when gestures disabled: let textarea scroll if scrollable - if (canScrollY) { - event.stopPropagation() - return - } - - // If textarea can't scroll vertically, pass to canvas - event.preventDefault() - app.canvas.processMouseWheel(event) + widget.onRemove = useChainCallback(widget.onRemove, () => { + controller.abort() }) return widget diff --git a/src/schemas/apiSchema.ts b/src/schemas/apiSchema.ts index e3277ada64..e7e40abdf7 100644 --- a/src/schemas/apiSchema.ts +++ b/src/schemas/apiSchema.ts @@ -16,7 +16,7 @@ export type ResultItemType = z.infer<typeof resultItemType> const zCustomNodesI18n = z.record(z.string(), z.unknown()) export type CustomNodesI18n = z.infer<typeof zCustomNodesI18n> -const zResultItem = z.object({ +export const zResultItem = z.object({ filename: z.string().optional(), subfolder: z.string().optional(), type: resultItemType.optional(), @@ -335,6 +335,7 @@ const zSettings = z.object({ 'Comfy.ModelLibrary.AutoLoadAll': z.boolean(), 'Comfy.ModelLibrary.NameFormat': z.enum(['filename', 'title']), 'Comfy.NodeSearchBoxImpl.NodePreview': z.boolean(), + 'Comfy.NodeSearchBoxImpl.FollowCursor': z.boolean(), 'Comfy.NodeSearchBoxImpl': z.enum([ 'default', 'v1 (legacy)', diff --git a/src/scripts/app.test.ts b/src/scripts/app.test.ts index 8f62552532..138a759376 100644 --- a/src/scripts/app.test.ts +++ b/src/scripts/app.test.ts @@ -1,4 +1,5 @@ -import { createPinia, setActivePinia } from 'pinia' +import { createTestingPinia } from '@pinia/testing' +import { setActivePinia } from 'pinia' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { @@ -6,10 +7,7 @@ import type { LGraphCanvas, LGraphNode } from '@/lib/litegraph/src/litegraph' -import type { - ComfyWorkflowJSON, - ModelFile -} from '@/platform/workflow/validation/schemas/workflowSchema' +import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema' import { ComfyApp } from './app' import { createNode } from '@/utils/litegraphUtil' import { @@ -22,14 +20,13 @@ import { } from '@/composables/usePaste' import { getWorkflowDataFromFile } from '@/scripts/metadata/parser' import { useMissingModelStore } from '@/platform/missingModel/missingModelStore' -import type { LoadedComfyWorkflow } from '@/platform/workflow/management/stores/comfyWorkflow' -import type { MissingModelCandidate } from '@/platform/missingModel/types' const { mockToastStore, mockExtensionService, mockNodeOutputStore, - mockWorkspaceWorkflow + mockWorkspaceWorkflow, + mockRefreshMissingModelPipeline } = vi.hoisted(() => ({ mockToastStore: { addAlert: vi.fn(), @@ -44,8 +41,9 @@ const { refreshNodeOutputs: vi.fn() }, mockWorkspaceWorkflow: { - activeWorkflow: null as unknown - } + activeWorkflow: null + }, + mockRefreshMissingModelPipeline: vi.fn() })) vi.mock('@/utils/litegraphUtil', () => ({ @@ -88,6 +86,11 @@ vi.mock('@/stores/workspaceStore', () => ({ })) })) +vi.mock('@/platform/missingModel/missingModelPipeline', () => ({ + refreshMissingModelPipeline: mockRefreshMissingModelPipeline, + runMissingModelPipeline: vi.fn() +})) + function createMockNode(options: { [K in keyof LGraphNode]?: any } = {}) { return { id: 1, @@ -115,16 +118,6 @@ function createTestFile(name: string, type: string): File { return new File([''], name, { type }) } -type ComfyAppMissingModelPipelineTarget = { - runMissingModelPipeline: ( - graphData: ComfyWorkflowJSON, - options?: { silent?: boolean; missingNodeTypes?: string[] } - ) => Promise<{ - missingModels: ModelFile[] - confirmedCandidates: MissingModelCandidate[] - }> -} - function createWorkflowGraphData(): ComfyWorkflowJSON { return { last_node_id: 0, @@ -143,7 +136,7 @@ describe('ComfyApp', () => { let mockCanvas: LGraphCanvas beforeEach(() => { - setActivePinia(createPinia()) + setActivePinia(createTestingPinia({ stubActions: false })) vi.clearAllMocks() app = new ComfyApp() mockCanvas = createMockCanvas() as LGraphCanvas @@ -187,104 +180,32 @@ describe('ComfyApp', () => { }) describe('refreshMissingModels', () => { - function mockRefreshMissingModelsApp( - graphData: ComfyWorkflowJSON, - candidates: MissingModelCandidate[] = [] - ) { - mockWorkspaceWorkflow.activeWorkflow = null - Reflect.set(app, 'rootGraphInternal', { + it('delegates to the app-independent missing model refresh pipeline', async () => { + const graph = { nodes: [], - serialize: vi.fn(() => graphData) - }) + serialize: vi.fn(() => createWorkflowGraphData()) + } + const result = { + missingModels: [], + confirmedCandidates: [] + } + Reflect.set(app, 'rootGraphInternal', graph) vi.spyOn(app, 'reloadNodeDefs').mockResolvedValue() - const appWithPrivate = - app as unknown as ComfyAppMissingModelPipelineTarget - const pipelineSpy = vi - .spyOn(appWithPrivate, 'runMissingModelPipeline') - .mockResolvedValue({ - missingModels: [], - confirmedCandidates: [] - }) - useMissingModelStore().missingModelCandidates = candidates - return pipelineSpy - } + mockRefreshMissingModelPipeline.mockResolvedValue(result) - it('reuses active workflow model metadata when refreshing the current graph', async () => { - const graphData = createWorkflowGraphData() - const activeModels = [ - { - name: 'embedded.safetensors', - url: 'https://example.com/embedded.safetensors', - directory: 'checkpoints' - } - ] - const pipelineSpy = mockRefreshMissingModelsApp(graphData, [ - { - nodeId: '1', - nodeType: 'CheckpointLoaderSimple', - widgetName: 'ckpt_name', - name: 'candidate.safetensors', - url: 'https://example.com/candidate.safetensors', - directory: 'checkpoints', - isMissing: true, - isAssetSupported: true - } - ]) - mockWorkspaceWorkflow.activeWorkflow = { - activeState: { models: activeModels } - } as LoadedComfyWorkflow + await expect(app.refreshMissingModels({ silent: false })).resolves.toBe( + result + ) - await app.refreshMissingModels({ silent: false }) + expect(mockRefreshMissingModelPipeline).toHaveBeenCalledWith({ + graph, + reloadNodeDefs: expect.any(Function), + missingModelStore: useMissingModelStore(), + silent: false + }) + await mockRefreshMissingModelPipeline.mock.calls[0][0].reloadNodeDefs() expect(app.reloadNodeDefs).toHaveBeenCalled() - expect(pipelineSpy).toHaveBeenCalledWith( - expect.objectContaining({ models: activeModels }), - { silent: false } - ) - }) - - it('falls back to current missing model metadata when workflow state has no models', async () => { - const graphData = createWorkflowGraphData() - const pipelineSpy = mockRefreshMissingModelsApp(graphData, [ - { - nodeId: '1', - nodeType: 'CheckpointLoaderSimple', - widgetName: 'ckpt_name', - name: 'candidate.safetensors', - url: 'https://example.com/candidate.safetensors', - directory: 'checkpoints', - hash: 'abc123', - hashType: 'sha256', - isMissing: true, - isAssetSupported: true - }, - { - nodeId: '2', - nodeType: 'CheckpointLoaderSimple', - widgetName: 'ckpt_name', - name: 'missing-url.safetensors', - directory: 'checkpoints', - isMissing: true, - isAssetSupported: true - } - ]) - - await app.refreshMissingModels() - - expect(pipelineSpy).toHaveBeenCalledWith( - expect.objectContaining({ - models: [ - { - name: 'candidate.safetensors', - url: 'https://example.com/candidate.safetensors', - directory: 'checkpoints', - hash: 'abc123', - hash_type: 'sha256' - } - ] - }), - { silent: true } - ) }) }) diff --git a/src/scripts/app.ts b/src/scripts/app.ts index 54001c6b61..5b6c8b214b 100644 --- a/src/scripts/app.ts +++ b/src/scripts/app.ts @@ -34,13 +34,13 @@ import { useWorkflowValidation } from '@/platform/workflow/validation/composable import type { ComfyApiWorkflow, ComfyWorkflowJSON, - ModelFile, NodeId } from '@/platform/workflow/validation/schemas/workflowSchema' import { - isSubgraphDefinition, + collectSubgraphDefinitions, buildSubgraphExecutionPaths -} from '@/platform/workflow/validation/schemas/workflowSchema' +} from '@/platform/workflow/core/utils/workflowFlattening' +import type { FlattenableWorkflowNode } from '@/platform/workflow/core/utils/workflowFlattening' import type { ExecutionErrorWsMessage, NodeError, @@ -73,7 +73,6 @@ import { useNodeOutputStore } from '@/stores/nodeOutputStore' import { useJobPreviewStore } from '@/stores/jobPreviewStore' import { KeyComboImpl } from '@/platform/keybindings/keyCombo' import { useKeybindingStore } from '@/platform/keybindings/keybindingStore' -import { useModelStore } from '@/stores/modelStore' import { SYSTEM_NODE_DEFS, useNodeDefStore } from '@/stores/nodeDefStore' import { useNodeReplacementStore } from '@/platform/nodeReplacement/nodeReplacementStore' @@ -87,12 +86,11 @@ import type { NodeExecutionId } from '@/types/nodeIdentification' import { graphToPrompt } from '@/utils/executionUtil' import { getCnrIdFromProperties } from '@/platform/nodeReplacement/cnrIdUtil' import { rescanAndSurfaceMissingNodes } from '@/platform/nodeReplacement/missingNodeScan' -import type { MissingModelCandidate } from '@/platform/missingModel/types' import { - scanAllModelCandidates, - enrichWithEmbeddedMetadata, - verifyAssetSupportedCandidates -} from '@/platform/missingModel/missingModelScan' + refreshMissingModelPipeline, + runMissingModelPipeline +} from '@/platform/missingModel/missingModelPipeline' +import type { MissingModelPipelineResult } from '@/platform/missingModel/missingModelPipeline' import { useMissingModelStore } from '@/platform/missingModel/missingModelStore' import { useMissingMediaStore } from '@/platform/missingMedia/missingMediaStore' import type { MissingMediaCandidate } from '@/platform/missingMedia/types' @@ -100,8 +98,6 @@ import { scanAllMediaCandidates, verifyCloudMediaCandidates } from '@/platform/missingMedia/missingMediaScan' -import { assetService } from '@/platform/assets/services/assetService' -import { useModelToNodeStore } from '@/stores/modelToNodeStore' import { anyItemOverlapsRect } from '@/utils/mathUtil' import { @@ -154,11 +150,6 @@ import { pasteVideoNodes } from '@/composables/usePaste' -interface MissingModelPipelineOptions { - missingNodeTypes?: MissingNodeType[] - silent?: boolean -} - export const ANIM_PREVIEW_WIDGET = '$$comfy_animation_preview' export function sanitizeNodeName(string: string) { @@ -241,7 +232,7 @@ export class ComfyApp { } canvas!: LGraphCanvas - dragOverNode: LGraphNode | null = null + dragOverNode: Pick<LGraphNode, 'onDragDrop' | 'id'> | null = null readonly canvasElRef = shallowRef<HTMLCanvasElement>() get canvasEl() { // TODO: Fix possibly undefined reference @@ -1225,7 +1216,7 @@ export class ComfyApp { // Collect missing node types from all nodes (root + subgraphs) const collectMissingNodes = ( - nodes: ComfyWorkflowJSON['nodes'], + nodes: readonly FlattenableWorkflowNode[], pathPrefix: string = '', displayName: string = '' ) => { @@ -1270,21 +1261,21 @@ export class ComfyApp { } collectMissingNodes(graphData.nodes) - const subgraphDefs = graphData.definitions?.subgraphs ?? [] + const subgraphDefs = collectSubgraphDefinitions( + graphData.definitions?.subgraphs ?? [] + ) const subgraphContainerIdMap = buildSubgraphExecutionPaths( graphData.nodes, subgraphDefs ) for (const subgraph of subgraphDefs) { - if (isSubgraphDefinition(subgraph)) { - const paths = subgraphContainerIdMap.get(subgraph.id) ?? [] - for (const pathPrefix of paths) { - collectMissingNodes( - subgraph.nodes, - pathPrefix, - subgraph.name || subgraph.id - ) - } + const paths = subgraphContainerIdMap.get(subgraph.id) ?? [] + for (const pathPrefix of paths) { + collectMissingNodes( + subgraph.nodes, + pathPrefix, + subgraph.name || subgraph.id + ) } } @@ -1454,7 +1445,10 @@ export class ComfyApp { ) if (!skipAssetScans) { - await this.runMissingModelPipeline(graphData, { + await runMissingModelPipeline({ + graph: this.rootGraph, + graphData, + missingModelStore: useMissingModelStore(), missingNodeTypes: activeMissingNodeTypes, silent: silentAssetErrors }) @@ -1477,201 +1471,14 @@ export class ComfyApp { } } - private async runMissingModelPipeline( - graphData: ComfyWorkflowJSON, - { missingNodeTypes, silent = false }: MissingModelPipelineOptions = {} - ): Promise<{ - missingModels: ModelFile[] - confirmedCandidates: MissingModelCandidate[] - }> { - const missingModelStore = useMissingModelStore() - const controller = missingModelStore.createVerificationAbortController() - - const getDirectory = (nodeType: string) => - useModelToNodeStore().getCategoryForNodeType(nodeType) - - const candidates = scanAllModelCandidates( - this.rootGraph, - isCloud - ? (nodeType, widgetName) => - assetService.shouldUseAssetBrowser(nodeType, widgetName) - : () => false, - getDirectory - ) - - const modelStore = useModelStore() - await modelStore.loadModelFolders() - const enrichedAll = await enrichWithEmbeddedMetadata( - candidates, - graphData, - async (name, directory) => { - const folder = await modelStore.getLoadedModelFolder(directory) - const models = folder?.models - return !!( - models && Object.values(models).some((m) => m.file_name === name) - ) - }, - isCloud - ? (nodeType, widgetName) => - assetService.shouldUseAssetBrowser(nodeType, widgetName) - : undefined - ) - - // Drop candidates whose enclosing subgraph is muted/bypassed. Per-node - // scans only checked each node's own mode; the cascade from an - // inactive container to its interior happens here. - // Asymmetric on purpose: a candidate dropped here is not resurrected if - // the user un-bypasses the container mid-verification. The realtime - // mode-change path (handleNodeModeChange → scanAndAddNodeErrors) is - // responsible for surfacing errors after an un-bypass. - const enrichedCandidates = enrichedAll.filter( - (c) => - c.nodeId == null || - isAncestorPathActive(this.rootGraph, String(c.nodeId)) - ) - - const missingModels: ModelFile[] = enrichedCandidates - .filter((c) => c.isMissing === true && c.url) - .map((c) => ({ - name: c.name, - url: c.url ?? '', - directory: c.directory ?? '', - hash: c.hash, - hash_type: c.hashType - })) - - const confirmedCandidates = enrichedCandidates.filter( - (c) => c.isMissing === true - ) - - const activeWf = useWorkspaceStore().workflow.activeWorkflow - updatePendingWarnings(activeWf, { - ...(missingNodeTypes ? { missingNodeTypes } : {}), - missingModelCandidates: confirmedCandidates - }) - - if (enrichedCandidates.length) { - if (isCloud) { - void verifyAssetSupportedCandidates( - enrichedCandidates, - controller.signal - ) - .then(() => { - if (controller.signal.aborted) return - // Re-check ancestor: user may have bypassed a container - // while verification was in flight. - const confirmed = enrichedCandidates.filter((c) => - isMissingCandidateActive(this.rootGraph, c) - ) - useExecutionErrorStore().surfaceMissingModels(confirmed, { silent }) - this.cacheModelCandidates(activeWf, confirmed) - }) - .catch((err) => { - console.warn( - '[Missing Model Pipeline] Asset verification failed:', - err - ) - useToastStore().add({ - severity: 'warn', - summary: st( - 'toastMessages.missingModelVerificationFailed', - 'Failed to verify missing models. Some models may not be shown in the Errors tab.' - ), - life: 5000 - }) - }) - } else { - const confirmed = enrichedCandidates.filter((c) => c.isMissing === true) - if (!confirmed.length) { - useExecutionErrorStore().surfaceMissingModels([], { silent }) - this.cacheModelCandidates(activeWf, []) - } else { - void api - .getFolderPaths() - .then((paths) => { - if (controller.signal.aborted) return - missingModelStore.setFolderPaths(paths) - }) - .catch((err) => { - console.warn( - '[Missing Model Pipeline] Failed to fetch folder paths:', - err - ) - }) - .finally(() => { - if (controller.signal.aborted) return - useExecutionErrorStore().surfaceMissingModels(confirmed, { - silent - }) - this.cacheModelCandidates(activeWf, confirmed) - }) - - void Promise.allSettled( - confirmed - .filter((c) => c.url) - .map(async (c) => { - const { fetchModelMetadata } = - await import('@/platform/missingModel/missingModelDownload') - const metadata = await fetchModelMetadata(c.url!) - if (!controller.signal.aborted && metadata.fileSize !== null) { - missingModelStore.setFileSize(c.url!, metadata.fileSize) - } - }) - ) - } - } - } else { - useExecutionErrorStore().surfaceMissingModels([], { silent }) - this.cacheModelCandidates(activeWf, []) - } - - return { missingModels, confirmedCandidates } - } - - async refreshMissingModels(options: { silent?: boolean } = {}): Promise<{ - missingModels: ModelFile[] - confirmedCandidates: MissingModelCandidate[] - }> { - await this.reloadNodeDefs() - const graphData = this.rootGraph.serialize() as unknown as ComfyWorkflowJSON - const activeWorkflowState = - useWorkspaceStore().workflow.activeWorkflow?.activeState - const currentModelMetadata = - useMissingModelStore() - .missingModelCandidates?.filter( - ( - candidate - ): candidate is MissingModelCandidate & { - url: string - directory: string - } => !!candidate.url && !!candidate.directory - ) - .map((candidate) => ({ - name: candidate.name, - url: candidate.url, - directory: candidate.directory, - hash: candidate.hash, - hash_type: candidate.hashType - })) ?? [] - const models = activeWorkflowState?.models?.length - ? activeWorkflowState.models - : currentModelMetadata - - return this.runMissingModelPipeline( - models.length ? { ...graphData, models } : graphData, - { - silent: options.silent ?? true - } - ) - } - - private cacheModelCandidates( - wf: ComfyWorkflow | null, - confirmed: MissingModelCandidate[] - ) { - if (!wf) return - updatePendingWarnings(wf, { - missingModelCandidates: confirmed + async refreshMissingModels( + options: { silent?: boolean } = {} + ): Promise<MissingModelPipelineResult> { + return refreshMissingModelPipeline({ + graph: this.rootGraph, + reloadNodeDefs: () => this.reloadNodeDefs(), + missingModelStore: useMissingModelStore(), + silent: options.silent ?? true }) } diff --git a/src/scripts/defaultGraph.ts b/src/scripts/defaultGraph.ts index bfec5f8c7f..67629f92e3 100644 --- a/src/scripts/defaultGraph.ts +++ b/src/scripts/defaultGraph.ts @@ -115,7 +115,15 @@ export const defaultGraph: ComfyWorkflowJSON = { { name: 'CLIP', type: 'CLIP', links: [3, 5], slot_index: 1 }, { name: 'VAE', type: 'VAE', links: [8], slot_index: 2 } ], - properties: {}, + properties: { + models: [ + { + name: 'v1-5-pruned-emaonly-fp16.safetensors', + url: 'https://huggingface.co/Comfy-Org/stable-diffusion-v1-5-archive/resolve/main/v1-5-pruned-emaonly-fp16.safetensors', + directory: 'checkpoints' + } + ] + }, widgets_values: ['v1-5-pruned-emaonly-fp16.safetensors'] } ], diff --git a/src/scripts/metadata/__fixtures__/helpers.ts b/src/scripts/metadata/__fixtures__/helpers.ts new file mode 100644 index 0000000000..486ea8280c --- /dev/null +++ b/src/scripts/metadata/__fixtures__/helpers.ts @@ -0,0 +1,48 @@ +import { vi } from 'vitest' + +export const EXPECTED_WORKFLOW = { + nodes: [{ id: 1, type: 'KSampler', pos: [100, 100], size: [200, 200] }] +} + +export const EXPECTED_PROMPT = { + '1': { class_type: 'KSampler', inputs: {} } +} + +type ReadMethod = 'readAsText' | 'readAsArrayBuffer' + +export function mockFileReaderError(method: ReadMethod): void { + vi.spyOn(FileReader.prototype, method).mockImplementation( + function (this: FileReader) { + queueMicrotask(() => + this.onerror?.(new ProgressEvent('error') as ProgressEvent<FileReader>) + ) + } + ) +} + +export function mockFileReaderAbort(method: ReadMethod): void { + vi.spyOn(FileReader.prototype, method).mockImplementation( + function (this: FileReader) { + queueMicrotask(() => + this.onabort?.(new ProgressEvent('abort') as ProgressEvent<FileReader>) + ) + } + ) +} + +export function mockFileReaderResult( + method: ReadMethod, + result: string | ArrayBuffer | null +): void { + vi.spyOn(FileReader.prototype, method).mockImplementation( + function (this: FileReader) { + Object.defineProperty(this, 'result', { + value: result, + configurable: true + }) + queueMicrotask(() => + this.onload?.(new ProgressEvent('load') as ProgressEvent<FileReader>) + ) + } + ) +} diff --git a/src/scripts/metadata/__fixtures__/with_metadata.avif b/src/scripts/metadata/__fixtures__/with_metadata.avif new file mode 100644 index 0000000000..7db0eb4c8f Binary files /dev/null and b/src/scripts/metadata/__fixtures__/with_metadata.avif differ diff --git a/src/scripts/metadata/__fixtures__/with_metadata.flac b/src/scripts/metadata/__fixtures__/with_metadata.flac new file mode 100644 index 0000000000..3d5b9d5e35 Binary files /dev/null and b/src/scripts/metadata/__fixtures__/with_metadata.flac differ diff --git a/src/scripts/metadata/__fixtures__/with_metadata.mp3 b/src/scripts/metadata/__fixtures__/with_metadata.mp3 new file mode 100644 index 0000000000..792be8dd5f Binary files /dev/null and b/src/scripts/metadata/__fixtures__/with_metadata.mp3 differ diff --git a/src/scripts/metadata/__fixtures__/with_metadata.mp4 b/src/scripts/metadata/__fixtures__/with_metadata.mp4 new file mode 100644 index 0000000000..de42638c14 Binary files /dev/null and b/src/scripts/metadata/__fixtures__/with_metadata.mp4 differ diff --git a/src/scripts/metadata/__fixtures__/with_metadata.opus b/src/scripts/metadata/__fixtures__/with_metadata.opus new file mode 100644 index 0000000000..ad4ac0c4f2 Binary files /dev/null and b/src/scripts/metadata/__fixtures__/with_metadata.opus differ diff --git a/src/scripts/metadata/__fixtures__/with_metadata.webm b/src/scripts/metadata/__fixtures__/with_metadata.webm new file mode 100644 index 0000000000..5f7d87b557 Binary files /dev/null and b/src/scripts/metadata/__fixtures__/with_metadata.webm differ diff --git a/src/scripts/metadata/__fixtures__/with_metadata.webp b/src/scripts/metadata/__fixtures__/with_metadata.webp new file mode 100644 index 0000000000..ab72751551 Binary files /dev/null and b/src/scripts/metadata/__fixtures__/with_metadata.webp differ diff --git a/src/scripts/metadata/__fixtures__/with_metadata_exif_prefix.webp b/src/scripts/metadata/__fixtures__/with_metadata_exif_prefix.webp new file mode 100644 index 0000000000..41873d7c3d Binary files /dev/null and b/src/scripts/metadata/__fixtures__/with_metadata_exif_prefix.webp differ diff --git a/src/scripts/metadata/avif.test.ts b/src/scripts/metadata/avif.test.ts new file mode 100644 index 0000000000..3547536dc9 --- /dev/null +++ b/src/scripts/metadata/avif.test.ts @@ -0,0 +1,400 @@ +import fs from 'fs' +import path from 'path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { + EXPECTED_PROMPT, + EXPECTED_WORKFLOW, + mockFileReaderAbort, + mockFileReaderError +} from './__fixtures__/helpers' +import { getFromAvifFile } from './avif' + +const fixturePath = path.resolve(__dirname, '__fixtures__/with_metadata.avif') + +afterEach(() => vi.restoreAllMocks()) + +describe('AVIF metadata', () => { + it('extracts workflow and prompt from EXIF data in ISOBMFF boxes', async () => { + const bytes = fs.readFileSync(fixturePath) + const file = new File([bytes], 'test.avif', { type: 'image/avif' }) + + const result = await getFromAvifFile(file) + + expect(JSON.parse(result.workflow)).toEqual(EXPECTED_WORKFLOW) + expect(JSON.parse(result.prompt)).toEqual(EXPECTED_PROMPT) + }) + + it('returns empty for non-AVIF data', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + const file = new File([new Uint8Array(16)], 'fake.avif') + + const result = await getFromAvifFile(file) + + expect(result).toEqual({}) + expect(console.error).toHaveBeenCalledWith('Not a valid AVIF file') + }) + + it('returns empty when AVIF has valid ftyp but corrupt internal boxes', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + + const buf = new Uint8Array(40) + const dv = new DataView(buf.buffer) + dv.setUint32(0, 16) + buf.set(new TextEncoder().encode('ftypavif'), 4) + dv.setUint32(16, 24) + buf.set(new TextEncoder().encode('meta'), 20) + + const file = new File([buf], 'corrupt.avif', { type: 'image/avif' }) + const result = await getFromAvifFile(file) + + expect(result).toEqual({}) + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining('Error parsing AVIF metadata'), + expect.anything() + ) + }) + + describe('FileReader failure modes', () => { + const file = new File([new Uint8Array(16)], 'test.avif') + + it('resolves empty when the FileReader fires error', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + mockFileReaderError('readAsArrayBuffer') + expect(await getFromAvifFile(file)).toEqual({}) + }) + + it('resolves empty when the FileReader fires abort', async () => { + mockFileReaderAbort('readAsArrayBuffer') + expect(await getFromAvifFile(file)).toEqual({}) + }) + }) +}) + +const setU32BE = (dv: DataView, off: number, val: number) => + dv.setUint32(off, val, false) +const setU16BE = (dv: DataView, off: number, val: number) => + dv.setUint16(off, val, false) + +const buildExifBlob = ( + asciiEntries: string[], + endian: 'II' | 'MM' = 'II' +): Uint8Array => { + const isLE = endian === 'II' + const headerSize = 8 + const ifdSize = 2 + asciiEntries.length * 12 + 4 + const entryDataSizes = asciiEntries.map((s) => s.length + 1) + const entryDataTotal = entryDataSizes.reduce((a, b) => a + b, 0) + + const buf = new Uint8Array(headerSize + ifdSize + entryDataTotal) + const dv = new DataView(buf.buffer) + + buf[0] = endian === 'II' ? 0x49 : 0x4d + buf[1] = buf[0] + dv.setUint16(2, 0x002a, isLE) + dv.setUint32(4, 8, isLE) + + let p = 8 + dv.setUint16(p, asciiEntries.length, isLE) + p += 2 + + let dataOffset = headerSize + ifdSize + for (let i = 0; i < asciiEntries.length; i++) { + const dataLen = entryDataSizes[i] + const tag = 0x9286 + i + dv.setUint16(p, tag, isLE) + p += 2 + dv.setUint16(p, 2, isLE) + p += 2 + dv.setUint32(p, dataLen, isLE) + p += 4 + dv.setUint32(p, dataOffset, isLE) + p += 4 + const enc = new TextEncoder().encode(asciiEntries[i]) + buf.set(enc, dataOffset) + buf[dataOffset + enc.length] = 0 + dataOffset += dataLen + } + dv.setUint32(p, 0, isLE) + return buf +} + +const buildInfeBox = ( + itemId: number, + itemType: string, + version = 2 +): Uint8Array => { + const bodySize = 4 + 2 + 2 + 4 + 1 + 1 + const totalSize = 8 + bodySize + const buf = new Uint8Array(totalSize) + const dv = new DataView(buf.buffer) + setU32BE(dv, 0, totalSize) + buf.set(new TextEncoder().encode('infe'), 4) + buf[8] = version + if (version >= 2) { + setU16BE(dv, 12, itemId) + setU16BE(dv, 14, 0) + buf.set(new TextEncoder().encode(itemType.padEnd(4).slice(0, 4)), 16) + } + return buf +} + +const buildIinfBox = (infeBoxes: Uint8Array[]): Uint8Array => { + const bodySize = 4 + 2 + infeBoxes.reduce((s, b) => s + b.length, 0) + const totalSize = 8 + bodySize + const buf = new Uint8Array(totalSize) + const dv = new DataView(buf.buffer) + setU32BE(dv, 0, totalSize) + buf.set(new TextEncoder().encode('iinf'), 4) + setU16BE(dv, 12, infeBoxes.length) + let off = 14 + for (const ib of infeBoxes) { + buf.set(ib, off) + off += ib.length + } + return buf +} + +const buildIlocBox = ( + items: { itemId: number; extentOffset: number; extentLength: number }[] +): Uint8Array => { + const perItemSize = 2 + 2 + 0 + 2 + (4 + 4) + const bodySize = 4 + 1 + 1 + 2 + items.length * perItemSize + const totalSize = 8 + bodySize + const buf = new Uint8Array(totalSize) + const dv = new DataView(buf.buffer) + setU32BE(dv, 0, totalSize) + buf.set(new TextEncoder().encode('iloc'), 4) + buf[12] = 0x44 + buf[13] = 0x00 + setU16BE(dv, 14, items.length) + let p = 16 + for (const it of items) { + setU16BE(dv, p, it.itemId) + p += 2 + setU16BE(dv, p, 0) + p += 2 + setU16BE(dv, p, 1) + p += 2 + setU32BE(dv, p, it.extentOffset) + p += 4 + setU32BE(dv, p, it.extentLength) + p += 4 + } + return buf +} + +const buildMetaBox = (boxes: Uint8Array[]): Uint8Array => { + const bodySize = 4 + boxes.reduce((s, b) => s + b.length, 0) + const totalSize = 8 + bodySize + const buf = new Uint8Array(totalSize) + const dv = new DataView(buf.buffer) + setU32BE(dv, 0, totalSize) + buf.set(new TextEncoder().encode('meta'), 4) + let p = 12 + for (const b of boxes) { + buf.set(b, p) + p += b.length + } + return buf +} + +const buildFtypBox = (majorBrand = 'avif'): Uint8Array => { + const buf = new Uint8Array(16) + const dv = new DataView(buf.buffer) + setU32BE(dv, 0, 16) + buf.set(new TextEncoder().encode('ftyp'), 4) + buf.set(new TextEncoder().encode(majorBrand.padEnd(4).slice(0, 4)), 8) + setU32BE(dv, 12, 0) + return buf +} + +interface BuildAvifOpts { + exifEntries?: string[] + endian?: 'II' | 'MM' + itemType?: string + ftypBrand?: string + omitMeta?: boolean + omitIloc?: boolean + infeVersion?: number +} + +const buildAvifFile = (opts: BuildAvifOpts = {}): ArrayBuffer => { + const { + exifEntries = [], + endian = 'II', + itemType = 'Exif', + ftypBrand = 'avif', + omitMeta = false, + omitIloc = false, + infeVersion = 2 + } = opts + + const ftyp = buildFtypBox(ftypBrand) + if (omitMeta) { + return ftyp.slice().buffer as ArrayBuffer + } + + const exifData = buildExifBlob(exifEntries, endian) + const infe = buildInfeBox(1, itemType, infeVersion) + const iinf = buildIinfBox([infe]) + + const realIloc = buildIlocBox([ + { itemId: 1, extentOffset: 0, extentLength: exifData.length } + ]) + const metaSize = 8 + 4 + iinf.length + (omitIloc ? 0 : realIloc.length) + const exifOffset = ftyp.length + metaSize + + const finalIloc = buildIlocBox([ + { itemId: 1, extentOffset: exifOffset, extentLength: exifData.length } + ]) + const finalInner = omitIloc ? [iinf] : [iinf, finalIloc] + const meta = buildMetaBox(finalInner) + + const total = ftyp.length + meta.length + exifData.length + const buf = new Uint8Array(total) + let p = 0 + buf.set(ftyp, p) + p += ftyp.length + buf.set(meta, p) + p += meta.length + buf.set(exifData, p) + return buf.slice().buffer as ArrayBuffer +} + +const fileFromBuffer = (buffer: ArrayBuffer, name = 'test.avif'): File => + new File([buffer], name, { type: 'image/avif' }) + +describe('getFromAvifFile', () => { + beforeEach(() => { + vi.spyOn(console, 'error').mockImplementation(() => undefined) + vi.spyOn(console, 'log').mockImplementation(() => undefined) + }) + + it('extracts workflow JSON from EXIF when AVIF has an Exif item', async () => { + const workflow = '{"nodes":[],"version":1}' + const file = fileFromBuffer( + buildAvifFile({ exifEntries: [`workflow:${workflow}`] }) + ) + + const result = await getFromAvifFile(file) + + expect(result.workflow).toBe(JSON.stringify(JSON.parse(workflow))) + }) + + it('extracts prompt JSON from EXIF', async () => { + const prompt = '{"1":{"class_type":"KSampler"}}' + const file = fileFromBuffer( + buildAvifFile({ exifEntries: [`prompt:${prompt}`] }) + ) + + const result = await getFromAvifFile(file) + + expect(result.prompt).toBe(JSON.stringify(JSON.parse(prompt))) + }) + + it('parses big-endian (MM) EXIF data', async () => { + const workflow = '{"endian":"big"}' + const file = fileFromBuffer( + buildAvifFile({ exifEntries: [`workflow:${workflow}`], endian: 'MM' }) + ) + + const result = await getFromAvifFile(file) + + expect(result.workflow).toBe(JSON.stringify(JSON.parse(workflow))) + }) + + it('returns {} when AVIF major brand is not "avif"', async () => { + const file = fileFromBuffer( + buildAvifFile({ exifEntries: ['workflow:{}'], ftypBrand: 'heic' }) + ) + + const result = await getFromAvifFile(file) + + expect(result).toEqual({}) + }) + + it('returns {} when meta box is missing', async () => { + const file = fileFromBuffer(buildAvifFile({ omitMeta: true })) + + const result = await getFromAvifFile(file) + + expect(result).toEqual({}) + }) + + it('returns {} when iinf has no Exif item', async () => { + const file = fileFromBuffer( + buildAvifFile({ + exifEntries: ['workflow:{}'], + itemType: 'mime' + }) + ) + + const result = await getFromAvifFile(file) + + expect(result).toEqual({}) + }) + + it('returns {} when EXIF entry uses an unrecognized key', async () => { + const file = fileFromBuffer( + buildAvifFile({ exifEntries: ['random:thing'] }) + ) + + const result = await getFromAvifFile(file) + + expect(result).toEqual({}) + }) + + it('returns {} when EXIF entry has malformed JSON', async () => { + const file = fileFromBuffer( + buildAvifFile({ exifEntries: ['workflow:{notjson'] }) + ) + + const result = await getFromAvifFile(file) + + expect(result).toEqual({}) + }) + + it('returns {} (and does not throw) when infe version is unsupported', async () => { + const file = fileFromBuffer( + buildAvifFile({ exifEntries: ['workflow:{}'], infeVersion: 1 }) + ) + + const result = await getFromAvifFile(file) + + expect(result).toEqual({}) + }) + + it('returns {} when iloc box is missing while iinf has an Exif item', async () => { + const file = fileFromBuffer( + buildAvifFile({ exifEntries: ['workflow:{}'], omitIloc: true }) + ) + + const result = await getFromAvifFile(file) + + expect(result).toEqual({}) + }) + + it('returns {} when buffer is too short to contain a valid header', async () => { + const file = fileFromBuffer(new Uint8Array(4).buffer) + + const result = await getFromAvifFile(file) + + expect(result).toEqual({}) + }) + + it('extracts both prompt and workflow when present in separate EXIF entries', async () => { + const prompt = '{"node":1}' + const workflow = '{"nodes":[1]}' + const file = fileFromBuffer( + buildAvifFile({ + exifEntries: [`prompt:${prompt}`, `workflow:${workflow}`] + }) + ) + + const result = await getFromAvifFile(file) + + expect(result.prompt).toBe(JSON.stringify(JSON.parse(prompt))) + expect(result.workflow).toBe(JSON.stringify(JSON.parse(workflow))) + }) +}) diff --git a/src/scripts/metadata/avif.ts b/src/scripts/metadata/avif.ts index c0d747d9e2..34664d0978 100644 --- a/src/scripts/metadata/avif.ts +++ b/src/scripts/metadata/avif.ts @@ -407,6 +407,7 @@ export function getFromAvifFile(file: File): Promise<Record<string, string>> { console.error('FileReader: Error reading AVIF file:', err) resolve({}) } + reader.onabort = () => resolve({}) reader.readAsArrayBuffer(file) }) } diff --git a/src/scripts/metadata/ebml.test.ts b/src/scripts/metadata/ebml.test.ts new file mode 100644 index 0000000000..bb66745c9c --- /dev/null +++ b/src/scripts/metadata/ebml.test.ts @@ -0,0 +1,49 @@ +import fs from 'fs' +import path from 'path' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { + EXPECTED_PROMPT, + EXPECTED_WORKFLOW, + mockFileReaderAbort, + mockFileReaderError +} from './__fixtures__/helpers' +import { getFromWebmFile } from './ebml' + +const fixturePath = path.resolve(__dirname, '__fixtures__/with_metadata.webm') + +describe('WebM/EBML metadata', () => { + it('extracts workflow and prompt from EBML SimpleTag elements', async () => { + const bytes = fs.readFileSync(fixturePath) + const file = new File([bytes], 'test.webm', { type: 'video/webm' }) + + const result = await getFromWebmFile(file) + + expect(result.workflow).toEqual(EXPECTED_WORKFLOW) + expect(result.prompt).toEqual(EXPECTED_PROMPT) + }) + + it('returns empty for non-WebM data', async () => { + const file = new File([new Uint8Array(16)], 'fake.webm') + + const result = await getFromWebmFile(file) + + expect(result).toEqual({}) + }) + + describe('FileReader failure modes', () => { + afterEach(() => vi.restoreAllMocks()) + + const file = new File([new Uint8Array(16)], 'test.webm') + + it('resolves empty when the FileReader fires error', async () => { + mockFileReaderError('readAsArrayBuffer') + expect(await getFromWebmFile(file)).toEqual({}) + }) + + it('resolves empty when the FileReader fires abort', async () => { + mockFileReaderAbort('readAsArrayBuffer') + expect(await getFromWebmFile(file)).toEqual({}) + }) + }) +}) diff --git a/src/scripts/metadata/ebml.ts b/src/scripts/metadata/ebml.ts index 835c2502d6..2e94c81737 100644 --- a/src/scripts/metadata/ebml.ts +++ b/src/scripts/metadata/ebml.ts @@ -353,6 +353,7 @@ export function getFromWebmFile(file: File): Promise<ComfyMetadata> { const reader = new FileReader() reader.onload = (event) => handleFileLoad(event, resolve) reader.onerror = () => resolve({}) + reader.onabort = () => resolve({}) reader.readAsArrayBuffer(file.slice(0, MAX_READ_BYTES)) }) } diff --git a/src/scripts/metadata/flac.test.ts b/src/scripts/metadata/flac.test.ts new file mode 100644 index 0000000000..c1e6bc5366 --- /dev/null +++ b/src/scripts/metadata/flac.test.ts @@ -0,0 +1,56 @@ +import fs from 'fs' +import path from 'path' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { + EXPECTED_PROMPT, + EXPECTED_WORKFLOW, + mockFileReaderAbort, + mockFileReaderError +} from './__fixtures__/helpers' +import { getFromFlacBuffer, getFromFlacFile } from './flac' + +const fixturePath = path.resolve(__dirname, '__fixtures__/with_metadata.flac') + +afterEach(() => vi.restoreAllMocks()) + +describe('FLAC metadata', () => { + it('extracts workflow and prompt from Vorbis comments', () => { + const bytes = fs.readFileSync(fixturePath) + const buffer = bytes.buffer.slice( + bytes.byteOffset, + bytes.byteOffset + bytes.byteLength + ) + + const result = getFromFlacBuffer(buffer) + + expect(result.workflow).toBe(JSON.stringify(EXPECTED_WORKFLOW)) + expect(result.prompt).toBe(JSON.stringify(EXPECTED_PROMPT)) + }) + + it('returns undefined for non-FLAC data', () => { + const buf = new ArrayBuffer(16) + const result = getFromFlacBuffer(buf) + expect(result).toBeUndefined() + }) + + describe('FileReader failure modes', () => { + const file = new File([new Uint8Array(16)], 'test.flac') + + it('resolves empty when the FileReader fires error', async () => { + mockFileReaderError('readAsArrayBuffer') + + const result = await getFromFlacFile(file) + + expect(result).toEqual({}) + }) + + it('resolves empty when the FileReader fires abort', async () => { + mockFileReaderAbort('readAsArrayBuffer') + + const result = await getFromFlacFile(file) + + expect(result).toEqual({}) + }) + }) +}) diff --git a/src/scripts/metadata/flac.ts b/src/scripts/metadata/flac.ts index 5a3efa6ac6..d2bfee8000 100644 --- a/src/scripts/metadata/flac.ts +++ b/src/scripts/metadata/flac.ts @@ -42,6 +42,8 @@ export function getFromFlacFile(file: File): Promise<Record<string, string>> { const arrayBuffer = event.target.result as ArrayBuffer r(getFromFlacBuffer(arrayBuffer)) } + reader.onerror = () => r({}) + reader.onabort = () => r({}) reader.readAsArrayBuffer(file) }) } diff --git a/src/scripts/metadata/gltf.test.ts b/src/scripts/metadata/gltf.test.ts index cead271807..230340baf8 100644 --- a/src/scripts/metadata/gltf.test.ts +++ b/src/scripts/metadata/gltf.test.ts @@ -1,7 +1,11 @@ -import { describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { ASCII, GltfSizeBytes } from '@/types/metadataTypes' +import { + mockFileReaderAbort, + mockFileReaderError +} from './__fixtures__/helpers' import { getGltfBinaryMetadata } from './gltf' describe('GLTF binary metadata parser', () => { @@ -160,4 +164,20 @@ describe('GLTF binary metadata parser', () => { const metadata = await getGltfBinaryMetadata(invalidEmptyFile) expect(metadata).toEqual({}) }) + + describe('FileReader failure modes', () => { + afterEach(() => vi.restoreAllMocks()) + + const file = new File([new Uint8Array(16)], 'test.glb') + + it('resolves empty when the FileReader fires error', async () => { + mockFileReaderError('readAsArrayBuffer') + expect(await getGltfBinaryMetadata(file)).toEqual({}) + }) + + it('resolves empty when the FileReader fires abort', async () => { + mockFileReaderAbort('readAsArrayBuffer') + expect(await getGltfBinaryMetadata(file)).toEqual({}) + }) + }) }) diff --git a/src/scripts/metadata/gltf.ts b/src/scripts/metadata/gltf.ts index 83a5c3c3e7..1da543cfcd 100644 --- a/src/scripts/metadata/gltf.ts +++ b/src/scripts/metadata/gltf.ts @@ -165,6 +165,7 @@ export function getGltfBinaryMetadata(file: File): Promise<ComfyMetadata> { } } reader.onerror = () => resolve({}) + reader.onabort = () => resolve({}) reader.readAsArrayBuffer(file.slice(0, bytesToRead)) }) } diff --git a/src/scripts/metadata/isobmff.test.ts b/src/scripts/metadata/isobmff.test.ts new file mode 100644 index 0000000000..7d342cc488 --- /dev/null +++ b/src/scripts/metadata/isobmff.test.ts @@ -0,0 +1,52 @@ +import fs from 'fs' +import path from 'path' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { + EXPECTED_PROMPT, + EXPECTED_WORKFLOW, + mockFileReaderAbort, + mockFileReaderError +} from './__fixtures__/helpers' +import { getFromIsobmffFile } from './isobmff' + +const fixturePath = path.resolve(__dirname, '__fixtures__/with_metadata.mp4') + +describe('ISOBMFF (MP4) metadata', () => { + it('extracts workflow and prompt from QuickTime keys/ilst boxes', async () => { + const bytes = fs.readFileSync(fixturePath) + const file = new File([bytes], 'test.mp4', { type: 'video/mp4' }) + + const result = await getFromIsobmffFile(file) + + expect(result.workflow).toEqual(EXPECTED_WORKFLOW) + expect(result.prompt).toEqual(EXPECTED_PROMPT) + }) + + it('returns empty for non-ISOBMFF data', async () => { + const file = new File([new Uint8Array(16)], 'fake.mp4', { + type: 'video/mp4' + }) + + const result = await getFromIsobmffFile(file) + + expect(result).toEqual({}) + }) + + describe('FileReader failure modes', () => { + afterEach(() => vi.restoreAllMocks()) + + const file = new File([new Uint8Array(16)], 'test.mp4') + + it('resolves empty when the FileReader fires error', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + mockFileReaderError('readAsArrayBuffer') + expect(await getFromIsobmffFile(file)).toEqual({}) + }) + + it('resolves empty when the FileReader fires abort', async () => { + mockFileReaderAbort('readAsArrayBuffer') + expect(await getFromIsobmffFile(file)).toEqual({}) + }) + }) +}) diff --git a/src/scripts/metadata/isobmff.ts b/src/scripts/metadata/isobmff.ts index e50a101699..95f263e4eb 100644 --- a/src/scripts/metadata/isobmff.ts +++ b/src/scripts/metadata/isobmff.ts @@ -274,6 +274,7 @@ export function getFromIsobmffFile(file: File): Promise<ComfyMetadata> { console.error('FileReader: Error reading ISOBMFF file:', err) resolve({}) } + reader.onabort = () => resolve({}) reader.readAsArrayBuffer(file.slice(0, MAX_READ_BYTES)) }) } diff --git a/src/scripts/metadata/json.test.ts b/src/scripts/metadata/json.test.ts new file mode 100644 index 0000000000..b48bf15a44 --- /dev/null +++ b/src/scripts/metadata/json.test.ts @@ -0,0 +1,91 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { + mockFileReaderAbort, + mockFileReaderError, + mockFileReaderResult +} from './__fixtures__/helpers' +import { getDataFromJSON } from './json' + +function jsonFile(content: object): File { + return new File([JSON.stringify(content)], 'test.json', { + type: 'application/json' + }) +} + +describe('getDataFromJSON', () => { + it('detects API-format workflows by class_type on every value', async () => { + const apiData = { + '1': { class_type: 'KSampler', inputs: {} }, + '2': { class_type: 'EmptyLatentImage', inputs: {} } + } + + const result = await getDataFromJSON(jsonFile(apiData)) + + expect(result).toEqual({ prompt: apiData }) + }) + + it('treats objects without universal class_type as a workflow', async () => { + const workflow = { nodes: [], links: [], version: 1 } + + const result = await getDataFromJSON(jsonFile(workflow)) + + expect(result).toEqual({ workflow }) + }) + + it('extracts templates when the root object has a templates key', async () => { + const templates = [{ name: 'basic' }] + + const result = await getDataFromJSON(jsonFile({ templates })) + + expect(result).toEqual({ templates }) + }) + + it('returns undefined for non-JSON content', async () => { + const file = new File(['not valid json'], 'bad.json', { + type: 'application/json' + }) + + const result = await getDataFromJSON(file) + + expect(result).toBeUndefined() + }) + + describe('FileReader failure modes', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + it('resolves undefined when the FileReader fires error', async () => { + mockFileReaderError('readAsText') + + const result = await getDataFromJSON(jsonFile({ nodes: [] })) + + expect(result).toBeUndefined() + }) + + it('resolves undefined when the FileReader fires abort', async () => { + mockFileReaderAbort('readAsText') + + const result = await getDataFromJSON(jsonFile({ nodes: [] })) + + expect(result).toBeUndefined() + }) + + it('resolves undefined when reader.result is not a string', async () => { + mockFileReaderResult('readAsText', new ArrayBuffer(8)) + + const result = await getDataFromJSON(jsonFile({ nodes: [] })) + + expect(result).toBeUndefined() + }) + + it('resolves undefined when reader.result is null', async () => { + mockFileReaderResult('readAsText', null) + + const result = await getDataFromJSON(jsonFile({ nodes: [] })) + + expect(result).toBeUndefined() + }) + }) +}) diff --git a/src/scripts/metadata/json.ts b/src/scripts/metadata/json.ts index 873b42117b..ee7fd1ad8d 100644 --- a/src/scripts/metadata/json.ts +++ b/src/scripts/metadata/json.ts @@ -6,21 +6,28 @@ export function getDataFromJSON( return new Promise<Record<string, object> | undefined>((resolve) => { const reader = new FileReader() reader.onload = async () => { - const readerResult = reader.result as string - const jsonContent = JSON.parse(readerResult) - if (jsonContent?.templates) { - resolve({ templates: jsonContent.templates }) - return + try { + if (typeof reader.result !== 'string') { + resolve(undefined) + return + } + const jsonContent = JSON.parse(reader.result) + if (jsonContent?.templates) { + resolve({ templates: jsonContent.templates }) + return + } + if (isApiJson(jsonContent)) { + resolve({ prompt: jsonContent }) + return + } + resolve({ workflow: jsonContent }) + } catch { + resolve(undefined) } - if (isApiJson(jsonContent)) { - resolve({ prompt: jsonContent }) - return - } - resolve({ workflow: jsonContent }) - return } + reader.onerror = () => resolve(undefined) + reader.onabort = () => resolve(undefined) reader.readAsText(file) - return }) } diff --git a/src/scripts/metadata/mp3.test.ts b/src/scripts/metadata/mp3.test.ts new file mode 100644 index 0000000000..c2fba7dbd5 --- /dev/null +++ b/src/scripts/metadata/mp3.test.ts @@ -0,0 +1,106 @@ +import fs from 'fs' +import path from 'path' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { + EXPECTED_PROMPT, + EXPECTED_WORKFLOW, + mockFileReaderAbort, + mockFileReaderError +} from './__fixtures__/helpers' +import { getMp3Metadata } from './mp3' + +const fixturePath = path.resolve(__dirname, '__fixtures__/with_metadata.mp3') + +afterEach(() => vi.restoreAllMocks()) + +describe('MP3 metadata', () => { + it('extracts workflow and prompt from ID3 tags', async () => { + const bytes = fs.readFileSync(fixturePath) + const file = new File([bytes], 'test.mp3', { type: 'audio/mpeg' }) + + const result = await getMp3Metadata(file) + + expect(result.workflow).toEqual(EXPECTED_WORKFLOW) + expect(result.prompt).toEqual(EXPECTED_PROMPT) + }) + + it('returns undefined fields when file has no embedded metadata', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + const file = new File([new Uint8Array(16)], 'empty.mp3', { + type: 'audio/mpeg' + }) + + const result = await getMp3Metadata(file) + + expect(result.workflow).toBeUndefined() + expect(result.prompt).toBeUndefined() + expect(console.error).toHaveBeenCalledWith('Invalid file signature.') + }) + + it('does not log an invalid signature for a valid MP3 sync header', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const buf = new Uint8Array(16) + buf[0] = 0xff + buf[1] = 0xfb + const file = new File([buf], 'valid.mp3', { type: 'audio/mpeg' }) + + await getMp3Metadata(file) + + expect(errorSpy).not.toHaveBeenCalled() + }) + + it('does not log an invalid signature for a valid ID3v2 header', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const buf = new Uint8Array(16) + buf[0] = 0x49 + buf[1] = 0x44 + buf[2] = 0x33 + const file = new File([buf], 'valid-id3.mp3', { type: 'audio/mpeg' }) + + await getMp3Metadata(file) + + expect(errorSpy).not.toHaveBeenCalled() + }) + + it('extracts metadata that spans the 4096-byte page boundary', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + const metadata = + `prompt\0${JSON.stringify(EXPECTED_PROMPT)}\0` + + `workflow\0${JSON.stringify(EXPECTED_WORKFLOW)}\0` + const metadataStart = 4090 + const size = metadataStart + metadata.length + 4 + const buf = new Uint8Array(size) + for (let i = 0; i < metadata.length; i++) { + buf[metadataStart + i] = metadata.charCodeAt(i) + } + buf[size - 2] = 0xff + buf[size - 1] = 0xfb + const file = new File([buf], 'large.mp3', { type: 'audio/mpeg' }) + + const result = await getMp3Metadata(file) + + expect(result.workflow).toEqual(EXPECTED_WORKFLOW) + expect(result.prompt).toEqual(EXPECTED_PROMPT) + }) + + describe('FileReader failure modes', () => { + const file = new File([new Uint8Array(16)], 'test.mp3') + + it('resolves undefined fields when the FileReader fires error', async () => { + mockFileReaderError('readAsArrayBuffer') + + const result = await getMp3Metadata(file) + + expect(result).toEqual({ prompt: undefined, workflow: undefined }) + }) + + it('resolves undefined fields when the FileReader fires abort', async () => { + mockFileReaderAbort('readAsArrayBuffer') + + const result = await getMp3Metadata(file) + + expect(result).toEqual({ prompt: undefined, workflow: undefined }) + }) + }) +}) diff --git a/src/scripts/metadata/mp3.ts b/src/scripts/metadata/mp3.ts index f3e5e23bbd..8ea3cc1f16 100644 --- a/src/scripts/metadata/mp3.ts +++ b/src/scripts/metadata/mp3.ts @@ -1,21 +1,28 @@ export async function getMp3Metadata(file: File) { const reader = new FileReader() - const read_process = new Promise( - (r) => (reader.onload = (event) => r(event?.target?.result)) - ) + const read_process = new Promise<ArrayBuffer | null>((r) => { + reader.onload = (event) => r((event?.target?.result as ArrayBuffer) ?? null) + reader.onerror = () => r(null) + reader.onabort = () => r(null) + }) reader.readAsArrayBuffer(file) - const arrayBuffer = (await read_process) as ArrayBuffer + const arrayBuffer = await read_process + if (!arrayBuffer) return { prompt: undefined, workflow: undefined } //https://stackoverflow.com/questions/7302439/how-can-i-determine-that-a-particular-file-is-in-fact-an-mp3-file#7302482 const sig_bytes = new Uint8Array(arrayBuffer, 0, 3) if ( - (sig_bytes[0] != 0xff && sig_bytes[1] != 0xfb) || - (sig_bytes[0] != 0x49 && sig_bytes[1] != 0x44 && sig_bytes[2] != 0x33) + (sig_bytes[0] != 0xff || sig_bytes[1] != 0xfb) && + (sig_bytes[0] != 0x49 || sig_bytes[1] != 0x44 || sig_bytes[2] != 0x33) ) console.error('Invalid file signature.') let header = '' while (header.length < arrayBuffer.byteLength) { const page = String.fromCharCode( - ...new Uint8Array(arrayBuffer, header.length, header.length + 4096) + ...new Uint8Array( + arrayBuffer, + header.length, + Math.min(4096, arrayBuffer.byteLength - header.length) + ) ) header += page if (page.match('\u00ff\u00fb')) break diff --git a/src/scripts/metadata/ogg.test.ts b/src/scripts/metadata/ogg.test.ts new file mode 100644 index 0000000000..13c3a4bea0 --- /dev/null +++ b/src/scripts/metadata/ogg.test.ts @@ -0,0 +1,74 @@ +import fs from 'fs' +import path from 'path' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { + EXPECTED_PROMPT, + EXPECTED_WORKFLOW, + mockFileReaderAbort, + mockFileReaderError +} from './__fixtures__/helpers' +import { getOggMetadata } from './ogg' + +const fixturePath = path.resolve(__dirname, '__fixtures__/with_metadata.opus') + +afterEach(() => vi.restoreAllMocks()) + +describe('OGG/Opus metadata', () => { + it('extracts workflow and prompt from an Opus file', async () => { + const bytes = fs.readFileSync(fixturePath) + const file = new File([bytes], 'test.opus', { type: 'audio/ogg' }) + + const result = await getOggMetadata(file) + + expect(result.workflow).toEqual(EXPECTED_WORKFLOW) + expect(result.prompt).toEqual(EXPECTED_PROMPT) + }) + + it('returns undefined fields for non-OGG data', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + const file = new File([new Uint8Array(16)], 'fake.ogg', { + type: 'audio/ogg' + }) + + const result = await getOggMetadata(file) + + expect(result.workflow).toBeUndefined() + expect(result.prompt).toBeUndefined() + expect(console.error).toHaveBeenCalledWith('Invalid file signature.') + }) + + it('handles files larger than 4096 bytes without RangeError', async () => { + const size = 5000 + const buf = new Uint8Array(size) + const oggs = new TextEncoder().encode('OggS\0') + buf.set(oggs, 0) + buf.set(oggs, 4500) + const file = new File([buf], 'large.ogg', { type: 'audio/ogg' }) + + const result = await getOggMetadata(file) + + expect(result.workflow).toBeUndefined() + expect(result.prompt).toBeUndefined() + }) + + describe('FileReader failure modes', () => { + const file = new File([new Uint8Array(16)], 'test.ogg') + + it('resolves undefined fields when the FileReader fires error', async () => { + mockFileReaderError('readAsArrayBuffer') + + const result = await getOggMetadata(file) + + expect(result).toEqual({ prompt: undefined, workflow: undefined }) + }) + + it('resolves undefined fields when the FileReader fires abort', async () => { + mockFileReaderAbort('readAsArrayBuffer') + + const result = await getOggMetadata(file) + + expect(result).toEqual({ prompt: undefined, workflow: undefined }) + }) + }) +}) diff --git a/src/scripts/metadata/ogg.ts b/src/scripts/metadata/ogg.ts index 5dc49c02b3..8adec350a7 100644 --- a/src/scripts/metadata/ogg.ts +++ b/src/scripts/metadata/ogg.ts @@ -1,17 +1,24 @@ export async function getOggMetadata(file: File) { const reader = new FileReader() - const read_process = new Promise( - (r) => (reader.onload = (event) => r(event?.target?.result)) - ) + const read_process = new Promise<ArrayBuffer | null>((r) => { + reader.onload = (event) => r((event?.target?.result as ArrayBuffer) ?? null) + reader.onerror = () => r(null) + reader.onabort = () => r(null) + }) reader.readAsArrayBuffer(file) - const arrayBuffer = (await read_process) as ArrayBuffer + const arrayBuffer = await read_process + if (!arrayBuffer) return { prompt: undefined, workflow: undefined } const signature = String.fromCharCode(...new Uint8Array(arrayBuffer, 0, 4)) if (signature !== 'OggS') console.error('Invalid file signature.') let oggs = 0 let header = '' while (header.length < arrayBuffer.byteLength) { const page = String.fromCharCode( - ...new Uint8Array(arrayBuffer, header.length, header.length + 4096) + ...new Uint8Array( + arrayBuffer, + header.length, + Math.min(4096, arrayBuffer.byteLength - header.length) + ) ) if (page.match('OggS\u0000')) oggs++ header += page diff --git a/src/scripts/metadata/png.test.ts b/src/scripts/metadata/png.test.ts index dcc47bf049..fac4eaeefd 100644 --- a/src/scripts/metadata/png.test.ts +++ b/src/scripts/metadata/png.test.ts @@ -1,11 +1,19 @@ -import { describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' -import { getFromPngBuffer } from './png' +import { + mockFileReaderAbort, + mockFileReaderError +} from './__fixtures__/helpers' +import { getFromPngBuffer, getFromPngFile } from './png' + +afterEach(() => vi.restoreAllMocks()) + +const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a] function createPngWithChunk( chunkType: string, keyword: string, - content: string, + content: string | Uint8Array, options: { compressionFlag?: number compressionMethod?: number @@ -20,12 +28,11 @@ function createPngWithChunk( translatedKeyword = '' } = options - const signature = new Uint8Array([ - 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a - ]) + const signature = new Uint8Array(PNG_SIGNATURE) const typeBytes = new TextEncoder().encode(chunkType) const keywordBytes = new TextEncoder().encode(keyword) - const contentBytes = new TextEncoder().encode(content) + const contentBytes = + content instanceof Uint8Array ? content : new TextEncoder().encode(content) let chunkData: Uint8Array if (chunkType === 'iTXt') { @@ -66,12 +73,11 @@ function createPngWithChunk( new DataView(lengthBytes.buffer).setUint32(0, chunkData.length, false) const crc = new Uint8Array(4) - const iendType = new TextEncoder().encode('IEND') const iendLength = new Uint8Array(4) const iendCrc = new Uint8Array(4) - const total = signature.length + 4 + 4 + chunkData.length + 4 + 4 + 4 + 0 + 4 + const total = signature.length + (4 + 4 + chunkData.length + 4) + (4 + 4 + 4) const result = new Uint8Array(total) let offset = 0 @@ -138,6 +144,21 @@ describe('getFromPngBuffer', () => { expect(result['workflow']).toBe(workflow) }) + it('logs warning and skips iTXt chunk with unsupported compression method', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}) + const buffer = createPngWithChunk('iTXt', 'workflow', 'data', { + compressionFlag: 1, + compressionMethod: 99 + }) + + const result = await getFromPngBuffer(buffer) + + expect(result['workflow']).toBeUndefined() + expect(console.warn).toHaveBeenCalledWith( + expect.stringContaining('Unsupported compression method 99') + ) + }) + it('parses compressed iTXt chunk', async () => { const workflow = '{"nodes":[{"id":1,"type":"KSampler"}]}' const contentBytes = new TextEncoder().encode(workflow) @@ -163,83 +184,49 @@ describe('getFromPngBuffer', () => { pos += chunk.length } - const buffer = createPngWithCompressedITXt( - 'workflow', - compressedBytes, - '', - '' - ) + const buffer = createPngWithChunk('iTXt', 'workflow', compressedBytes, { + compressionFlag: 1, + compressionMethod: 0 + }) const result = await getFromPngBuffer(buffer) expect(result['workflow']).toBe(workflow) }) }) -function createPngWithCompressedITXt( - keyword: string, - compressedContent: Uint8Array, - languageTag: string, - translatedKeyword: string -): ArrayBuffer { - const signature = new Uint8Array([ - 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a - ]) - const typeBytes = new TextEncoder().encode('iTXt') - const keywordBytes = new TextEncoder().encode(keyword) - const langBytes = new TextEncoder().encode(languageTag) - const transBytes = new TextEncoder().encode(translatedKeyword) +describe('getFromPngFile', () => { + it('reads metadata from a File object', async () => { + const workflow = '{"nodes":[]}' + const buffer = createPngWithChunk('tEXt', 'workflow', workflow) + const file = new File([buffer], 'test.png', { type: 'image/png' }) - const totalLength = - keywordBytes.length + - 1 + - 2 + - langBytes.length + - 1 + - transBytes.length + - 1 + - compressedContent.length + const result = await getFromPngFile(file) - const chunkData = new Uint8Array(totalLength) - let pos = 0 - chunkData.set(keywordBytes, pos) - pos += keywordBytes.length - chunkData[pos++] = 0 - chunkData[pos++] = 1 - chunkData[pos++] = 0 - chunkData.set(langBytes, pos) - pos += langBytes.length - chunkData[pos++] = 0 - chunkData.set(transBytes, pos) - pos += transBytes.length - chunkData[pos++] = 0 - chunkData.set(compressedContent, pos) + expect(result['workflow']).toBe(workflow) + }) - const lengthBytes = new Uint8Array(4) - new DataView(lengthBytes.buffer).setUint32(0, chunkData.length, false) + it('returns empty for an invalid PNG File', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + const file = new File([new ArrayBuffer(8)], 'bad.png', { + type: 'image/png' + }) - const crc = new Uint8Array(4) - const iendType = new TextEncoder().encode('IEND') - const iendLength = new Uint8Array(4) - const iendCrc = new Uint8Array(4) + const result = await getFromPngFile(file) - const total = signature.length + 4 + 4 + chunkData.length + 4 + 4 + 4 + 0 + 4 - const result = new Uint8Array(total) + expect(result).toEqual({}) + expect(console.error).toHaveBeenCalledWith('Not a valid PNG file') + }) - let offset = 0 - result.set(signature, offset) - offset += signature.length - result.set(lengthBytes, offset) - offset += 4 - result.set(typeBytes, offset) - offset += 4 - result.set(chunkData, offset) - offset += chunkData.length - result.set(crc, offset) - offset += 4 - result.set(iendLength, offset) - offset += 4 - result.set(iendType, offset) - offset += 4 - result.set(iendCrc, offset) + describe('FileReader failure modes', () => { + const file = new File([new Uint8Array(16)], 'test.png') - return result.buffer -} + it('rejects when the FileReader fires error', async () => { + mockFileReaderError('readAsArrayBuffer') + await expect(getFromPngFile(file)).rejects.toBeDefined() + }) + + it('rejects when the FileReader fires abort', async () => { + mockFileReaderAbort('readAsArrayBuffer') + await expect(getFromPngFile(file)).rejects.toThrow('FileReader aborted') + }) + }) +}) diff --git a/src/scripts/metadata/png.ts b/src/scripts/metadata/png.ts index 2d47effcb2..30add0b2e8 100644 --- a/src/scripts/metadata/png.ts +++ b/src/scripts/metadata/png.ts @@ -126,6 +126,7 @@ export async function getFromPngFile( resolve(result) } reader.onerror = () => reject(reader.error) + reader.onabort = () => reject(new Error('FileReader aborted')) reader.readAsArrayBuffer(file) }) } diff --git a/src/scripts/metadata/svg.test.ts b/src/scripts/metadata/svg.test.ts new file mode 100644 index 0000000000..dcc691c8ec --- /dev/null +++ b/src/scripts/metadata/svg.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest' + +import { getSvgMetadata } from './svg' + +function svgFile(content: string): File { + return new File([content], 'test.svg', { type: 'image/svg+xml' }) +} + +describe('getSvgMetadata', () => { + it('extracts workflow and prompt from CDATA in <metadata>', async () => { + const svg = `<svg xmlns="http://www.w3.org/2000/svg"> + <metadata><![CDATA[${JSON.stringify({ + workflow: { nodes: [] }, + prompt: { '1': {} } + })}]]></metadata> + <rect width="1" height="1"/> + </svg>` + + const result = await getSvgMetadata(svgFile(svg)) + + expect(result).toEqual({ + workflow: { nodes: [] }, + prompt: { '1': {} } + }) + }) + + it('returns empty when SVG has no metadata element', async () => { + const svg = '<svg xmlns="http://www.w3.org/2000/svg"><rect/></svg>' + + const result = await getSvgMetadata(svgFile(svg)) + + expect(result).toEqual({}) + }) + + it('returns empty when CDATA contains invalid JSON', async () => { + const svg = `<svg><metadata><![CDATA[not valid json]]></metadata></svg>` + + const result = await getSvgMetadata(svgFile(svg)) + + expect(result).toEqual({}) + }) +}) diff --git a/src/scripts/pnginfo.test.ts b/src/scripts/pnginfo.test.ts index 2f19d1ba0a..c10aaa4b1a 100644 --- a/src/scripts/pnginfo.test.ts +++ b/src/scripts/pnginfo.test.ts @@ -1,67 +1,236 @@ -import { describe, expect, it } from 'vitest' +import fs from 'fs' +import path from 'path' +import { afterEach, describe, expect, it, vi } from 'vitest' -import { getWebpMetadata } from './pnginfo' +import { getFromAvifFile } from './metadata/avif' +import { getFromFlacFile } from './metadata/flac' +import { getFromPngFile } from './metadata/png' +import { + getAvifMetadata, + getFlacMetadata, + getLatentMetadata, + getPngMetadata, + getWebpMetadata +} from './pnginfo' -function buildExifPayload(workflowJson: string): Uint8Array { - const fullStr = `workflow:${workflowJson}\0` - const strBytes = new TextEncoder().encode(fullStr) +vi.mock('./metadata/png', () => ({ + getFromPngFile: vi.fn() +})) +vi.mock('./metadata/flac', () => ({ + getFromFlacFile: vi.fn() +})) +vi.mock('./metadata/avif', () => ({ + getFromAvifFile: vi.fn() +})) - const headerSize = 22 - const buf = new Uint8Array(headerSize + strBytes.length) +afterEach(() => vi.restoreAllMocks()) + +const fixturesDir = path.resolve(__dirname, 'metadata/__fixtures__') + +type AsciiIfdEntry = { tag: number; value: string } + +function encodeAsciiIfd(entries: AsciiIfdEntry[]): Uint8Array { + const tableSize = 10 + 12 * entries.length + const strings = entries.map((e) => new TextEncoder().encode(`${e.value}\0`)) + const totalStringBytes = strings.reduce((sum, s) => sum + s.length, 0) + + const buf = new Uint8Array(tableSize + totalStringBytes) const dv = new DataView(buf.buffer) buf.set([0x49, 0x49], 0) dv.setUint16(2, 0x002a, true) dv.setUint32(4, 8, true) - dv.setUint16(8, 1, true) - dv.setUint16(10, 0, true) - dv.setUint16(12, 2, true) - dv.setUint32(14, strBytes.length, true) - dv.setUint32(18, 22, true) - buf.set(strBytes, 22) + dv.setUint16(8, entries.length, true) + + let stringOffset = tableSize + for (let i = 0; i < entries.length; i++) { + const entryOffset = 10 + i * 12 + dv.setUint16(entryOffset, entries[i].tag, true) + dv.setUint16(entryOffset + 2, 2, true) + dv.setUint32(entryOffset + 4, strings[i].length, true) + dv.setUint32(entryOffset + 8, stringOffset, true) + buf.set(strings[i], stringOffset) + stringOffset += strings[i].length + } return buf } -function buildWebp(precedingChunkLength: number, workflowJson: string): File { - const exifPayload = buildExifPayload(workflowJson) - const precedingPadded = precedingChunkLength + (precedingChunkLength % 2) - const totalSize = 12 + (8 + precedingPadded) + (8 + exifPayload.length) +type WebpChunk = { type: string; payload: Uint8Array } - const buffer = new Uint8Array(totalSize) - const dv = new DataView(buffer.buffer) +function wrapInWebp(chunks: WebpChunk[]): File { + let payloadSize = 0 + for (const c of chunks) { + payloadSize += 8 + c.payload.length + (c.payload.length % 2) + } + const totalSize = 12 + payloadSize + const buf = new Uint8Array(totalSize) + const dv = new DataView(buf.buffer) - buffer.set([0x52, 0x49, 0x46, 0x46], 0) + buf.set([0x52, 0x49, 0x46, 0x46], 0) dv.setUint32(4, totalSize - 8, true) - buffer.set([0x57, 0x45, 0x42, 0x50], 8) + buf.set([0x57, 0x45, 0x42, 0x50], 8) - buffer.set([0x56, 0x50, 0x38, 0x20], 12) - dv.setUint32(16, precedingChunkLength, true) + let offset = 12 + for (const c of chunks) { + for (let i = 0; i < 4; i++) { + buf[offset + i] = c.type.charCodeAt(i) + } + dv.setUint32(offset + 4, c.payload.length, true) + buf.set(c.payload, offset + 8) + offset += 8 + c.payload.length + (c.payload.length % 2) + } - const exifStart = 20 + precedingPadded - buffer.set([0x45, 0x58, 0x49, 0x46], exifStart) - dv.setUint32(exifStart + 4, exifPayload.length, true) - buffer.set(exifPayload, exifStart + 8) + return new File([buf], 'test.webp', { type: 'image/webp' }) +} - return new File([buffer], 'test.webp', { type: 'image/webp' }) +function exifChunk( + entries: AsciiIfdEntry[], + options: { withExifPrefix?: boolean } = {} +): WebpChunk { + const ifd = encodeAsciiIfd(entries) + if (!options.withExifPrefix) { + return { type: 'EXIF', payload: ifd } + } + const prefixed = new Uint8Array(6 + ifd.length) + prefixed.set(new TextEncoder().encode('Exif\0\0'), 0) + prefixed.set(ifd, 6) + return { type: 'EXIF', payload: prefixed } } describe('getWebpMetadata', () => { - it('finds workflow when a preceding chunk has odd length (RIFF padding)', async () => { - const workflow = '{"nodes":[]}' - const file = buildWebp(3, workflow) + it('returns empty when the file is not a valid WEBP', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + const file = new File([new Uint8Array(12)], 'fake.webp') const metadata = await getWebpMetadata(file) - expect(metadata.workflow).toBe(workflow) + expect(metadata).toEqual({}) + expect(console.error).toHaveBeenCalledWith('Not a valid WEBP file') }) - it('finds workflow when preceding chunk has even length (no padding)', async () => { - const workflow = '{"nodes":[1]}' - const file = buildWebp(4, workflow) + it('returns empty when a valid WEBP has no EXIF chunk', async () => { + const file = wrapInWebp([ + { type: 'VP8 ', payload: new Uint8Array([0, 0, 0, 0]) } + ]) const metadata = await getWebpMetadata(file) - expect(metadata.workflow).toBe(workflow) + expect(metadata).toEqual({}) + }) + + it('extracts workflow and prompt from EXIF without prefix', async () => { + const bytes = fs.readFileSync(path.join(fixturesDir, 'with_metadata.webp')) + const file = new File([bytes], 'test.webp', { type: 'image/webp' }) + + const metadata = await getWebpMetadata(file) + + expect(metadata).toEqual({ + workflow: + '{"nodes":[{"id":1,"type":"KSampler","pos":[100,100],"size":[200,200]}]}', + prompt: '{"1":{"class_type":"KSampler","inputs":{}}}' + }) + }) + + it('extracts workflow and prompt from EXIF with Exif\\0\\0 prefix', async () => { + const bytes = fs.readFileSync( + path.join(fixturesDir, 'with_metadata_exif_prefix.webp') + ) + const file = new File([bytes], 'test.webp', { type: 'image/webp' }) + + const metadata = await getWebpMetadata(file) + + expect(metadata).toEqual({ + workflow: + '{"nodes":[{"id":1,"type":"KSampler","pos":[100,100],"size":[200,200]}]}', + prompt: '{"1":{"class_type":"KSampler","inputs":{}}}' + }) + }) + + it('walks past odd-length preceding chunks (RIFF padding)', async () => { + const file = wrapInWebp([ + { type: 'VP8 ', payload: new Uint8Array(3) }, + exifChunk([{ tag: 0, value: 'workflow:{"a":1}' }]) + ]) + + const metadata = await getWebpMetadata(file) + + expect(metadata).toEqual({ workflow: '{"a":1}' }) + }) +}) + +describe('getLatentMetadata', () => { + function buildSafetensors(headerObj: object): File { + const headerBytes = new TextEncoder().encode(JSON.stringify(headerObj)) + const buf = new Uint8Array(8 + headerBytes.length) + const dv = new DataView(buf.buffer) + dv.setUint32(0, headerBytes.length, true) + dv.setUint32(4, 0, true) + buf.set(headerBytes, 8) + return new File([buf], 'test.safetensors') + } + + it('extracts __metadata__ from a safetensors header', async () => { + const workflow = + '{"nodes":[{"id":1,"type":"KSampler","pos":[100,100],"size":[200,200]}]}' + const prompt = '{"1":{"class_type":"KSampler","inputs":{}}}' + const file = buildSafetensors({ + __metadata__: { workflow, prompt }, + 'tensor.weight': { dtype: 'F32', shape: [1], data_offsets: [0, 4] } + }) + + const metadata = await getLatentMetadata(file) + + expect(metadata).toEqual({ workflow, prompt }) + }) + + it('returns undefined when the safetensors header has no __metadata__', async () => { + const file = buildSafetensors({ + 'tensor.weight': { dtype: 'F32', shape: [1], data_offsets: [0, 4] } + }) + + const metadata = await getLatentMetadata(file) + + expect(metadata).toBeUndefined() + }) + + it('returns undefined for a truncated or malformed file', async () => { + const file = new File([new Uint8Array(4)], 'bad.safetensors') + + const metadata = await getLatentMetadata(file) + + expect(metadata).toBeUndefined() + }) +}) + +describe('format-specific metadata wrappers', () => { + it('getPngMetadata delegates to getFromPngFile', async () => { + const file = new File([], 'a.png', { type: 'image/png' }) + vi.mocked(getFromPngFile).mockResolvedValue({ workflow: '{"png":1}' }) + + const result = await getPngMetadata(file) + + expect(getFromPngFile).toHaveBeenCalledWith(file) + expect(result).toEqual({ workflow: '{"png":1}' }) + }) + + it('getFlacMetadata delegates to getFromFlacFile', async () => { + const file = new File([], 'a.flac', { type: 'audio/flac' }) + vi.mocked(getFromFlacFile).mockResolvedValue({ workflow: '{"flac":1}' }) + + const result = await getFlacMetadata(file) + + expect(getFromFlacFile).toHaveBeenCalledWith(file) + expect(result).toEqual({ workflow: '{"flac":1}' }) + }) + + it('getAvifMetadata delegates to getFromAvifFile', async () => { + const file = new File([], 'a.avif', { type: 'image/avif' }) + vi.mocked(getFromAvifFile).mockResolvedValue({ workflow: '{"avif":1}' }) + + const result = await getAvifMetadata(file) + + expect(getFromAvifFile).toHaveBeenCalledWith(file) + expect(result).toEqual({ workflow: '{"avif":1}' }) }) }) diff --git a/src/scripts/pnginfo.ts b/src/scripts/pnginfo.ts index d0b6ee3e4d..c27ee50528 100644 --- a/src/scripts/pnginfo.ts +++ b/src/scripts/pnginfo.ts @@ -105,14 +105,17 @@ export function getWebpMetadata(file: File) { ...webp.slice(offset, offset + 4) ) if (chunk_type === 'EXIF') { + let exifOffset = offset + 8 + let exifLength = chunk_length if ( - String.fromCharCode(...webp.slice(offset + 8, offset + 8 + 6)) == + String.fromCharCode(...webp.slice(exifOffset, exifOffset + 6)) == 'Exif\0\0' ) { - offset += 6 + exifOffset += 6 + exifLength -= 6 } - let data = parseExifData( - webp.slice(offset + 8, offset + 8 + chunk_length) + const data = parseExifData( + webp.slice(exifOffset, exifOffset + exifLength) ) for (const key in data) { const value = data[Number(key)] @@ -131,30 +134,38 @@ export function getWebpMetadata(file: File) { r(txt_chunks) } - + reader.onerror = () => r({}) + reader.onabort = () => r({}) reader.readAsArrayBuffer(file) }) } -export function getLatentMetadata(file: File): Promise<Record<string, string>> { +export function getLatentMetadata( + file: File +): Promise<Record<string, string> | undefined> { return new Promise((r) => { const reader = new FileReader() reader.onload = (event) => { - const safetensorsData = new Uint8Array( - event.target?.result as ArrayBuffer - ) - const dataView = new DataView(safetensorsData.buffer) - let header_size = dataView.getUint32(0, true) - let offset = 8 - let header = JSON.parse( - new TextDecoder().decode( - safetensorsData.slice(offset, offset + header_size) + try { + const safetensorsData = new Uint8Array( + event.target?.result as ArrayBuffer ) - ) - r(header.__metadata__) + const dataView = new DataView(safetensorsData.buffer) + const headerSize = dataView.getUint32(0, true) + const offset = 8 + const header = JSON.parse( + new TextDecoder().decode( + safetensorsData.slice(offset, offset + headerSize) + ) + ) + r(header.__metadata__) + } catch { + r(undefined) + } } - - var slice = file.slice(0, 1024 * 1024 * 4) + reader.onerror = () => r(undefined) + reader.onabort = () => r(undefined) + const slice = file.slice(0, 1024 * 1024 * 4) reader.readAsArrayBuffer(slice) }) } diff --git a/src/services/dialogService.ts b/src/services/dialogService.ts index e10823919e..41993c64ad 100644 --- a/src/services/dialogService.ts +++ b/src/services/dialogService.ts @@ -30,6 +30,8 @@ const lazyComfyOrgHeader = () => import('@/components/dialog/header/ComfyOrgHeader.vue') const lazyCloudNotificationContent = () => import('@/platform/cloud/notification/components/CloudNotificationContent.vue') +const lazyPublishDialog = () => + import('@/platform/workflow/sharing/components/publish/ComfyHubPublishDialog.vue') export type ConfirmationDialogType = | 'default' @@ -592,10 +594,28 @@ export const useDialogService = () => { }) } + async function showPublishDialog(): Promise<void> { + const { default: ComfyHubPublishDialog } = await lazyPublishDialog() + const key = 'global-comfyhub-publish' + showLayoutDialog({ + key, + component: ComfyHubPublishDialog, + props: { + onClose: () => dialogStore.closeDialog({ key }) + }, + dialogComponentProps: { + pt: { + root: { 'data-testid': 'publish-dialog' } + } + } + }) + } + return { showExecutionErrorDialog, showApiNodesSignInDialog, showSignInDialog, + showPublishDialog, showSubscriptionRequiredDialog, showTopUpCreditsDialog, showUpdatePasswordDialog, diff --git a/src/services/gateway/registrySearchGateway.ts b/src/services/gateway/registrySearchGateway.ts index 5714d661e7..92a05a6c4a 100644 --- a/src/services/gateway/registrySearchGateway.ts +++ b/src/services/gateway/registrySearchGateway.ts @@ -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( diff --git a/src/services/load3dService.test.ts b/src/services/load3dService.test.ts new file mode 100644 index 0000000000..374e11b1fd --- /dev/null +++ b/src/services/load3dService.test.ts @@ -0,0 +1,743 @@ +import * as THREE from 'three' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import type Load3d from '@/extensions/core/load3d/Load3d' +import type { LGraphNode } from '@/lib/litegraph/src/litegraph' +import { useLoad3dService } from '@/services/load3dService' +import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils' + +const { nodeMap, useLoad3dViewerMock, skeletonCloneMock } = vi.hoisted(() => ({ + nodeMap: new Map<LGraphNode, Load3d>(), + useLoad3dViewerMock: vi.fn(), + skeletonCloneMock: vi.fn() +})) + +vi.mock('@/composables/useLoad3d', () => ({ + nodeToLoad3dMap: nodeMap +})) + +vi.mock('@/composables/useLoad3dViewer', () => ({ + useLoad3dViewer: useLoad3dViewerMock +})) + +vi.mock('three/examples/jsm/utils/SkeletonUtils', () => ({ + clone: skeletonCloneMock +})) + +// Track every node a test creates so the load3dService singleton's +// internal viewerInstances map can be drained in beforeEach without +// reaching into the module's private state. +const createdNodes = new Set<LGraphNode>() + +function makeNode(id: number | string): LGraphNode { + const node = createMockLGraphNode({ id }) + createdNodes.add(node) + return node +} + +function makeLoad3d(): Load3d { + return { + remove: vi.fn() + } as unknown as Load3d +} + +function makeViewer(overrides: Record<string, unknown> = {}) { + return { + needApplyChanges: { value: false }, + applyChanges: vi.fn().mockResolvedValue(true), + cleanup: vi.fn(), + ...overrides + } +} + +describe('load3dService', () => { + beforeEach(() => { + vi.clearAllMocks() + nodeMap.clear() + const svc = useLoad3dService() + for (const node of createdNodes) svc.removeViewer(node) + createdNodes.clear() + }) + + afterEach(() => { + vi.clearAllMocks() + }) + + describe('singleton', () => { + it('returns the same instance from useLoad3dService()', () => { + expect(useLoad3dService()).toBe(useLoad3dService()) + }) + }) + + describe('getLoad3d (sync)', () => { + it('returns null when the load3d module has not been loaded yet', () => { + // Before any async accessor has been called, the cache is empty. + // We can't easily simulate "module never loaded" because vi.mock makes + // it eagerly available, so this test verifies the behavior via missing + // entries instead. + const node = makeNode('missing') + expect(useLoad3dService().getLoad3d(node)).toBeNull() + }) + + it('returns null after async load when the node has no entry in the map', async () => { + const svc = useLoad3dService() + // Trigger the async loader so the sync path has a populated cache. + await svc.getLoad3dAsync(makeNode('anything')) + + expect(svc.getLoad3d(makeNode('still-missing'))).toBeNull() + }) + + it('returns the registered Load3d instance once the map has been populated', async () => { + const svc = useLoad3dService() + const node = makeNode('a') + const load3d = makeLoad3d() + nodeMap.set(node, load3d) + await svc.getLoad3dAsync(node) + + expect(svc.getLoad3d(node)).toBe(load3d) + }) + }) + + describe('getLoad3dAsync', () => { + it('returns the Load3d for a registered node', async () => { + const svc = useLoad3dService() + const node = makeNode('async-a') + const load3d = makeLoad3d() + nodeMap.set(node, load3d) + + await expect(svc.getLoad3dAsync(node)).resolves.toBe(load3d) + }) + + it('returns null for an unregistered node', async () => { + const svc = useLoad3dService() + await expect( + svc.getLoad3dAsync(makeNode('async-missing')) + ).resolves.toBeNull() + }) + }) + + describe('getNodeByLoad3d', () => { + it('finds the node owning a given Load3d instance', async () => { + const svc = useLoad3dService() + const node = makeNode('owner') + const load3d = makeLoad3d() + nodeMap.set(node, load3d) + await svc.getLoad3dAsync(node) + + expect(svc.getNodeByLoad3d(load3d)).toBe(node) + }) + + it('returns null when the Load3d instance is not in the map', async () => { + const svc = useLoad3dService() + await svc.getLoad3dAsync(makeNode('warmup')) + + expect(svc.getNodeByLoad3d(makeLoad3d())).toBeNull() + }) + }) + + describe('removeLoad3d', () => { + it('calls remove() on the instance and drops it from the map', async () => { + const svc = useLoad3dService() + const node = makeNode('to-remove') + const load3d = makeLoad3d() + nodeMap.set(node, load3d) + await svc.getLoad3dAsync(node) + + svc.removeLoad3d(node) + + expect(load3d.remove).toHaveBeenCalled() + expect(nodeMap.has(node)).toBe(false) + }) + + it('is a no-op when the node has no registered Load3d', async () => { + const svc = useLoad3dService() + await svc.getLoad3dAsync(makeNode('warmup')) + + expect(() => svc.removeLoad3d(makeNode('not-there'))).not.toThrow() + }) + }) + + describe('clear', () => { + it('removes every registered Load3d', async () => { + const svc = useLoad3dService() + const a = makeNode('a') + const b = makeNode('b') + const ld1 = makeLoad3d() + const ld2 = makeLoad3d() + nodeMap.set(a, ld1) + nodeMap.set(b, ld2) + await svc.getLoad3dAsync(a) + + svc.clear() + + expect(nodeMap.size).toBe(0) + expect(ld1.remove).toHaveBeenCalled() + expect(ld2.remove).toHaveBeenCalled() + }) + }) + + describe('viewer lifecycle', () => { + it('getOrCreateViewer creates a viewer on first call and reuses it on subsequent calls', async () => { + const svc = useLoad3dService() + const node = makeNode('v1') + const viewer = makeViewer() + useLoad3dViewerMock.mockReturnValue(viewer) + + const first = await svc.getOrCreateViewer(node) + const second = await svc.getOrCreateViewer(node) + + expect(first).toBe(viewer) + expect(second).toBe(viewer) + expect(useLoad3dViewerMock).toHaveBeenCalledTimes(1) + expect(useLoad3dViewerMock).toHaveBeenCalledWith(node) + }) + + it('getOrCreateViewerSync uses the supplied factory once and caches the result', () => { + const svc = useLoad3dService() + const node = makeNode('v-sync') + const viewer = makeViewer() + const factory = vi.fn().mockReturnValue(viewer) + + const first = svc.getOrCreateViewerSync( + node, + factory as unknown as typeof useLoad3dViewerMock + ) + const second = svc.getOrCreateViewerSync( + node, + factory as unknown as typeof useLoad3dViewerMock + ) + + expect(first).toBe(viewer) + expect(second).toBe(viewer) + expect(factory).toHaveBeenCalledTimes(1) + }) + + it('removeViewer calls cleanup and forgets the viewer', async () => { + const svc = useLoad3dService() + const node = makeNode('v2') + const viewer = makeViewer() + useLoad3dViewerMock.mockReturnValue(viewer) + await svc.getOrCreateViewer(node) + + svc.removeViewer(node) + + expect(viewer.cleanup).toHaveBeenCalled() + useLoad3dViewerMock.mockClear() + const fresh = makeViewer() + useLoad3dViewerMock.mockReturnValue(fresh) + const result = await svc.getOrCreateViewer(node) + expect(useLoad3dViewerMock).toHaveBeenCalledTimes(1) + expect(result).toBe(fresh) + }) + + it('removeViewer is safe when no viewer has been created for the node', () => { + const svc = useLoad3dService() + expect(() => svc.removeViewer(makeNode('never'))).not.toThrow() + }) + }) + + describe('handleViewerClose', () => { + it('removes the viewer without applying changes when none are pending', async () => { + const svc = useLoad3dService() + const node = makeNode('close-clean') + const viewer = makeViewer({ needApplyChanges: { value: false } }) + useLoad3dViewerMock.mockReturnValue(viewer) + await svc.getOrCreateViewer(node) + + await svc.handleViewerClose(node) + + expect(viewer.applyChanges).not.toHaveBeenCalled() + expect(viewer.cleanup).toHaveBeenCalled() + }) + + it('applies changes and syncs the node config when changes are pending', async () => { + const svc = useLoad3dService() + const syncLoad3dConfig = vi.fn() + const node = Object.assign(makeNode('close-dirty'), { + syncLoad3dConfig + }) as LGraphNode + const viewer = makeViewer({ needApplyChanges: { value: true } }) + useLoad3dViewerMock.mockReturnValue(viewer) + await svc.getOrCreateViewer(node) + + await svc.handleViewerClose(node) + + expect(viewer.applyChanges).toHaveBeenCalled() + expect(syncLoad3dConfig).toHaveBeenCalled() + expect(viewer.cleanup).toHaveBeenCalled() + }) + + it('skips syncLoad3dConfig when the node does not define it', async () => { + const svc = useLoad3dService() + const node = makeNode('close-no-sync') + const viewer = makeViewer({ needApplyChanges: { value: true } }) + useLoad3dViewerMock.mockReturnValue(viewer) + await svc.getOrCreateViewer(node) + + await expect(svc.handleViewerClose(node)).resolves.toBeUndefined() + expect(viewer.applyChanges).toHaveBeenCalled() + expect(viewer.cleanup).toHaveBeenCalled() + }) + }) + + describe('handleViewportRefresh', () => { + it('returns silently when the load3d is null', () => { + expect(() => useLoad3dService().handleViewportRefresh(null)).not.toThrow() + }) + + it('toggles the camera through the opposite type and back, then updates controls', () => { + const controls = { update: vi.fn() } + const load3d = { + handleResize: vi.fn(), + getCurrentCameraType: vi.fn().mockReturnValue('perspective'), + toggleCamera: vi.fn(), + getControlsManager: vi.fn().mockReturnValue({ controls }) + } as unknown as Load3d + + useLoad3dService().handleViewportRefresh(load3d) + + expect(load3d.handleResize).toHaveBeenCalled() + expect(load3d.toggleCamera).toHaveBeenNthCalledWith(1, 'orthographic') + expect(load3d.toggleCamera).toHaveBeenNthCalledWith(2, 'perspective') + expect(controls.update).toHaveBeenCalled() + }) + + it('toggles in the reverse direction when starting from orthographic', () => { + const controls = { update: vi.fn() } + const load3d = { + handleResize: vi.fn(), + getCurrentCameraType: vi.fn().mockReturnValue('orthographic'), + toggleCamera: vi.fn(), + getControlsManager: vi.fn().mockReturnValue({ controls }) + } as unknown as Load3d + + useLoad3dService().handleViewportRefresh(load3d) + + expect(load3d.toggleCamera).toHaveBeenNthCalledWith(1, 'perspective') + expect(load3d.toggleCamera).toHaveBeenNthCalledWith(2, 'orthographic') + }) + }) + + describe('copyLoad3dState', () => { + type SourceOverrides = Partial<{ + currentModel: THREE.Object3D | null + isSplat: boolean + originalURL: string | null + originalModel: unknown + materialMode: string + currentUpDirection: string + appliedTexture: unknown + gizmoEnabled: boolean + hasAnimations: boolean + cameraType: 'perspective' | 'orthographic' + backgroundInfo: { type: 'image' | 'color' } + lightsIntensity: number | undefined + fov: number + }> + + function makeSource(overrides: SourceOverrides = {}): Load3d { + const { + currentModel = null, + isSplat = false, + originalURL = null, + originalModel = null, + materialMode = 'original', + currentUpDirection = 'original', + appliedTexture = null, + gizmoEnabled = false, + hasAnimations = false, + cameraType = 'perspective', + backgroundInfo = { type: 'color' }, + lightsIntensity = 0.8, + fov = 35 + } = overrides + const ambient = { intensity: 0.5 } + const main = { intensity: lightsIntensity } + return { + modelManager: { currentModel, originalURL }, + getGizmoManager: () => ({ + isEnabled: () => gizmoEnabled, + getInitialTransform: () => ({ + position: { x: 1, y: 2, z: 3 }, + rotation: { x: 0.1, y: 0.2, z: 0.3 }, + scale: { x: 4, y: 5, z: 6 } + }) + }), + isSplatModel: () => isSplat, + getModelManager: () => ({ + originalModel, + materialMode, + currentUpDirection, + appliedTexture + }), + getGizmoTransform: () => ({ + position: { x: 7, y: 8, z: 9 }, + rotation: { x: 0.4, y: 0.5, z: 0.6 }, + scale: { x: 10, y: 11, z: 12 } + }), + hasAnimations: () => hasAnimations, + getCurrentCameraType: () => cameraType, + getCameraState: () => ({ snapshot: true }), + getSceneManager: () => ({ + scene: new THREE.Scene(), + currentBackgroundColor: '#abcdef', + gridHelper: { visible: true }, + getCurrentBackgroundInfo: () => backgroundInfo + }), + getLightingManager: () => ({ lights: [ambient, main] }), + getCameraManager: () => ({ perspectiveCamera: { fov } }) + } as unknown as Load3d + } + + type TargetState = { + modelManager: { + currentModel: THREE.Object3D | null + originalModel: unknown + materialMode: string + currentUpDirection: string + appliedTexture: unknown + } + gizmoManager: { + isEnabled: () => boolean + detach: ReturnType<typeof vi.fn> + setupForModel: ReturnType<typeof vi.fn> + } + animationManager: { + setupModelAnimations: ReturnType<typeof vi.fn> + } + sceneRemoved: THREE.Object3D[] + sceneAdded: THREE.Object3D[] + } + + function makeTarget( + opts: { + gizmoEnabled?: boolean + existingModel?: THREE.Object3D | null + } = {} + ) { + const { gizmoEnabled = false, existingModel = null } = opts + const scene = new THREE.Scene() + const sceneRemoved: THREE.Object3D[] = [] + const sceneAdded: THREE.Object3D[] = [] + const sceneRemove = vi.fn((o: THREE.Object3D) => { + sceneRemoved.push(o) + scene.remove(o) + }) + const sceneAdd = vi.fn((o: THREE.Object3D) => { + sceneAdded.push(o) + scene.add(o) + }) + const modelManager = { + currentModel: existingModel as THREE.Object3D | null, + originalModel: null as unknown, + materialMode: 'original', + currentUpDirection: 'original', + appliedTexture: null as unknown + } + const animationManager = { + setupModelAnimations: vi.fn() + } + // Memoize the gizmo manager so production code's repeated + // `target.getGizmoManager()` calls reach the same vi.fn instances. + const gizmoManager = { + isEnabled: () => gizmoEnabled, + detach: vi.fn(), + setupForModel: vi.fn() + } + const target = { + getGizmoManager: () => gizmoManager, + getModelManager: () => modelManager, + getSceneManager: () => ({ + scene: { + add: sceneAdd, + remove: sceneRemove + } as unknown as THREE.Scene + }), + loadModel: vi.fn().mockResolvedValue(undefined), + setMaterialMode: vi.fn(), + setUpDirection: vi.fn(), + applyGizmoTransform: vi.fn(), + setGizmoEnabled: vi.fn(), + animationManager, + toggleCamera: vi.fn(), + setCameraState: vi.fn(), + setBackgroundColor: vi.fn(), + toggleGrid: vi.fn(), + setBackgroundImage: vi.fn().mockResolvedValue(undefined), + setLightIntensity: vi.fn(), + setFOV: vi.fn() + } as unknown as Load3d + const state: TargetState = { + modelManager, + gizmoManager, + animationManager, + sceneRemoved, + sceneAdded + } + return { target, state } + } + + function makeModel(): THREE.Object3D { + return new THREE.Object3D() + } + + it('copies camera/scene/lighting/FOV even when there is no source model', async () => { + const source = makeSource({ currentModel: null, lightsIntensity: 2 }) + const { target } = makeTarget() + skeletonCloneMock.mockReturnValue(makeModel()) + + await useLoad3dService().copyLoad3dState(source, target) + + expect(target.toggleCamera).toHaveBeenCalledWith('perspective') + expect(target.setCameraState).toHaveBeenCalledWith({ snapshot: true }) + expect(target.setBackgroundColor).toHaveBeenCalledWith('#abcdef') + expect(target.toggleGrid).toHaveBeenCalledWith(true) + expect(target.setLightIntensity).toHaveBeenCalledWith(2) + expect(target.setFOV).toHaveBeenCalledWith(35) + expect(skeletonCloneMock).not.toHaveBeenCalled() + expect(target.loadModel).not.toHaveBeenCalled() + }) + + it('uses target.loadModel(originalURL) for splat models, never invoking SkeletonUtils.clone', async () => { + const source = makeSource({ + currentModel: makeModel(), + isSplat: true, + originalURL: 'http://example.com/scan.splat' + }) + const { target } = makeTarget() + + await useLoad3dService().copyLoad3dState(source, target) + + expect(target.loadModel).toHaveBeenCalledWith( + 'http://example.com/scan.splat' + ) + expect(skeletonCloneMock).not.toHaveBeenCalled() + }) + + it('skips loadModel for splat models when originalURL is null', async () => { + const source = makeSource({ + currentModel: makeModel(), + isSplat: true, + originalURL: null + }) + const { target } = makeTarget() + + await useLoad3dService().copyLoad3dState(source, target) + + expect(target.loadModel).not.toHaveBeenCalled() + }) + + it('removes the target existing model from the scene before adding the clone', async () => { + const existing = makeModel() + existing.name = 'existing' + const source = makeSource({ currentModel: makeModel() }) + const { target, state } = makeTarget({ existingModel: existing }) + const clone = makeModel() + skeletonCloneMock.mockReturnValue(clone) + + await useLoad3dService().copyLoad3dState(source, target) + + expect(state.sceneRemoved).toContain(existing) + expect(state.sceneAdded).toContain(clone) + }) + + it('clones the source model via SkeletonUtils and assigns it as the target current model', async () => { + const sourceModel = makeModel() + const clone = makeModel() + const source = makeSource({ currentModel: sourceModel }) + const { target, state } = makeTarget() + skeletonCloneMock.mockReturnValue(clone) + + await useLoad3dService().copyLoad3dState(source, target) + + expect(skeletonCloneMock).toHaveBeenCalledWith(sourceModel) + expect(state.modelManager.currentModel).toBe(clone) + }) + + it('copies originalModel, material mode, up direction, and applied texture from source to target', async () => { + const sourceOriginal = { kind: 'gltf' } + const texture = { id: 'tex1' } + const source = makeSource({ + currentModel: makeModel(), + originalModel: sourceOriginal, + materialMode: 'wireframe', + currentUpDirection: '+y', + appliedTexture: texture + }) + const { target, state } = makeTarget() + skeletonCloneMock.mockReturnValue(makeModel()) + + await useLoad3dService().copyLoad3dState(source, target) + + expect(state.modelManager.originalModel).toBe(sourceOriginal) + expect(state.modelManager.materialMode).toBe('wireframe') + expect(state.modelManager.currentUpDirection).toBe('+y') + expect(state.modelManager.appliedTexture).toBe(texture) + expect(target.setMaterialMode).toHaveBeenCalledWith('wireframe') + expect(target.setUpDirection).toHaveBeenCalledWith('+y') + }) + + it('positions the clone at the source initial transform', async () => { + const clone = makeModel() + const source = makeSource({ currentModel: makeModel() }) + const { target } = makeTarget() + skeletonCloneMock.mockReturnValue(clone) + + await useLoad3dService().copyLoad3dState(source, target) + + expect(clone.position.toArray()).toEqual([1, 2, 3]) + expect(clone.rotation.toArray().slice(0, 3)).toEqual([0.1, 0.2, 0.3]) + expect(clone.scale.toArray()).toEqual([4, 5, 6]) + }) + + it('applies the source gizmo transform to the target', async () => { + const source = makeSource({ currentModel: makeModel() }) + const { target } = makeTarget() + skeletonCloneMock.mockReturnValue(makeModel()) + + await useLoad3dService().copyLoad3dState(source, target) + + expect(target.applyGizmoTransform).toHaveBeenCalledWith( + { x: 7, y: 8, z: 9 }, + { x: 0.4, y: 0.5, z: 0.6 }, + { x: 10, y: 11, z: 12 } + ) + }) + + it('enables the gizmo on target when the source had it enabled', async () => { + const source = makeSource({ + currentModel: makeModel(), + gizmoEnabled: true + }) + const { target } = makeTarget({ gizmoEnabled: false }) + skeletonCloneMock.mockReturnValue(makeModel()) + + await useLoad3dService().copyLoad3dState(source, target) + + expect(target.setGizmoEnabled).toHaveBeenCalledWith(true) + }) + + it('enables the gizmo on target when the target previously had it enabled, even if source did not', async () => { + const source = makeSource({ + currentModel: makeModel(), + gizmoEnabled: false + }) + const { target } = makeTarget({ gizmoEnabled: true }) + skeletonCloneMock.mockReturnValue(makeModel()) + + await useLoad3dService().copyLoad3dState(source, target) + + expect(target.setGizmoEnabled).toHaveBeenCalledWith(true) + }) + + it('does not enable the gizmo when neither side had it', async () => { + const source = makeSource({ + currentModel: makeModel(), + gizmoEnabled: false + }) + const { target } = makeTarget({ gizmoEnabled: false }) + skeletonCloneMock.mockReturnValue(makeModel()) + + await useLoad3dService().copyLoad3dState(source, target) + + expect(target.setGizmoEnabled).not.toHaveBeenCalled() + }) + + it('forwards animation setup when the source has animations', async () => { + const sourceOriginal = { kind: 'gltf' } + const clone = makeModel() + const source = makeSource({ + currentModel: makeModel(), + originalModel: sourceOriginal, + hasAnimations: true + }) + const { target, state } = makeTarget() + skeletonCloneMock.mockReturnValue(clone) + + await useLoad3dService().copyLoad3dState(source, target) + + expect(state.animationManager.setupModelAnimations).toHaveBeenCalledWith( + clone, + sourceOriginal + ) + }) + + it('does not forward animation setup when the source has none', async () => { + const source = makeSource({ + currentModel: makeModel(), + hasAnimations: false + }) + const { target, state } = makeTarget() + skeletonCloneMock.mockReturnValue(makeModel()) + + await useLoad3dService().copyLoad3dState(source, target) + + expect(state.animationManager.setupModelAnimations).not.toHaveBeenCalled() + }) + + it('forwards an image background to setBackgroundImage when the source node has a configured path', async () => { + const node = createMockLGraphNode({ + id: 'bg-source', + properties: { 'Scene Config': { backgroundImage: '3d/bg.png' } } + }) + createdNodes.add(node) + const source = makeSource({ backgroundInfo: { type: 'image' } }) + nodeMap.set(node, source) + // Warm the cache so `getNodeByLoad3d` finds the source. + await useLoad3dService().getLoad3dAsync(node) + const { target } = makeTarget() + + await useLoad3dService().copyLoad3dState(source, target) + + expect(target.setBackgroundImage).toHaveBeenCalledWith('3d/bg.png') + }) + + it('clears the background when the source background type is not image', async () => { + const source = makeSource({ backgroundInfo: { type: 'color' } }) + const { target } = makeTarget() + + await useLoad3dService().copyLoad3dState(source, target) + + expect(target.setBackgroundImage).toHaveBeenCalledWith('') + }) + + it('falls back to setLightIntensity(1) when the second light intensity is falsy', async () => { + const source = makeSource({ lightsIntensity: 0 }) + const { target } = makeTarget() + + await useLoad3dService().copyLoad3dState(source, target) + + expect(target.setLightIntensity).toHaveBeenCalledWith(1) + }) + + it('skips setFOV when the source camera is orthographic', async () => { + const source = makeSource({ cameraType: 'orthographic' }) + const { target } = makeTarget() + + await useLoad3dService().copyLoad3dState(source, target) + + expect(target.setFOV).not.toHaveBeenCalled() + }) + + it('always detaches the target gizmo at the start of the copy', async () => { + const source = makeSource({ currentModel: makeModel() }) + const { target, state } = makeTarget() + skeletonCloneMock.mockReturnValue(makeModel()) + + await useLoad3dService().copyLoad3dState(source, target) + + expect(state.gizmoManager.detach).toHaveBeenCalled() + }) + + it('calls setupForModel on the target gizmo with the freshly cloned model', async () => { + const clone = makeModel() + const source = makeSource({ currentModel: makeModel() }) + const { target, state } = makeTarget() + skeletonCloneMock.mockReturnValue(clone) + + await useLoad3dService().copyLoad3dState(source, target) + + expect(state.gizmoManager.setupForModel).toHaveBeenCalledWith(clone) + }) + }) +}) diff --git a/src/stores/assetDownloadStore.ts b/src/stores/assetDownloadStore.ts index 054f28a775..0d9f237351 100644 --- a/src/stores/assetDownloadStore.ts +++ b/src/stores/assetDownloadStore.ts @@ -2,12 +2,13 @@ import { useIntervalFn } from '@vueuse/core' import { defineStore } from 'pinia' import { computed, ref, watch } from 'vue' +import type { TaskId } from '@/platform/tasks/services/taskService' import { taskService } from '@/platform/tasks/services/taskService' import type { AssetDownloadWsMessage } from '@/schemas/apiSchema' import { api } from '@/scripts/api' export interface AssetDownload { - taskId: string + taskId: TaskId assetName: string bytesTotal: number bytesDownloaded: number @@ -21,7 +22,7 @@ export interface AssetDownload { } interface CompletedDownload { - taskId: string + taskId: TaskId modelType: string timestamp: number } @@ -29,7 +30,7 @@ const STALE_THRESHOLD_MS = 10_000 const POLL_INTERVAL_MS = 10_000 function generateDownloadTrackingPlaceholder( - taskId: string, + taskId: TaskId, modelType: string, assetName: string ): AssetDownload { @@ -83,7 +84,7 @@ export const useAssetDownloadStore = defineStore('assetDownload', () => { } } - function trackDownload(taskId: string, modelType: string, assetName: string) { + function trackDownload(taskId: TaskId, modelType: string, assetName: string) { if (downloads.value.has(taskId)) return downloads.value.set( diff --git a/src/stores/assetExportStore.ts b/src/stores/assetExportStore.ts index a8fe9ada53..a904e051ee 100644 --- a/src/stores/assetExportStore.ts +++ b/src/stores/assetExportStore.ts @@ -4,13 +4,14 @@ import { computed, ref, watch } from 'vue' import { assetService } from '@/platform/assets/services/assetService' import { useToastStore } from '@/platform/updates/common/toastStore' +import type { TaskId } from '@/platform/tasks/services/taskService' import { taskService } from '@/platform/tasks/services/taskService' import type { AssetExportWsMessage } from '@/schemas/apiSchema' import { api } from '@/scripts/api' import { t } from '@/i18n' export interface AssetExport { - taskId: string + taskId: TaskId exportName: string assetsTotal: number assetsAttempted: number @@ -29,7 +30,7 @@ const STALE_THRESHOLD_MS = 10_000 const POLL_INTERVAL_MS = 10_000 export const useAssetExportStore = defineStore('assetExport', () => { - const exports = ref<Map<string, AssetExport>>(new Map()) + const exports = ref<Map<TaskId, AssetExport>>(new Map()) const exportList = computed(() => Array.from(exports.value.values())) const activeExports = computed(() => @@ -45,7 +46,7 @@ export const useAssetExportStore = defineStore('assetExport', () => { const hasActiveExports = computed(() => activeExports.value.length > 0) const hasExports = computed(() => exports.value.size > 0) - function trackExport(taskId: string) { + function trackExport(taskId: TaskId) { if (exports.value.has(taskId)) return exports.value.set(taskId, { diff --git a/src/stores/assetsStore.test.ts b/src/stores/assetsStore.test.ts index 0a8cb0a75f..6f60606108 100644 --- a/src/stores/assetsStore.test.ts +++ b/src/stores/assetsStore.test.ts @@ -24,7 +24,12 @@ vi.mock('@/scripts/api', () => ({ vi.mock('@/platform/assets/services/assetService', () => ({ assetService: { getAssetsByTag: vi.fn(), - getAssetsForNodeType: vi.fn() + getAllAssetsByTag: vi.fn(), + getAssetsForNodeType: vi.fn(), + invalidateInputAssetsIncludingPublic: vi.fn(), + updateAsset: vi.fn(), + addAssetTags: vi.fn(), + removeAssetTags: vi.fn() } })) @@ -1034,4 +1039,234 @@ describe('assetsStore - Model Assets Cache (Cloud)', () => { ).not.toThrow() }) }) + + describe('updateAssetMetadata optimistic cache', () => { + it('reflects the server response in the cache after a successful update', async () => { + const store = useAssetsStore() + const original = { + ...createMockAsset('opt-1'), + user_metadata: { note: 'before' } as Record<string, unknown> + } + + vi.mocked(assetService.getAssetsForNodeType).mockResolvedValueOnce([ + original + ]) + await store.updateModelsForNodeType('CheckpointLoaderSimple') + + const serverResponse = { + ...original, + user_metadata: { note: 'server-confirmed' } + } + vi.mocked(assetService.updateAsset).mockResolvedValueOnce(serverResponse) + + await store.updateAssetMetadata( + original, + { note: 'optimistic' }, + 'CheckpointLoaderSimple' + ) + + const cached = store.getAssets('CheckpointLoaderSimple')[0] + expect(cached.user_metadata).toEqual({ note: 'server-confirmed' }) + }) + + it('rolls back to the original metadata when the server rejects', async () => { + const store = useAssetsStore() + const original = { + ...createMockAsset('opt-2'), + user_metadata: { note: 'before' } as Record<string, unknown> + } + + vi.mocked(assetService.getAssetsForNodeType).mockResolvedValueOnce([ + original + ]) + await store.updateModelsForNodeType('CheckpointLoaderSimple') + + vi.mocked(assetService.updateAsset).mockRejectedValueOnce( + new Error('500 Internal Error') + ) + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + await store.updateAssetMetadata( + original, + { note: 'will be reverted' }, + 'CheckpointLoaderSimple' + ) + + const cached = store.getAssets('CheckpointLoaderSimple')[0] + expect(cached.user_metadata).toEqual({ note: 'before' }) + consoleSpy.mockRestore() + }) + }) + + describe('updateAssetTags diff-based dispatch', () => { + it('skips both endpoints and does not mutate the cache when tags are unchanged', async () => { + const store = useAssetsStore() + const asset = createMockAsset('tags-noop', ['models', 'checkpoints']) + + vi.mocked(assetService.getAssetsForNodeType).mockResolvedValueOnce([ + asset + ]) + await store.updateModelsForNodeType('CheckpointLoaderSimple') + + await store.updateAssetTags( + asset, + ['checkpoints', 'models'], + 'CheckpointLoaderSimple' + ) + + expect(vi.mocked(assetService.addAssetTags)).not.toHaveBeenCalled() + expect(vi.mocked(assetService.removeAssetTags)).not.toHaveBeenCalled() + }) + + it('calls only the add endpoint when there are no tags to remove', async () => { + const store = useAssetsStore() + const asset = createMockAsset('tags-add-only', ['models']) + + vi.mocked(assetService.getAssetsForNodeType).mockResolvedValueOnce([ + asset + ]) + await store.updateModelsForNodeType('CheckpointLoaderSimple') + + vi.mocked(assetService.addAssetTags).mockResolvedValueOnce({ + added: ['featured'], + total_tags: ['models', 'featured'] + }) + + await store.updateAssetTags( + asset, + ['models', 'featured'], + 'CheckpointLoaderSimple' + ) + + expect(vi.mocked(assetService.addAssetTags)).toHaveBeenCalledWith( + 'tags-add-only', + ['featured'] + ) + expect(vi.mocked(assetService.removeAssetTags)).not.toHaveBeenCalled() + expect(store.getAssets('CheckpointLoaderSimple')[0].tags).toEqual([ + 'models', + 'featured' + ]) + }) + + it('rolls back the cache when removeAssetTags succeeds but addAssetTags rejects', async () => { + // Documents the known recovery gap on partial-failure during a + // "change category" mutation: remove succeeds server-side, add fails, + // and the cache is restored to the original tags. The server now has + // the old category tag removed, so the cache and backend diverge until + // the next refetch — surface that gap here rather than papering over it. + const store = useAssetsStore() + const asset = createMockAsset('tags-partial-fail', ['models', 'loras']) + + vi.mocked(assetService.getAssetsForNodeType).mockResolvedValueOnce([ + asset + ]) + await store.updateModelsForNodeType('LoraLoader') + + vi.mocked(assetService.removeAssetTags).mockResolvedValueOnce({ + removed: ['loras'], + total_tags: ['models'] + }) + vi.mocked(assetService.addAssetTags).mockRejectedValueOnce( + new Error('500 add failed') + ) + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + await store.updateAssetTags( + asset, + ['models', 'checkpoints'], + 'LoraLoader' + ) + + expect(vi.mocked(assetService.removeAssetTags)).toHaveBeenCalledWith( + 'tags-partial-fail', + ['loras'] + ) + expect(vi.mocked(assetService.addAssetTags)).toHaveBeenCalledWith( + 'tags-partial-fail', + ['checkpoints'] + ) + // Cache restored to original tags even though the server has already + // removed 'loras'. This codifies a known divergence — fix the recovery + // semantics in updateAssetTags to address it (e.g. invalidate the + // category cache, or reconcile against the last confirmed total_tags). + expect(store.getAssets('LoraLoader')[0].tags).toEqual(['models', 'loras']) + consoleSpy.mockRestore() + }) + }) +}) + +describe('assetsStore - Deletion State and Input Mapping', () => { + beforeEach(() => { + setActivePinia(createTestingPinia({ stubActions: false })) + vi.clearAllMocks() + }) + + describe('setAssetDeleting / isAssetDeleting', () => { + it('tracks per-asset deletion state and clears it on flip', () => { + const store = useAssetsStore() + + expect(store.isAssetDeleting('asset-A')).toBe(false) + + store.setAssetDeleting('asset-A', true) + expect(store.isAssetDeleting('asset-A')).toBe(true) + expect(store.isAssetDeleting('asset-B')).toBe(false) + + store.setAssetDeleting('asset-A', false) + expect(store.isAssetDeleting('asset-A')).toBe(false) + }) + }) + + describe('getInputName', () => { + it('resolves a hashed filename to the human-readable name when the input asset is in the cache', async () => { + mockIsCloud.value = true + try { + setActivePinia(createTestingPinia({ stubActions: false })) + const store = useAssetsStore() + + vi.mocked(assetService.getAssetsByTag).mockResolvedValueOnce([ + { + id: 'input-1', + name: 'cute-puppy.png', + asset_hash: 'abc123def.png', + tags: ['input'] + } + ]) + await store.updateInputs() + + expect(store.getInputName('abc123def.png')).toBe('cute-puppy.png') + } finally { + mockIsCloud.value = false + } + }) + + it('falls back to the original filename when the input asset is not cached', () => { + const store = useAssetsStore() + expect(store.getInputName('unknown.png')).toBe('unknown.png') + }) + }) + + describe('updateInputs cloud routing', () => { + it('reads from assetService.getAssetsByTag with limit 100 when isCloud is true', async () => { + mockIsCloud.value = true + try { + setActivePinia(createTestingPinia({ stubActions: false })) + const store = useAssetsStore() + + vi.mocked(assetService.getAssetsByTag).mockResolvedValueOnce([]) + await store.updateInputs() + + expect(vi.mocked(assetService.getAssetsByTag)).toHaveBeenCalledWith( + 'input', + false, + { limit: 100 } + ) + expect( + assetService.invalidateInputAssetsIncludingPublic + ).toHaveBeenCalledOnce() + } finally { + mockIsCloud.value = false + } + }) + }) }) diff --git a/src/stores/assetsStore.ts b/src/stores/assetsStore.ts index df655a3b3b..34f9910e94 100644 --- a/src/stores/assetsStore.ts +++ b/src/stores/assetsStore.ts @@ -123,7 +123,7 @@ export const useAssetsStore = defineStore('assets', () => { state: inputAssets, isLoading: inputLoading, error: inputError, - execute: updateInputs + execute: executeUpdateInputs } = useAsyncState(fetchInputFiles, [], { immediate: false, resetOnExecute: false, @@ -132,6 +132,12 @@ export const useAssetsStore = defineStore('assets', () => { } }) + const updateInputs = async () => { + const result = await executeUpdateInputs() + assetService.invalidateInputAssetsIncludingPublic() + return result + } + /** * Fetch history assets with pagination support * @param loadMore - true for pagination (append), false for initial load (replace) diff --git a/src/stores/executionStore.test.ts b/src/stores/executionStore.test.ts index 535667cf9c..dc143d844f 100644 --- a/src/stores/executionStore.test.ts +++ b/src/stores/executionStore.test.ts @@ -754,3 +754,301 @@ describe('useMissingNodesErrorStore - setMissingNodeTypes', () => { expect(store.missingNodesError?.nodeTypes).toEqual(input) }) }) + +describe('useExecutionStore - WebSocket event handlers', () => { + let store: ReturnType<typeof useExecutionStore> + + function fire<T>(event: string, detail: T) { + const handler = apiEventHandlers.get(event) + if (!handler) throw new Error(`${event} handler not bound`) + handler(new CustomEvent(event, { detail })) + } + + beforeEach(() => { + vi.clearAllMocks() + apiEventHandlers.clear() + setActivePinia(createTestingPinia({ stubActions: false })) + store = useExecutionStore() + store.bindExecutionEvents() + }) + + describe('execution_start', () => { + it('sets activeJobId and seeds an empty queued job entry', () => { + fire('execution_start', { prompt_id: 'job-1', timestamp: 0 }) + + expect(store.activeJobId).toBe('job-1') + expect(store.queuedJobs['job-1']).toEqual({ nodes: {} }) + }) + + it('clears initializing state for the starting job', () => { + store.initializingJobIds = new Set([ + 'job-1', + 'job-2' + ]) as unknown as Set<string> + fire('execution_start', { prompt_id: 'job-1', timestamp: 0 }) + + expect(store.initializingJobIds.has('job-1')).toBe(false) + expect(store.initializingJobIds.has('job-2')).toBe(true) + }) + }) + + describe('execution_cached', () => { + it('marks the listed nodes as cached on the active job', () => { + fire('execution_start', { prompt_id: 'job-1', timestamp: 0 }) + + fire('execution_cached', { + prompt_id: 'job-1', + nodes: ['nodeA', 'nodeB'], + timestamp: 0 + }) + + expect(store.activeJob?.nodes).toEqual({ nodeA: true, nodeB: true }) + }) + + it('is a no-op when no active job exists', () => { + fire('execution_cached', { + prompt_id: 'job-1', + nodes: ['nodeA'], + timestamp: 0 + }) + + expect(store.activeJob).toBeUndefined() + }) + }) + + describe('execution_interrupted', () => { + it('clears active job state on interrupt', () => { + fire('execution_start', { prompt_id: 'job-1', timestamp: 0 }) + expect(store.activeJobId).toBe('job-1') + + fire('execution_interrupted', { + prompt_id: 'job-1', + node_id: 'n1', + node_type: 't', + executed: [], + timestamp: 0 + }) + + expect(store.activeJobId).toBeNull() + expect(store.queuedJobs['job-1']).toBeUndefined() + }) + }) + + describe('executed', () => { + it('marks the executed node as done on the active job', () => { + fire('execution_start', { prompt_id: 'job-1', timestamp: 0 }) + fire('execution_cached', { + prompt_id: 'job-1', + nodes: ['n1'], + timestamp: 0 + }) + + fire('executed', { + node: 'n1', + display_node: 'n1', + prompt_id: 'job-1', + output: {} + }) + + expect(store.activeJob?.nodes['n1']).toBe(true) + }) + + it('is a no-op when no active job exists', () => { + expect(() => + fire('executed', { + node: 'n1', + display_node: 'n1', + prompt_id: 'orphan', + output: {} + }) + ).not.toThrow() + expect(store.activeJob).toBeUndefined() + }) + }) + + describe('execution_success', () => { + it('clears active job and progress state', () => { + fire('execution_start', { prompt_id: 'job-1', timestamp: 0 }) + + fire('execution_success', { prompt_id: 'job-1', timestamp: 0 }) + + expect(store.activeJobId).toBeNull() + expect(store.queuedJobs['job-1']).toBeUndefined() + }) + }) + + describe('executing', () => { + it('clears _executingNodeProgress and activeJobId when detail is null', () => { + fire('execution_start', { prompt_id: 'job-1', timestamp: 0 }) + store._executingNodeProgress = { + value: 1, + max: 2, + prompt_id: 'job-1', + node: '1' + } + + fire('executing', null) + + expect(store._executingNodeProgress).toBeNull() + expect(store.activeJobId).toBeNull() + }) + }) + + describe('progress', () => { + it('sets _executingNodeProgress from the event payload', () => { + const payload = { value: 3, max: 10, prompt_id: 'job-1', node: 'n1' } + + fire('progress', payload) + + expect(store._executingNodeProgress).toEqual(payload) + }) + }) + + describe('status', () => { + it('reads clientId from api once and stops listening', async () => { + const apiModule = await import('@/scripts/api') + const removeSpy = vi.mocked(apiModule.api.removeEventListener) + + fire('status', { exec_info: { queue_remaining: 0 } }) + + expect(store.clientId).toBe('test-client') + expect(removeSpy).toHaveBeenCalledWith('status', expect.any(Function)) + }) + }) + + describe('execution_error', () => { + it('routes a service-level error (no node_id) to the prompt error store', () => { + const errorStore = useExecutionErrorStore() + + fire('execution_error', { + prompt_id: 'job-1', + node_id: null, + exception_type: 'StagnationError', + exception_message: 'Job has stagnated', + traceback: ['line 1', 'line 2'] + }) + + expect(errorStore.lastPromptError).toMatchObject({ + type: 'StagnationError', + message: 'StagnationError: Job has stagnated', + details: 'line 1\nline 2' + }) + }) + + it('routes a runtime error (with node_id) to lastExecutionError', () => { + const errorStore = useExecutionErrorStore() + + fire('execution_error', { + prompt_id: 'job-1', + node_id: 'n1', + node_type: 'KSampler', + exception_type: 'RuntimeError', + exception_message: 'CUDA OOM', + traceback: [] + }) + + expect(errorStore.lastExecutionError).toMatchObject({ + prompt_id: 'job-1', + node_id: 'n1', + exception_message: 'CUDA OOM' + }) + }) + }) + + describe('notification', () => { + it('marks a job as initializing when text indicates waiting for a machine', () => { + fire('notification', { + id: 'job-9', + value: 'Waiting for a machine to become available' + }) + + expect(store.initializingJobIds.has('job-9')).toBe(true) + }) + + it('ignores notifications without an id', () => { + fire('notification', { + id: '', + value: 'Waiting for a machine' + }) + + expect(store.initializingJobIds.size).toBe(0) + }) + + it('ignores notifications without the waiting-for-machine sentinel', () => { + fire('notification', { id: 'job-9', value: 'Hello' }) + + expect(store.initializingJobIds.has('job-9')).toBe(false) + }) + }) + + describe('unbindExecutionEvents', () => { + it('removes every listener registered by bindExecutionEvents', async () => { + const apiModule = await import('@/scripts/api') + const removeSpy = vi.mocked(apiModule.api.removeEventListener) + const events = [ + 'notification', + 'execution_start', + 'execution_cached', + 'execution_interrupted', + 'execution_success', + 'executed', + 'executing', + 'progress', + 'progress_state', + 'execution_error', + 'progress_text' + ] + + store.unbindExecutionEvents() + + for (const event of events) { + expect(removeSpy).toHaveBeenCalledWith(event, expect.any(Function)) + } + }) + }) +}) + +describe('useExecutionStore - storeJob and workflow path tracking', () => { + let store: ReturnType<typeof useExecutionStore> + + beforeEach(() => { + vi.clearAllMocks() + apiEventHandlers.clear() + setActivePinia(createTestingPinia({ stubActions: false })) + store = useExecutionStore() + }) + + it('storeJob populates queuedJobs and tracks the workflow path', () => { + const workflow = { + activeState: { id: 'wf-1' }, + initialState: { id: 'wf-1' }, + path: '/workflows/foo.json' + } as unknown as Parameters<typeof store.storeJob>[0]['workflow'] + + store.storeJob({ nodes: ['a', 'b'], id: 'job-1', workflow }) + + expect(store.queuedJobs['job-1']?.nodes).toEqual({ a: false, b: false }) + expect(store.queuedJobs['job-1']?.workflow).toStrictEqual(workflow) + expect(store.jobIdToWorkflowId.get('job-1')).toBe('wf-1') + expect(store.jobIdToSessionWorkflowPath.get('job-1')).toBe( + '/workflows/foo.json' + ) + }) + + it('registerJobWorkflowIdMapping ignores empty inputs', () => { + store.registerJobWorkflowIdMapping('job-1', 'wf-1') + store.registerJobWorkflowIdMapping('', 'wf-2') + store.registerJobWorkflowIdMapping('job-2', '') + + expect(store.jobIdToWorkflowId.get('job-1')).toBe('wf-1') + expect(store.jobIdToWorkflowId.size).toBe(1) + }) + + it('ensureSessionWorkflowPath is idempotent and updates on change', () => { + store.ensureSessionWorkflowPath('job-1', '/a.json') + store.ensureSessionWorkflowPath('job-1', '/a.json') + store.ensureSessionWorkflowPath('job-1', '/b.json') + + expect(store.jobIdToSessionWorkflowPath.get('job-1')).toBe('/b.json') + }) +}) diff --git a/src/stores/executionStore.ts b/src/stores/executionStore.ts index f69490c7a6..1df4702858 100644 --- a/src/stores/executionStore.ts +++ b/src/stores/executionStore.ts @@ -9,7 +9,8 @@ import { useWorkflowStore } from '@/platform/workflow/management/stores/workflow import type { ComfyNode, ComfyWorkflowJSON, - NodeId + NodeId, + WorkflowId } from '@/platform/workflow/validation/schemas/workflowSchema' import { useCanvasStore } from '@/renderer/core/canvas/canvasStore' import type { @@ -19,6 +20,7 @@ import type { ExecutionInterruptedWsMessage, ExecutionStartWsMessage, ExecutionSuccessWsMessage, + JobId, NodeProgressState, NotificationWsMessage, ProgressStateWsMessage, @@ -59,26 +61,26 @@ export const useExecutionStore = defineStore('execution', () => { const executionErrorStore = useExecutionErrorStore() const clientId = ref<string | null>(null) - const activeJobId = ref<string | null>(null) + const activeJobId = ref<JobId | null>(null) const queuedJobs = ref<Record<NodeId, QueuedJob>>({}) // This is the progress of all nodes in the currently executing workflow const nodeProgressStates = ref<Record<string, NodeProgressState>>({}) const nodeProgressStatesByJob = ref< - Record<string, Record<string, NodeProgressState>> + Record<JobId, Record<string, NodeProgressState>> >({}) /** * Map of job ID to workflow ID for quick lookup across the app. */ - const jobIdToWorkflowId = ref<Map<string, string>>(new Map()) + const jobIdToWorkflowId = ref<Map<JobId, WorkflowId>>(new Map()) /** * Map of job ID to workflow file path in the current session. * Only populated for jobs that are queued in this browser tab. */ - const jobIdToSessionWorkflowPath = shallowRef<Map<string, string>>(new Map()) + const jobIdToSessionWorkflowPath = shallowRef<Map<JobId, string>>(new Map()) - const initializingJobIds = ref<Set<string>>(new Set()) + const initializingJobIds = ref<Set<JobId>>(new Set()) /** * Cache for executionIdToNodeLocatorId lookups. @@ -456,7 +458,7 @@ export const useExecutionStore = defineStore('execution', () => { } } - function clearInitializationByJobId(jobId: string | null) { + function clearInitializationByJobId(jobId: JobId | null) { if (!jobId) return if (!initializingJobIds.value.has(jobId)) return const next = new Set(initializingJobIds.value) @@ -464,7 +466,7 @@ export const useExecutionStore = defineStore('execution', () => { initializingJobIds.value = next } - function clearInitializationByJobIds(jobIds: string[]) { + function clearInitializationByJobIds(jobIds: JobId[]) { if (!jobIds.length) return const current = initializingJobIds.value const toRemove = jobIds.filter((id) => current.has(id)) @@ -476,14 +478,14 @@ export const useExecutionStore = defineStore('execution', () => { initializingJobIds.value = next } - function reconcileInitializingJobs(activeJobIds: Set<string>) { + function reconcileInitializingJobs(activeJobIds: Set<JobId>) { const orphaned = [...initializingJobIds.value].filter( (id) => !activeJobIds.has(id) ) clearInitializationByJobIds(orphaned) } - function isJobInitializing(jobId: string | number | undefined): boolean { + function isJobInitializing(jobId: JobId | number | undefined): boolean { if (!jobId) return false return initializingJobIds.value.has(String(jobId)) } @@ -491,7 +493,7 @@ export const useExecutionStore = defineStore('execution', () => { /** * Reset execution-related state after a run completes or is stopped. */ - function resetExecutionState(jobIdParam?: string | null) { + function resetExecutionState(jobIdParam?: JobId | null) { executionIdToLocatorCache.clear() nodeProgressStates.value = {} const jobId = jobIdParam ?? activeJobId.value ?? null @@ -539,7 +541,7 @@ export const useExecutionStore = defineStore('execution', () => { workflow }: { nodes: string[] - id: string + id: JobId workflow: ComfyWorkflow }) { queuedJobs.value[id] ??= { nodes: {} } @@ -554,17 +556,17 @@ export const useExecutionStore = defineStore('execution', () => { queuedJob.workflow = workflow const wid = workflow?.activeState?.id ?? workflow?.initialState?.id if (wid) { - jobIdToWorkflowId.value.set(String(id), String(wid)) + jobIdToWorkflowId.value.set(id, wid) } if (workflow?.path) { - ensureSessionWorkflowPath(String(id), workflow.path) + ensureSessionWorkflowPath(id, workflow.path) } } // ~0.65 MB at capacity (32 char GUID key + 50 char path value) const MAX_SESSION_PATH_ENTRIES = 4000 - function ensureSessionWorkflowPath(jobId: string, path: string) { + function ensureSessionWorkflowPath(jobId: JobId, path: string) { if (jobIdToSessionWorkflowPath.value.get(jobId) === path) return const next = new Map(jobIdToSessionWorkflowPath.value) next.set(jobId, path) @@ -579,9 +581,9 @@ export const useExecutionStore = defineStore('execution', () => { /** * Register or update a mapping from job ID to workflow ID. */ - function registerJobWorkflowIdMapping(jobId: string, workflowId: string) { + function registerJobWorkflowIdMapping(jobId: JobId, workflowId: WorkflowId) { if (!jobId || !workflowId) return - jobIdToWorkflowId.value.set(String(jobId), String(workflowId)) + jobIdToWorkflowId.value.set(jobId, workflowId) } /** @@ -596,8 +598,8 @@ export const useExecutionStore = defineStore('execution', () => { return executionId } - const runningJobIds = computed<string[]>(() => { - const result: string[] = [] + const runningJobIds = computed<JobId[]>(() => { + const result: JobId[] = [] for (const [pid, nodes] of Object.entries(nodeProgressStatesByJob.value)) { if (Object.values(nodes).some((n) => n.state === 'running')) { result.push(pid) diff --git a/src/stores/jobPreviewStore.ts b/src/stores/jobPreviewStore.ts index f7dbe3c374..e214b68af9 100644 --- a/src/stores/jobPreviewStore.ts +++ b/src/stores/jobPreviewStore.ts @@ -2,12 +2,13 @@ import { defineStore } from 'pinia' import { computed, readonly, ref, watch } from 'vue' import { useSettingStore } from '@/platform/settings/settingStore' +import type { JobId } from '@/schemas/apiSchema' import { releaseSharedObjectUrl, retainSharedObjectUrl } from '@/utils/objectUrlUtil' -type PromptPreviewMap = Record<string, string> +type PromptPreviewMap = Record<JobId, string> interface NodePromptPreview { url: string nodeId?: string @@ -15,7 +16,7 @@ interface NodePromptPreview { export const useJobPreviewStore = defineStore('jobPreview', () => { const settingStore = useSettingStore() - const nodePreviewsByPromptId = ref<Record<string, NodePromptPreview>>({}) + const nodePreviewsByPromptId = ref<Record<JobId, NodePromptPreview>>({}) const previewMethod = computed(() => settingStore.get('Comfy.Execution.PreviewMethod') @@ -31,7 +32,7 @@ export const useJobPreviewStore = defineStore('jobPreview', () => { }) function setPreviewUrl( - promptId: string | undefined, + promptId: JobId | undefined, url: string, nodeId?: string ) { @@ -46,7 +47,7 @@ export const useJobPreviewStore = defineStore('jobPreview', () => { } } - function clearPreview(promptId: string | undefined) { + function clearPreview(promptId: JobId | undefined) { if (!promptId) return const current = nodePreviewsByPromptId.value[promptId] if (!current) return diff --git a/src/stores/modelToNodeStore.test.ts b/src/stores/modelToNodeStore.test.ts index 563b0fa00b..d5725190d3 100644 --- a/src/stores/modelToNodeStore.test.ts +++ b/src/stores/modelToNodeStore.test.ts @@ -596,8 +596,8 @@ describe('useModelToNodeStore', () => { } const end = performance.now() - // Should be fast enough for UI responsiveness - expect(end - start).toBeLessThan(10) + // Should be fast enough for UI responsiveness (O(1) map lookup) + expect(end - start).toBeLessThan(100) }) it('should handle invalid input types gracefully', () => { diff --git a/src/types/authTypes.ts b/src/types/authTypes.ts index 5f3891bd5e..549cd01b86 100644 --- a/src/types/authTypes.ts +++ b/src/types/authTypes.ts @@ -8,6 +8,15 @@ export type ApiKeyAuthHeader = { export type AuthHeader = LoggedInAuthHeader | ApiKeyAuthHeader +/** + * Identifier for an authenticated user. + * + * Backed by the `id` claim returned from the auth provider, which is always + * a string. This alias names that primitive at use sites (auth store, + * workspace member APIs) without changing structural typing. + */ +export type UserId = string + export interface AuthUserInfo { - id: string + id: UserId } diff --git a/src/types/litegraph-augmentation.d.ts b/src/types/litegraph-augmentation.d.ts index 7d3d6e2420..8ee82a1d42 100644 --- a/src/types/litegraph-augmentation.d.ts +++ b/src/types/litegraph-augmentation.d.ts @@ -144,13 +144,9 @@ declare module '@/lib/litegraph/src/litegraph' { * Callback invoked when the node is dropped from an external source, i.e. * a file or another HTML element. * @param e The drag event - * @param claimEvent If true, the handler should call preventDefault and - * stopPropagation synchronously before any await once it has decided to - * accept the drop, so bubbling fallback handlers know not to also process - * the event. * @returns {boolean} True if the drag event should be handled by this node, false otherwise */ - onDragDrop?(e: DragEvent, claimEvent?: boolean): Promise<boolean> | boolean + onDragDrop?(e: DragEvent): Promise<boolean> | boolean index?: number runningInternalNodeId?: NodeId diff --git a/src/utils/colorUtil.test.ts b/src/utils/colorUtil.test.ts index 38bba99517..b0c9672f6f 100644 --- a/src/utils/colorUtil.test.ts +++ b/src/utils/colorUtil.test.ts @@ -8,8 +8,10 @@ import { hexToRgb, hsbToRgb, hsvaToHex, + isTransparent, parseToRgb, - rgbToHex + rgbToHex, + toHexFromFormat } from '@/utils/colorUtil' interface ColorTestCase { @@ -207,6 +209,80 @@ describe('colorUtil conversions', () => { expect(hsva.a).toBe(53) expect(hsvaToHex(hsva)).toMatch(/^#ff0000/) }) + + // Note: a round-trip test for non-primary palette colors (e.g. #80c0ff) + // is intentionally NOT included here. The current conversion path drifts + // by 1 channel (hsbToRgb floors, rgbToHex rounds), so encoding that drift + // as a passing assertion would block fixing the underlying user-visible + // ColorPicker bug. Track the source-side fix separately. + }) + + describe('parseToRgb edge cases', () => { + it.each(['', 'not-a-color', '#GGGGGG', 'cmky(1,2,3,4)'])( + 'returns black for unrecognized input %s', + (input) => { + expect(parseToRgb(input)).toEqual({ r: 0, g: 0, b: 0 }) + } + ) + + it('parses 4-digit hex with alpha and ignores the alpha channel in RGB output', () => { + // #f008 == #ff0000 with 53% alpha; parseToRgb returns RGB only. + expect(parseToRgb('#f008')).toEqual({ r: 255, g: 0, b: 0 }) + }) + + it('parses 8-digit hex and ignores the alpha channel in RGB output', () => { + expect(parseToRgb('#ff000080')).toEqual({ r: 255, g: 0, b: 0 }) + }) + }) + + describe('hsbToRgb normalization', () => { + it('normalizes negative hue', () => { + // h = -120 should map to h = 240 (blue) when s and b are both 100. + expect(hsbToRgb({ h: -120, s: 100, b: 100 })).toEqual({ + r: 0, + g: 0, + b: 255 + }) + }) + }) + + describe('isTransparent', () => { + it('returns true for the literal "transparent" keyword', () => { + expect(isTransparent('transparent')).toBe(true) + }) + + it('returns true for 5-char hex with zero alpha', () => { + expect(isTransparent('#abc0')).toBe(true) + }) + + it('returns true for 9-char hex with zero alpha', () => { + expect(isTransparent('#abcdef00')).toBe(true) + }) + + it('returns false for fully opaque hex colors', () => { + expect(isTransparent('#ff0000')).toBe(false) + expect(isTransparent('#ff0000ff')).toBe(false) + }) + }) + + describe('toHexFromFormat', () => { + it('treats an HSV object (with v field) the same as an HSB object', () => { + const hsbObject = { h: 120, s: 100, b: 100 } + const hsvObject = { h: 120, s: 100, v: 100 } + + expect(toHexFromFormat(hsvObject, 'hsb')).toBe( + toHexFromFormat(hsbObject, 'hsb') + ) + expect(toHexFromFormat(hsvObject, 'hsb')).toBe('#00ff00') + }) + + it('returns #000000 for unparseable hsb input', () => { + expect(toHexFromFormat({ h: 0 }, 'hsb')).toBe('#000000') + }) + + it('prefixes a bare 6-digit hex with #', () => { + expect(toHexFromFormat('abcdef', 'hex')).toBe('#abcdef') + }) }) }) describe('colorUtil - adjustColor', () => { diff --git a/src/utils/createAnnotatedPath.ts b/src/utils/createAnnotatedPath.ts index c178d19646..d167862ffa 100644 --- a/src/utils/createAnnotatedPath.ts +++ b/src/utils/createAnnotatedPath.ts @@ -18,6 +18,6 @@ export function createAnnotatedPath( if (typeof item === 'string') return `${createPath(item, subfolder)}${createAnnotation(item, rootFolder)}` return `${createPath(item.filename ?? '', item.subfolder)}${ - item.type ? createAnnotation(item.type, rootFolder) : '' + item.type && item.type !== rootFolder ? ` [${item.type}]` : '' }` } diff --git a/src/utils/errorUtil.test.ts b/src/utils/errorUtil.test.ts new file mode 100644 index 0000000000..053181ff41 --- /dev/null +++ b/src/utils/errorUtil.test.ts @@ -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() + }) +}) diff --git a/src/utils/errorUtil.ts b/src/utils/errorUtil.ts new file mode 100644 index 0000000000..ee8c95450e --- /dev/null +++ b/src/utils/errorUtil.ts @@ -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 +} diff --git a/src/utils/linkFixer.test.ts b/src/utils/linkFixer.test.ts new file mode 100644 index 0000000000..67df6668cd --- /dev/null +++ b/src/utils/linkFixer.test.ts @@ -0,0 +1,281 @@ +import { describe, expect, it, vi } from 'vitest' + +import type { SerialisedLLinkArray } from '@/lib/litegraph/src/LLink' +import type { + ISerialisedGraph, + ISerialisedNode +} from '@/lib/litegraph/src/types/serialisation' + +import { fixBadLinks } from './linkFixer' + +type SerialisedInput = NonNullable<ISerialisedNode['inputs']>[number] +type SerialisedOutput = NonNullable<ISerialisedNode['outputs']>[number] + +function createInput(link: number | null): SerialisedInput { + return { + name: 'input', + type: '*', + link + } satisfies Partial<SerialisedInput> as SerialisedInput +} + +function createOutput(links: number[]): SerialisedOutput { + return { + name: 'output', + type: '*', + links + } satisfies Partial<SerialisedOutput> as SerialisedOutput +} + +function createNode({ + id, + inputs = [], + outputs = [] +}: { + id: number + inputs?: SerialisedInput[] + outputs?: SerialisedOutput[] +}): ISerialisedNode { + return { + id, + type: 'TestNode', + pos: [0, 0], + size: [100, 100], + flags: {}, + order: 0, + mode: 0, + inputs, + outputs + } +} + +function createGraph({ + nodes, + links +}: { + nodes: ISerialisedNode[] + links: SerialisedLLinkArray[] +}): ISerialisedGraph { + return { + id: 'b4e984f1-b421-4d24-b8b4-ff895793af13', + revision: 0, + version: 0.4, + last_node_id: Math.max(...nodes.map((node) => Number(node.id)), 0), + last_link_id: Math.max(...links.map((link) => link[0]), 0), + nodes, + links, + groups: [] + } +} + +describe('fixBadLinks', () => { + it('leaves a valid serialized graph unchanged', () => { + const graph = createGraph({ + nodes: [ + createNode({ id: 1, outputs: [createOutput([1])] }), + createNode({ id: 2, inputs: [createInput(1)] }) + ], + links: [[1, 1, 0, 2, 0, '*']] + }) + const logger = { log: vi.fn() } + + const result = fixBadLinks(graph, { logger }) + + expect(result).toMatchObject({ + hasBadLinks: false, + fixed: false, + patched: 0, + deleted: 0 + }) + expect(graph.nodes[0]?.outputs?.[0]?.links).toEqual([1]) + expect(graph.nodes[1]?.inputs?.[0]?.link).toBe(1) + expect(logger.log).not.toHaveBeenCalled() + }) + + it('reports a missing origin output link during a dry run', () => { + const graph = createGraph({ + nodes: [ + createNode({ id: 1, outputs: [createOutput([])] }), + createNode({ id: 2, inputs: [createInput(1)] }) + ], + links: [[1, 1, 0, 2, 0, '*']] + }) + const logger = { log: vi.fn() } + + const result = fixBadLinks(graph, { logger }) + + expect(result).toMatchObject({ + hasBadLinks: true, + fixed: false, + patched: 1, + deleted: 0 + }) + expect(graph.nodes[0]?.outputs?.[0]?.links).toEqual([]) + expect(graph.nodes[1]?.inputs?.[0]?.link).toBe(1) + expect(logger.log).toHaveBeenCalled() + }) + + it('adds a missing origin output link in fix mode', () => { + const graph = createGraph({ + nodes: [ + createNode({ id: 1, outputs: [createOutput([])] }), + createNode({ id: 2, inputs: [createInput(1)] }) + ], + links: [[1, 1, 0, 2, 0, '*']] + }) + + const result = fixBadLinks(graph, { fix: true }) + + expect(result).toMatchObject({ + hasBadLinks: false, + fixed: true, + patched: 1, + deleted: 0 + }) + expect(graph.nodes[0]?.outputs?.[0]?.links).toEqual([1]) + expect(graph.nodes[1]?.inputs?.[0]?.link).toBe(1) + }) + + it('sets a missing target input link in fix mode', () => { + const graph = createGraph({ + nodes: [ + createNode({ id: 1, outputs: [createOutput([1])] }), + createNode({ id: 2, inputs: [createInput(null)] }) + ], + links: [[1, 1, 0, 2, 0, '*']] + }) + + const result = fixBadLinks(graph, { fix: true }) + + expect(result).toMatchObject({ + hasBadLinks: false, + fixed: true, + patched: 1, + deleted: 0 + }) + expect(graph.nodes[0]?.outputs?.[0]?.links).toEqual([1]) + expect(graph.nodes[1]?.inputs?.[0]?.link).toBe(1) + }) + + it('removes the origin reference when the target input slot is missing', () => { + const graph = createGraph({ + nodes: [ + createNode({ id: 1, outputs: [createOutput([1])] }), + createNode({ id: 2 }) + ], + links: [[1, 1, 0, 2, 0, '*']] + }) + + const result = fixBadLinks(graph, { fix: true }) + + expect(result).toMatchObject({ + hasBadLinks: false, + fixed: true, + patched: 1, + deleted: 1 + }) + expect(graph.nodes[0]?.outputs?.[0]?.links).toEqual([]) + expect(graph.nodes[1]?.inputs).toEqual([]) + expect(graph.links).toEqual([]) + }) + + it('removes a stale origin reference instead of overwriting another target link', () => { + const graph = createGraph({ + nodes: [ + createNode({ id: 1, outputs: [createOutput([1])] }), + createNode({ id: 2, inputs: [createInput(2)] }) + ], + links: [[1, 1, 0, 2, 0, '*']] + }) + + const result = fixBadLinks(graph, { fix: true }) + + expect(result).toMatchObject({ + hasBadLinks: false, + fixed: true, + patched: 1, + deleted: 1 + }) + expect(graph.nodes[0]?.outputs?.[0]?.links).toEqual([]) + expect(graph.nodes[1]?.inputs?.[0]?.link).toBe(2) + expect(graph.links).toEqual([]) + }) + + it('cleans dangling references when a linked node is missing', () => { + const graph = createGraph({ + nodes: [createNode({ id: 2, inputs: [createInput(1)] })], + links: [[1, 1, 0, 2, 0, '*']] + }) + + const result = fixBadLinks(graph, { fix: true }) + + expect(result).toMatchObject({ + hasBadLinks: false, + fixed: true, + patched: 1, + deleted: 1 + }) + expect(graph.nodes[0]?.inputs?.[0]?.link).toBeNull() + expect(graph.links).toEqual([]) + }) + + it('cleans dangling origin references when the target node is missing', () => { + const graph = createGraph({ + nodes: [createNode({ id: 1, outputs: [createOutput([1])] })], + links: [[1, 1, 0, 2, 0, '*']] + }) + + const result = fixBadLinks(graph, { fix: true }) + + expect(result).toMatchObject({ + hasBadLinks: false, + fixed: true, + patched: 1, + deleted: 1 + }) + expect(graph.nodes[0]?.outputs?.[0]?.links).toEqual([]) + expect(graph.links).toEqual([]) + }) + + it('deletes a stale link that neither endpoint references', () => { + const graph = createGraph({ + nodes: [ + createNode({ id: 1, outputs: [createOutput([])] }), + createNode({ id: 2, inputs: [createInput(null)] }) + ], + links: [[1, 1, 0, 2, 0, '*']] + }) + + const result = fixBadLinks(graph, { fix: true }) + + expect(result).toMatchObject({ + hasBadLinks: false, + fixed: true, + patched: 0, + deleted: 1 + }) + expect(graph.links).toEqual([]) + }) + + it('suppresses logger calls in silent mode while still applying fixes', () => { + const graph = createGraph({ + nodes: [ + createNode({ id: 1, outputs: [createOutput([])] }), + createNode({ id: 2, inputs: [createInput(1)] }) + ], + links: [[1, 1, 0, 2, 0, '*']] + }) + const logger = { log: vi.fn() } + + const result = fixBadLinks(graph, { fix: true, silent: true, logger }) + + expect(result).toMatchObject({ + hasBadLinks: false, + fixed: true, + patched: 1, + deleted: 0 + }) + expect(graph.nodes[0]?.outputs?.[0]?.links).toEqual([1]) + expect(logger.log).not.toHaveBeenCalled() + }) +}) diff --git a/src/utils/linkFixer.ts b/src/utils/linkFixer.ts index 065934224d..9eb3144a74 100644 --- a/src/utils/linkFixer.ts +++ b/src/utils/linkFixer.ts @@ -136,9 +136,16 @@ export function fixBadLinks( return false } const linkIdToSet = op === 'REMOVE' ? null : linkId + const inputSlot = node.inputs?.[slot] + if (fix && !inputSlot) { + logger.log( + ` > Cannot patch ${node.id}.inputs[${slot}] because the input slot is missing.` + ) + return false + } patchedNode['inputs']![slot] = linkIdToSet if (fix) { - // node.inputs[slot]!.link = linkIdToSet; + inputSlot!.link = linkIdToSet } } else { patchedNode['outputs'] = patchedNode['outputs'] || {} @@ -461,18 +468,17 @@ export function fixBadLinks( } stale link removals.` ) - let hasBadLinks: boolean = !!( - data.patchedNodes.length || data.deletedLinks.length - ) + const hasChanges = !!(data.patchedNodes.length || data.deletedLinks.length) + let hasBadLinks: boolean = hasChanges // If we're fixing, then let's run it again to see if there are no more bad links. - if (fix && !silent) { + if (fix) { const rerun = fixBadLinks(graph, { fix: false, silent: true }) hasBadLinks = rerun.hasBadLinks } return { hasBadLinks, - fixed: !!hasBadLinks && fix, + fixed: fix && hasChanges && !hasBadLinks, graph, patched: data.patchedNodes.length, deleted: data.deletedLinks.length diff --git a/src/views/LinearView.vue b/src/views/LinearView.vue index d1000619f1..09a041330d 100644 --- a/src/views/LinearView.vue +++ b/src/views/LinearView.vue @@ -94,9 +94,7 @@ const linearWorkflowRef = useTemplateRef('linearWorkflowRef') function dragDrop(e: DragEvent) { const { dataTransfer } = e - if (!dataTransfer) return - - linearWorkflowRef.value?.handleDragDrop(e) + if (dataTransfer) linearWorkflowRef.value?.handleDragDrop() } </script> <template> diff --git a/src/workbench/extensions/manager/stores/comfyManagerStore.ts b/src/workbench/extensions/manager/stores/comfyManagerStore.ts index c6ae8a8e3a..98c7a25548 100644 --- a/src/workbench/extensions/manager/stores/comfyManagerStore.ts +++ b/src/workbench/extensions/manager/stores/comfyManagerStore.ts @@ -12,7 +12,10 @@ import { app } from '@/scripts/app' import { normalizePackKeys } from '@/utils/packUtils' import { useManagerQueue } from '@/workbench/extensions/manager/composables/useManagerQueue' import { useComfyManagerService } from '@/workbench/extensions/manager/services/comfyManagerService' -import type { TaskLog } from '@/workbench/extensions/manager/types/comfyManagerTypes' +import type { + NodePackId, + TaskLog +} from '@/workbench/extensions/manager/types/comfyManagerTypes' import type { components } from '@/workbench/extensions/manager/types/generatedManagerTypes' type InstallPackParams = components['schemas']['InstallPackParams'] @@ -33,10 +36,10 @@ export const useComfyManagerStore = defineStore('comfyManager', () => { const managerService = useComfyManagerService() const installedPacks = ref<InstalledPacksResponse>({}) - const enabledPacksIds = ref<Set<string>>(new Set()) - const disabledPacksIds = ref<Set<string>>(new Set()) - const installedPacksIds = ref<Set<string>>(new Set()) - const installingPacksIds = ref<Set<string>>(new Set()) + const enabledPacksIds = ref<Set<NodePackId>>(new Set()) + const disabledPacksIds = ref<Set<NodePackId>>(new Set()) + const installedPacksIds = ref<Set<NodePackId>>(new Set()) + const installingPacksIds = ref<Set<NodePackId>>(new Set()) const isStale = ref(true) const taskLogs = ref<TaskLog[]>([]) const succeededTasksLogs = ref<TaskLog[]>([]) @@ -53,7 +56,7 @@ export const useComfyManagerStore = defineStore('comfyManager', () => { }) // Track task ID to pack ID mapping for proper state cleanup - const taskIdToPackId = ref(new Map<string, string>()) + const taskIdToPackId = ref(new Map<string, NodePackId>()) const managerQueue = useManagerQueue(taskHistory, taskQueue, installedPacks) @@ -114,15 +117,15 @@ export const useComfyManagerStore = defineStore('comfyManager', () => { const getPackId = (pack: ManagerPackInstalled) => pack.cnr_id || pack.aux_id - const isInstalledPackId = (packName: string | undefined): boolean => + const isInstalledPackId = (packName: NodePackId | undefined): boolean => !!packName && installedPacksIds.value.has(packName) - const isEnabledPackId = (packName: string | undefined): boolean => + const isEnabledPackId = (packName: NodePackId | undefined): boolean => !!packName && isInstalledPackId(packName) && enabledPacksIds.value.has(packName) - const isInstallingPackId = (packName: string | undefined): boolean => + const isInstallingPackId = (packName: NodePackId | undefined): boolean => !!packName && installingPacksIds.value.has(packName) const packsToIdSet = (packs: ManagerPackInstalled[]) => @@ -130,7 +133,7 @@ export const useComfyManagerStore = defineStore('comfyManager', () => { const id = pack.cnr_id || pack.aux_id if (id) acc.add(id) return acc - }, new Set<string>()) + }, new Set<NodePackId>()) /** * A pack is disabled if there is a disabled entry and no corresponding @@ -151,8 +154,8 @@ export const useComfyManagerStore = defineStore('comfyManager', () => { */ const updateDisabledIds = (packs: ManagerPackInstalled[]) => { // Use temporary variables to avoid triggering reactivity - const enabledIds = new Set<string>() - const disabledIds = new Set<string>() + const enabledIds = new Set<NodePackId>() + const disabledIds = new Set<NodePackId>() for (const pack of packs) { const id = getPackId(pack) @@ -326,7 +329,7 @@ export const useComfyManagerStore = defineStore('comfyManager', () => { await enqueueTaskWithLogs(task, t('g.enabling', { id: params.id })) } - const getInstalledPackVersion = (packId: string) => { + const getInstalledPackVersion = (packId: NodePackId) => { const pack = installedPacks.value[packId] return pack?.ver } diff --git a/src/workbench/extensions/manager/types/comfyManagerTypes.ts b/src/workbench/extensions/manager/types/comfyManagerTypes.ts index a9327b4572..64dc92d04a 100644 --- a/src/workbench/extensions/manager/types/comfyManagerTypes.ts +++ b/src/workbench/extensions/manager/types/comfyManagerTypes.ts @@ -3,6 +3,16 @@ import type { InjectionKey, Ref } from 'vue' import type { AlgoliaNodePack } from '@/types/algoliaTypes' import type { components } from '@/types/comfyRegistryTypes' +/** + * Identifier for a node pack from the Comfy Registry / Manager. + * + * Backed by the registry pack's `id` (typically a slug like `cnr_id` or + * `aux_id`, possibly suffixed with `@version` for disabled packs). This alias + * names that primitive at use sites (manager store, services, composables) + * without changing structural typing. + */ +export type NodePackId = string + export type RegistryPack = components['schemas']['Node'] export type MergedNodePack = RegistryPack & AlgoliaNodePack export const isMergedNodePack = ( diff --git a/src/workbench/utils/modelMetadataUtil.ts b/src/workbench/utils/modelMetadataUtil.ts index e5137d6f1c..e243de6680 100644 --- a/src/workbench/utils/modelMetadataUtil.ts +++ b/src/workbench/utils/modelMetadataUtil.ts @@ -21,7 +21,7 @@ import type { ModelFile } from '@/platform/workflow/validation/schemas/workflowS */ export function getSelectedModelsMetadata(node: { type: string - widgets_values?: unknown[] | Record<string, unknown> + widgets_values?: readonly unknown[] | Record<string, unknown> properties?: { models?: ModelFile[] } }): ModelFile[] | undefined { try { diff --git a/tools/devtools/__init__.py b/tools/devtools/__init__.py index 57812526f6..e8fbda0771 100644 --- a/tools/devtools/__init__.py +++ b/tools/devtools/__init__.py @@ -113,4 +113,5 @@ async def delete_file(request: Request): return web.Response(status=500, text=f"Error: {str(e)}") -__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"] +WEB_DIRECTORY = "./web" +__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"] diff --git a/tools/devtools/nodes/inputs.py b/tools/devtools/nodes/inputs.py index ac31056cad..56baaf06e1 100644 --- a/tools/devtools/nodes/inputs.py +++ b/tools/devtools/nodes/inputs.py @@ -302,6 +302,21 @@ class NodeWithV2ComboInput: def node_with_v2_combo_input(self, combo_input: str): return (combo_input,) +class NodeWithLegacyWidget: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { "legacy_widget": ("INT", { "widgetType": "DEVTOOLSLEGACYWIDGET" }) } + } + + RETURN_TYPES = () + FUNCTION = "node_with_legacy_widget" + CATEGORY = "DevTools" + DESCRIPTION = ("A node with a legacy widget") + + def node_with_legacy_widget(self): + return () + NODE_CLASS_MAPPINGS = { "DevToolsLongComboDropdown": LongComboDropdown, @@ -318,6 +333,7 @@ NODE_CLASS_MAPPINGS = { "DevToolsNodeWithSeedInput": NodeWithSeedInput, "DevToolsNodeWithValidation": NodeWithValidation, "DevToolsNodeWithV2ComboInput": NodeWithV2ComboInput, + "DevToolsNodeWithLegacyWidget": NodeWithLegacyWidget, } NODE_DISPLAY_NAME_MAPPINGS = { @@ -335,6 +351,7 @@ NODE_DISPLAY_NAME_MAPPINGS = { "DevToolsNodeWithSeedInput": "Node With Seed Input", "DevToolsNodeWithValidation": "Node With Validation", "DevToolsNodeWithV2ComboInput": "Node With V2 Combo Input", + "DevToolsNodeWithLegacyWidget": "Node With Legacy Widget", } __all__ = [ diff --git a/tools/devtools/web/legacyWidget.js b/tools/devtools/web/legacyWidget.js new file mode 100644 index 0000000000..af49ad957a --- /dev/null +++ b/tools/devtools/web/legacyWidget.js @@ -0,0 +1,35 @@ +//es +// eslint-disable-next-line import-x/no-unresolved -- import is correct at time of test execution +import { app } from '../../scripts/app.js' + +function legacyWidget(node, inputName, inputData) { + if (!node.widgets) node.widgets = [] + node.widgets.push({ + draw: function (ctx, node, widget_width, y, H) { + ctx.save() + ctx.fillStyle = '#7F7' + ctx.fillRect(15, y, widget_width - 15 * 2, H) + ctx.restore() + }, + mouse: function mouseAnnotated(event, [x, y], node) { + const widget_width = this.width || node.size[0] + if (x < 30) { + this.value-- + } else if (x > widget_width - 30 && x < widget_width) { + this.value++ + } + }, + name: inputName, + options: {}, + type: 'DEVTOOLS.LEGACYWIDGET', + value: 0, + y: 0 + }) +} + +app.registerExtension({ + name: 'DevTools.LegacyWidget', + async getCustomWidgets() { + return { DEVTOOLSLEGACYWIDGET: legacyWidget } + } +})