mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-06 14:11:55 +00:00
Compare commits
51 Commits
batch-disp
...
sno-fronte
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eba055befe | ||
|
|
4c8e5ad797 | ||
|
|
d3bd6f9f12 | ||
|
|
a785f72aa0 | ||
|
|
6130880992 | ||
|
|
fb46996002 | ||
|
|
bf11a90cd8 | ||
|
|
0c4861162d | ||
|
|
17f5bde180 | ||
|
|
d840020427 | ||
|
|
1a993fc1dc | ||
|
|
321c32463e | ||
|
|
0a441ab896 | ||
|
|
cf01213235 | ||
|
|
61bcf0a8bc | ||
|
|
0b90645d87 | ||
|
|
8f60294f63 | ||
|
|
da8be4dc6c | ||
|
|
3fa9c4522a | ||
|
|
2b675d6b5c | ||
|
|
66072fc4a6 | ||
|
|
5f612e19b2 | ||
|
|
eb1fe9d88a | ||
|
|
51e77c65ad | ||
|
|
324d20477e | ||
|
|
6fb9915b45 | ||
|
|
fed451edac | ||
|
|
3ee55dfa1e | ||
|
|
93073cc242 | ||
|
|
5ef07f09fa | ||
|
|
2d5d77f7db | ||
|
|
ef4d8622fa | ||
|
|
dc2d8375fd | ||
|
|
bd0a10d7f0 | ||
|
|
5b75bb5bbf | ||
|
|
8bdc850830 | ||
|
|
95d0f22906 | ||
|
|
a53ea4dae2 | ||
|
|
7ba5fcdf1a | ||
|
|
c176723bbc | ||
|
|
559001341a | ||
|
|
b9c677f54c | ||
|
|
6304a60656 | ||
|
|
0b0a1076f4 | ||
|
|
d1968f2033 | ||
|
|
f129dc2aeb | ||
|
|
1a4b766fb7 | ||
|
|
3e8022c0d1 | ||
|
|
cb696d8426 | ||
|
|
3720ba3829 | ||
|
|
cdc834705a |
695
.claude/skills/bug-dump-ingest/SKILL.md
Normal file
695
.claude/skills/bug-dump-ingest/SKILL.md
Normal file
@@ -0,0 +1,695 @@
|
||||
---
|
||||
name: bug-dump-ingest
|
||||
description: 'Syncs the #bug-dump Slack channel into Linear as the system of record AND auto-fixes verified real bugs via red-green-fix. Every Linear operation (create, search, link, label) is performed by posting an @Linear mention in the bug-dump thread — no Linear MCP, no API key. Flow: fetch → mandatory dedupe gate (@Linear search + gh PR search) → false-defect verification → post @Linear create in thread (tool call) → parse bot card for FE-NNNN + URL → post :white_check_mark: confirmation reply → if candidate is a verified real bug with no dedupe hit and no open PR, invoke red-green-fix automatically to produce failing test + fix + PR. Respects team emoji scheme (:white_check_mark: ticket created, :pr-open: PR open, :question: needs context, :repeat: duplicate). Use when asked to sync #bug-dump to Linear, triage slack bugs, run a bug-dump sweep, or ingest bug reports. Triggers on: bug-dump, sync bug-dump, ingest bugs, triage slack bugs, bug sweep.'
|
||||
---
|
||||
|
||||
# Bug Dump Ingest
|
||||
|
||||
**Primary job: sync `#bug-dump` (Slack: `C0A4XMHANP3`) into Linear as the source of truth, then auto-fix the verified real bugs.** Linear is where status, labels, and follow-up triage happen — this skill gets every bug into Linear with enough context that a downstream agent or human can work from Linear alone. **Every Linear action is performed by mentioning `@Linear` in the bug-dump thread**; there is no Linear MCP and no API key path. When pre-flight verification confirms a candidate is a real bug (not dedupe, not already in a PR, not out of scope), the skill then invokes `red-green-fix` automatically.
|
||||
|
||||
```text
|
||||
fetch → pre-flight dedupe gate (@Linear search + gh) → verify false defects → present approvals
|
||||
→ POST "@Linear create ..." thread reply via slack_send_message (mandatory tool call)
|
||||
→ poll slack_read_thread → parse Linear bot card for FE-NNNN + URL
|
||||
→ POST :white_check_mark: confirmation thread reply via slack_send_message
|
||||
→ if verification = "real bug" AND no dedupe AND no open PR:
|
||||
invoke Skill(skill="red-green-fix") → POST :pr-open: thread reply
|
||||
```
|
||||
|
||||
### Non-negotiable rules
|
||||
|
||||
1. **Linear actions are Slack tool calls.** The skill MUST drive Linear by calling `mcp__plugin_slack_slack__slack_send_message` with `thread_ts` set and text that mentions `@Linear`. There is no MCP-direct path and no API-key path. Printing `@Linear create ...` into the Claude CLI response is NOT a substitute — the Slack thread reply is what triggers the Linear bot, and its card is the canonical receipt.
|
||||
2. **Dedupe is a gate, not a suggestion.** No candidate is proposed for creation until `@Linear search` AND `gh pr` search have been run and recorded. A hit short-circuits creation to `L` (link) or `pr-open`.
|
||||
3. **Auto-fix real bugs.** When the dedupe gate is clean AND false-defect verification is clean AND the candidate isn't on the handoff-exclusion list (see § Handoff conditions), after Linear creation the skill invokes `red-green-fix` via the `Skill` tool — without waiting for an extra human prompt.
|
||||
|
||||
### What the skill cannot do
|
||||
|
||||
The Slack MCP exposes no `reactions.add` tool, so the skill cannot put a `:white_check_mark:` reaction on the parent message. The thread reply with the leading `:white_check_mark:` emoji is the skill's canonical marker; a human can additionally add the parent reaction for channel visibility (see § Parent reaction — optional visibility nudge). Both are respected by Processed Detection.
|
||||
|
||||
## Team emoji scheme
|
||||
|
||||
| Emoji | Meaning | Who adds it | Skill behavior |
|
||||
| -------------------- | ------------------ | ------------------------------------------------------ | ---------------------------------------------- |
|
||||
| `:white_check_mark:` | Ticket created | Human on parent (after skill files); also in bot reply | Skip in future sweeps |
|
||||
| `:pr-open:` | PR open | Human | Skip creation; include PR link in approval row |
|
||||
| `:question:` | Needs more context | Human | Skip creation; agent may ask for clarification |
|
||||
| `:repeat:` | Duplicate | Human | Skip creation; link existing Linear issue |
|
||||
|
||||
## Design Priority
|
||||
|
||||
Optimize for **coverage, label quality, and proven fixes** over fix-path cleverness. Linear is the downstream triage surface — once every bug is there with status, labels, and context, agents and humans can work from Linear alone. A Linear ticket with a wrong severity is cheap to fix; a Slack-only bug is invisible to downstream tooling; a "filed but not fixed" real regression wastes a human turn that the skill could have spent on a red-green PR.
|
||||
|
||||
## Quick Start
|
||||
|
||||
1. **Scope** — default window: messages in the last 48h. Override with `--since YYYY-MM-DD` or a Slack permalink list.
|
||||
2. **Fetch** — `slack_read_channel` for `C0A4XMHANP3`; `slack_read_thread` per message with replies.
|
||||
3. **Filter** — drop already-processed (see Processed Detection).
|
||||
4. **Classify** — bug / discussion / meta (see Classification Rules).
|
||||
5. **Pre-flight dedupe gate (MANDATORY)** — for every bug candidate, run `@Linear search` AND `gh pr` search BEFORE proposing (see § Pre-flight Dedupe Gate). A hit means the candidate goes into the batch as `L` (link) or `pr-open`, not as a new create.
|
||||
6. **Verify false defects** — per candidate, run quick checks before proposing (see False-Defect Verification).
|
||||
7. **Extract** — normalize to ticket schema (see Ticket Schema).
|
||||
8. **Human approval** — batch table, collect Y/N/?/S/L/R per candidate (see Interactive Approval). Default recommendation for clean candidates is `Y` (file + auto-fix).
|
||||
9. **Post `@Linear create` thread reply — MANDATORY TOOL CALL** — for each approved `Y`/`L` row, call `mcp__plugin_slack_slack__slack_send_message` with `channel_id=C0A4XMHANP3`, `thread_ts=<parent-ts>`, and text starting with `@Linear create` (see § Linear Slack Bot Integration). Do NOT print the command into chat as a substitute.
|
||||
10. **Capture the Linear bot card** — poll `slack_read_thread` up to 3× with ~3s spacing, parse the first Linear-app reply for the `FE-NNNN` identifier and `https://linear.app/...` URL. No URL = not ingested; never fabricate one.
|
||||
11. **Post `:white_check_mark:` confirmation reply — MANDATORY TOOL CALL** — call `slack_send_message` again with text starting with `:white_check_mark: Filed to Linear: <URL>` so future sweeps can detect the marker via `has::white_check_mark: from:me`. Record both `ts` values in the session log.
|
||||
12. **Auto-fix (clean candidates only)** — if dedupe gate is clean AND false-defect verification is clean AND the candidate isn't on the Handoff-Exclusion list, immediately invoke the `red-green-fix` skill via the `Skill` tool. See § Fix Workflow for the exact call contract.
|
||||
13. **Log** — append to session log; update `processed.json`.
|
||||
|
||||
## System Context
|
||||
|
||||
| Item | Value |
|
||||
| ------------------ | -------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Source channel | `#bug-dump` (`C0A4XMHANP3`) |
|
||||
| Destination | Linear `Frontend Engineering` team, via the Linear Slack app (`@Linear`). Team is named in every `@Linear create` message. |
|
||||
| Default state | `Triage` — every `@Linear create` message includes `Status: Triage` |
|
||||
| State dir | `~/temp/bug-dump-ingest/` |
|
||||
| Processed registry | `~/temp/bug-dump-ingest/processed.json` |
|
||||
| Session log | `~/temp/bug-dump-ingest/session-YYYY-MM-DD.md` |
|
||||
| Drafts (failure) | `~/temp/bug-dump-ingest/drafts/*.md` — written only when `@Linear` never replies, so the human can retry manually |
|
||||
|
||||
## Label Taxonomy
|
||||
|
||||
Every created Linear issue MUST get the following labels, passed as a comma-separated list in the `Labels:` line of the `@Linear create` message. The Linear Slack app creates missing labels on first use:
|
||||
|
||||
| Label kind | Values | Source |
|
||||
| ------------ | ------------------------------------------------------------------------------ | ------------------------- |
|
||||
| `source:` | `source:bug-dump` | Always (marks Slack sync) |
|
||||
| `area:` | `area:ui`, `area:node-system`, `area:workflow`, `area:cloud`, `area:templates` | Area Heuristics |
|
||||
| `env:` | `env:cloud-prod`, `env:cloud-dev`, `env:local`, `env:electron` | Env Heuristics |
|
||||
| `severity:` | `sev:high`, `sev:medium`, `sev:low` | Severity Heuristics |
|
||||
| `reporter:` | `reporter:<slack-handle>` (kebab-case) | From message author |
|
||||
| Status flags | `needs-repro`, `needs-backend`, `regression`, `pr-open` | When applicable |
|
||||
|
||||
Label rules:
|
||||
|
||||
- Always include `source:bug-dump`, exactly one `area:`, at least one `env:` (or `env:unknown`), exactly one `severity:`, exactly one `reporter:`.
|
||||
- `needs-repro` — set when repro steps were ambiguous; signals "human should confirm before fix".
|
||||
- `needs-backend` — set when fix is clearly in ComfyUI backend, not this frontend repo.
|
||||
- `regression` — set when the bug mentions a version/upgrade correlation.
|
||||
- `pr-open` — set instead of creating a fresh ticket when a fix PR already exists; the Linear issue becomes a tracker.
|
||||
|
||||
Labels are the primary affordance for downstream triage — invest in getting them right, not just in the title.
|
||||
|
||||
## Processed Detection
|
||||
|
||||
A top-level message is considered already-handled (skip creation) if ANY of:
|
||||
|
||||
- Its timestamp appears in `processed.json`.
|
||||
- It carries a `:white_check_mark:` reaction on the parent — ticket already created.
|
||||
- It carries a `:pr-open:` reaction — fix PR is open; skill records the PR link in the session log rather than creating a fresh Linear issue.
|
||||
- It carries a `:repeat:` reaction — duplicate; skill attempts to find the original Linear issue and link it in the session log.
|
||||
- It carries a `:question:` reaction — needs more context; skill skips creation and records for follow-up.
|
||||
- Its thread contains a reply with a `https://linear.app/` URL (fetch via `slack_read_thread`).
|
||||
- Its thread contains a reply starting with `:white_check_mark:` from the skill's bot user.
|
||||
- It is a system/meta message (`has joined the channel`, bot-only message).
|
||||
- Its thread already contains resolution confirmation (`"solved"`, `"resolved"`, `:done:` reaction from the reporter) AND has no fix PR referenced — treat as "resolved without ticket, skip".
|
||||
|
||||
Never re-ingest a message already marked in any of the above ways.
|
||||
|
||||
Filter query for Slack search-based sweeps:
|
||||
|
||||
```text
|
||||
in:<#C0A4XMHANP3> -has::white_check_mark: -has::pr-open: -has::repeat: -has::question: after:YYYY-MM-DD
|
||||
```
|
||||
|
||||
## False-Defect Verification
|
||||
|
||||
Before a candidate hits the approval batch, run cheap checks to demote obvious non-bugs. Goal: keep the approval table high-signal. This is not a full repro — just fast heuristics that catch the top false-positive classes.
|
||||
|
||||
| Check | Command / Signal | Demote-to |
|
||||
| ---------------------------------------- | ---------------------------------------------------------------- | ---------- |
|
||||
| Reporter self-resolved in same msg | "no action needed", "solved", "nvm", "fixed it" | `resolved` |
|
||||
| Reporter self-resolved in thread | `slack_read_thread` → reporter's last reply contains "solved" | `resolved` |
|
||||
| Fix PR merged on main | `gh search prs "in:title <keyword>" --state merged --limit 3` | `fixed` |
|
||||
| Fix PR open (already-filed) | `gh search prs "<keyword>" --state open --limit 3` | `pr-open` |
|
||||
| Linear issue exists (open) | Linear `searchIssues` on title keywords → any open match | `dedupe` |
|
||||
| Behavior is documented / intended | grep `docs/` and `src/locales/en/*.json` for the feature | `expected` |
|
||||
| Not reproducible — feature doesn't exist | grep `src/` for mentioned component/feature → 0 hits | `stale` |
|
||||
| Env drift only (local setup issue) | Thread contains "my machine", "my setup", "proxy" without others | `env` |
|
||||
|
||||
For each demoted candidate, record the demotion reason in the approval table as `Verify: <tag>` so the human can override if they disagree. Never hard-skip based on verification alone — always show the row with the demotion.
|
||||
|
||||
### Recommended verify commands
|
||||
|
||||
```bash
|
||||
# 1. Search recent PRs for the feature in question
|
||||
gh search prs "<keyword>" --repo Comfy-Org/ComfyUI_frontend --limit 5
|
||||
|
||||
# 2. Grep for the feature / component mentioned
|
||||
rg -l "<ComponentOrFeatureName>" src/ apps/
|
||||
|
||||
# 3. Check if it's a known i18n / documented setting
|
||||
rg "<setting-key>" src/locales/en/ docs/
|
||||
```
|
||||
|
||||
Keep verification under ~30s per candidate. If it takes longer, propose a ticket and let the human decide — don't let verification become the bottleneck.
|
||||
|
||||
## Classification Rules
|
||||
|
||||
For each unprocessed top-level message, decide:
|
||||
|
||||
| Class | Signal | Action |
|
||||
| ----------------- | --------------------------------------------------------------------------------------------------------- | ---------------------------- |
|
||||
| **bug** | Describes unexpected behavior, visual glitch, error, regression, crash. Usually has repro steps or media. | Propose Linear ticket |
|
||||
| **discussion** | Design question, rollout thoughts, team chatter, PR planning (e.g. "how about we make a PR to do...") | Skip |
|
||||
| **question** | User asking if something is expected or known | Skip unless answered = bug |
|
||||
| **meta** | Channel joins, bot messages, cross-posts without content | Skip |
|
||||
| **already-filed** | Thread shows PR already open OR existing Linear link | Skip, log with existing link |
|
||||
|
||||
When ambiguous, default to **bug** and let the human decide in the approval batch.
|
||||
|
||||
## Ticket Schema
|
||||
|
||||
Normalize each bug to this shape before presenting:
|
||||
|
||||
```json
|
||||
{
|
||||
"slack_ts": "1776639963.837519",
|
||||
"slack_permalink": "https://comfy-organization.slack.com/archives/C0A4XMHANP3/p1776639963837519",
|
||||
"reporter": "Ali Ranjah (wavey)",
|
||||
"title": "Unet model dropdown missing selected model",
|
||||
"description": "Body with repro steps, env, attachments list, thread summary",
|
||||
"env": ["cloud prod"],
|
||||
"severity": "low | medium | high",
|
||||
"area": "ui | node-system | workflow | cloud | templates | unknown",
|
||||
"attachments": [{ "name": "...", "id": "F...", "type": "image/png" }],
|
||||
"thread_resolution": "solved | open | none"
|
||||
}
|
||||
```
|
||||
|
||||
Keep descriptions copy-paste friendly: lead with repro bullets, then env, then "See Slack: <permalink>". Attach thread summary only if it adds context beyond the top-level message.
|
||||
|
||||
### Severity Heuristics
|
||||
|
||||
- **high** — crash, data loss, blocks a template or core feature, affects paying users broadly (e.g. "job ends in 30m on Pro", "widget values reset").
|
||||
- **medium** — visible regression, template error, wrong pricing, broken UX on a common path.
|
||||
- **low** — cosmetic, single-template edge case, minor tooltip/boundary issue.
|
||||
|
||||
When unsure, mark `medium` and flag for human in the approval batch.
|
||||
|
||||
### Area Heuristics
|
||||
|
||||
- `ui` — visual glitches, palette issues, popover clipping, dropdown styling.
|
||||
- `node-system` — canvas perf, reroute, node drag, widget rendering, undo.
|
||||
- `workflow` — template failures, save/load, refresh regressions.
|
||||
- `cloud` — jobs, pricing, assets, auth, queue.
|
||||
- `templates` — specific template errors.
|
||||
|
||||
## Pre-flight Dedupe Gate (MANDATORY)
|
||||
|
||||
Before any candidate enters the approval table, run BOTH checks below and record the result in the row's `Dedup` and `PR` columns. This is a hard gate — no candidate may be proposed for creation without a verdict.
|
||||
|
||||
### Check 1 — Open Linear issues (via `@Linear search`)
|
||||
|
||||
Extract 3-5 keyword terms from the proposed title (strip stopwords). Post a search command to the bug-dump thread — use a scratch thread if no parent `ts` is available yet, but prefer the candidate's own parent thread so the search card becomes part of that thread's audit trail:
|
||||
|
||||
```text
|
||||
mcp__plugin_slack_slack__slack_send_message({
|
||||
channel_id: "C0A4XMHANP3",
|
||||
thread_ts: "<parent-ts>",
|
||||
text: "@Linear search <keyword-1> <keyword-2>\nTeam: Frontend Engineering\nStatus: open"
|
||||
})
|
||||
```
|
||||
|
||||
Poll `slack_read_thread` for up to 10s; parse the Linear app's card reply for `FE-NNNN` identifiers and URLs. Run the search twice with different keyword subsets if the first returns zero hits — reworded titles are the top false-negative class.
|
||||
|
||||
If `@Linear search` is not supported by the workspace's Linear app version, fall back to a Slack search for prior `@Linear` card replies in the channel:
|
||||
|
||||
```text
|
||||
mcp__plugin_slack_slack__slack_search_public({
|
||||
query: "in:<#C0A4XMHANP3> from:@Linear <keyword-1> <keyword-2>"
|
||||
})
|
||||
```
|
||||
|
||||
This scans past Linear bot replies in the channel — any reply containing a matching `FE-NNNN` URL is a candidate duplicate. Record which dedupe path was used in the session log.
|
||||
|
||||
Treat a hit as a duplicate if any of:
|
||||
|
||||
- Title overlap ≥ 80% (after lowercasing + stopword removal)
|
||||
- Same reporter + same component reference in description
|
||||
- Same stack trace or error code
|
||||
|
||||
**Verdict:** set `Dedup: FE-NNNN` and default recommendation to `L` (link, don't create). The human may still override to `Y` to file a separate ticket.
|
||||
|
||||
### Check 2 — Open or merged fix PRs on GitHub
|
||||
|
||||
```bash
|
||||
# Open PRs matching title keywords
|
||||
gh pr list --repo Comfy-Org/ComfyUI_frontend --state open \
|
||||
--search "<keyword-1> <keyword-2>" --limit 5 \
|
||||
--json number,title,url,createdAt
|
||||
|
||||
# Recent merged fixes (last 30d) — catches "already fixed, waiting to ship"
|
||||
gh pr list --repo Comfy-Org/ComfyUI_frontend --state merged \
|
||||
--search "<keyword-1> <keyword-2> merged:>=<YYYY-MM-DD>" --limit 5 \
|
||||
--json number,title,url,mergedAt
|
||||
```
|
||||
|
||||
Treat a hit as a match if the PR title/body mentions the same component or bug phrase and the PR is unmerged or merged within the window covering the reporter's observation.
|
||||
|
||||
**Verdict:**
|
||||
|
||||
- Open PR match → set `PR: #NNNN (open)`, recommendation `pr-open` (file Linear with `pr-open` label linking the PR, skip auto-fix).
|
||||
- Merged PR match → set `PR: #NNNN (merged)`, recommendation `fixed` (demote in verify, usually skip; human can override if the reporter claims the fix didn't land).
|
||||
|
||||
### Failure handling
|
||||
|
||||
If either check errors (Linear Slack app silent or not in channel, `gh` auth expired), DO NOT proceed to proposal — stop the sweep, report the failure to the user, and let them decide whether to re-run or manually dedupe. A silent skip of dedupe is never acceptable; it's the single biggest source of duplicate tickets.
|
||||
|
||||
Log each dedupe query + top hits in `~/temp/bug-dump-ingest/session-YYYY-MM-DD.md` under a per-candidate `Dedup trace:` block so the human can audit.
|
||||
|
||||
## Interactive Approval
|
||||
|
||||
Present candidates in batches of 5-10. Table format (10 columns):
|
||||
|
||||
```text
|
||||
# | Slack (author, time) | Proposed title | Env | Sev | Area | Dedup | PR | Verify | Rec
|
||||
----+------------------------+-----------------------------------------+------------+------+------------+------------+---------------+-------------+-----
|
||||
1 | wavey, 04-20 08:06 | Unet dropdown missing selected model | cloud prod | low | ui | - | - | resolved | N
|
||||
2 | Denys, 04-18 05:45 | Pro plan jobs end at 30 minutes | cloud prod | high | cloud | - | - | clean | Y
|
||||
3 | Terry Jia, 04-18 12:52 | Nodes 2.0 canvas lag on large workflows | - | high | node-system| FE-4521 | - | clean | L
|
||||
4 | Pablo, 04-17 08:52 | Multi-asset delete popup shows hashes | cloud prod | low | ui | - | #11402 (open) | clean | pr-open
|
||||
```
|
||||
|
||||
Each row MUST show: Slack author + date, proposed title, env tags, severity, area, **dedupe status from the Pre-flight Dedupe Gate**, **open/merged PR hit from the Pre-flight Dedupe Gate**, verify tag (from False-Defect Verification), and agent recommendation.
|
||||
|
||||
### Default recommendation logic
|
||||
|
||||
The skill computes `Rec` deterministically from the gate results:
|
||||
|
||||
- `L` — Dedupe hit on open Linear issue.
|
||||
- `pr-open` — Open GitHub PR hit.
|
||||
- `fixed` — Merged PR hit within the reporter's observation window.
|
||||
- `N` — Verify tag is `resolved`, `expected`, `stale`, or `env` only.
|
||||
- `?` — Repro incomplete or classification ambiguous.
|
||||
- `Y` — Everything clean AND candidate is not on the § Handoff-Exclusion list. This is the "file + auto-fix" path.
|
||||
- `Y (file-only)` — Clean but on the handoff-exclusion list (e.g. touches LGraphNode, needs backend). File Linear, skip auto-fix.
|
||||
|
||||
### Response format
|
||||
|
||||
- `Y` — default path: create Linear ticket, post `:white_check_mark:` thread reply, AND if the candidate is eligible (dedupe clean, verify clean, not on handoff-exclusion list), immediately invoke `red-green-fix` via the `Skill` tool. See § Fix Workflow.
|
||||
- `S` — **skip auto-fix** for this row: create Linear ticket + thread reply only, do NOT run red-green-fix. Use when the human knows a specific person is already investigating or wants to batch fixes.
|
||||
- `N` — skip entirely (log reason in session file).
|
||||
- `?` — mark as needs-context; skill posts a thread reply asking for repro details and prompts the human to add `:question:` to the parent.
|
||||
- `L` — link to existing Linear issue instead of creating (skill asks which one if the Pre-flight Dedupe Gate didn't return an exact match).
|
||||
- `R` — duplicate of another bug-dump message; skill links the two and prompts the human for `:repeat:` on the parent.
|
||||
- `E` — edit proposed title/description before creating (skill shows draft for inline tweaks).
|
||||
- Bulk responses accepted: `1 N, 2 Y, 3 L FE-4521, 4 pr-open #11402, 5 ?` — any row omitted from the response is treated as its computed `Rec` default.
|
||||
|
||||
Do not post any `@Linear create` messages until all candidates in the batch have a terminal decision. Auto-fix invocations run sequentially AFTER every `@Linear create` has produced a parsed `FE-NNNN`, so every `red-green-fix` call has a `Fixes FE-NNNN` to put in the PR body.
|
||||
|
||||
## Linear Slack Bot Integration (@Linear)
|
||||
|
||||
Every Linear action — create, search, link, label, status change — is performed by posting a message to the candidate's thread in `#bug-dump` that mentions `@Linear`. The Linear Slack app parses the mention and responds with a card in the same thread. There is no Linear MCP path and no `LINEAR_API_KEY` path; see `reference/linear-api.md` § "Why no direct API path" for the rationale.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- The Comfy Slack workspace already has the Linear Slack app installed (this is how humans add `@Linear` mentions today).
|
||||
- Channel `C0A4XMHANP3` is connected to the `Frontend Engineering` Linear team.
|
||||
- No per-machine setup. If a `@Linear` invocation produces no bot reply, the app is not in the channel — surface to the human, do NOT retry silently.
|
||||
|
||||
### Create an issue
|
||||
|
||||
For each approved `Y` candidate, call:
|
||||
|
||||
```text
|
||||
mcp__plugin_slack_slack__slack_send_message({
|
||||
channel_id: "C0A4XMHANP3",
|
||||
thread_ts: "<parent-ts>",
|
||||
text: "@Linear create\nTeam: Frontend Engineering\nTitle: <title>\nStatus: Triage\nLabels: source:bug-dump, area:<area>, env:<env>, sev:<severity>, reporter:<handle>\n\n<description>\n\nSource: <slack-permalink>"
|
||||
})
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- First line MUST be `@Linear create` — this is the command token.
|
||||
- `Team: Frontend Engineering` is required on every create — without it the bot falls back to the workspace default, which may route to a different team.
|
||||
- `Status: Triage` pins the initial state (per § System Context).
|
||||
- `Labels:` — comma-separated, full `source:bug-dump, area:*, env:*, sev:*, reporter:*` set per § Label Taxonomy. Missing labels are auto-created by the Linear Slack app on first use.
|
||||
- Description body is markdown — see `reference/linear-api.md` § "Description body template" and `reference/schema.md` for per-field extraction.
|
||||
- Use real newlines (not literal `\n`) when constructing the text.
|
||||
|
||||
After the tool call returns, poll `slack_read_thread` for the Linear app's reply card (up to 3× with ~3s spacing). Parse the card for:
|
||||
|
||||
- An `FE-NNNN` identifier
|
||||
- A `https://linear.app/<org>/issue/FE-NNNN` URL
|
||||
|
||||
The URL is the ingested receipt. The skill then posts the `:white_check_mark:` confirmation reply (§ Slack Thread Reply).
|
||||
|
||||
### Search (dedupe)
|
||||
|
||||
See § Pre-flight Dedupe Gate § Check 1 for the search command shape and handling of the bot's reply. The search is a tool call in the candidate's thread — not a chat aside.
|
||||
|
||||
### Link an existing issue (`L` response)
|
||||
|
||||
When the human picks `L FE-4521` for a row, do NOT post `@Linear create`. Instead:
|
||||
|
||||
```text
|
||||
mcp__plugin_slack_slack__slack_send_message({
|
||||
channel_id: "C0A4XMHANP3",
|
||||
thread_ts: "<parent-ts>",
|
||||
text: "@Linear link FE-4521"
|
||||
})
|
||||
```
|
||||
|
||||
The bot replies with the linked issue card. Then post the `:white_check_mark:` confirmation reply (adjusted to say `Linked to Linear:` rather than `Filed to Linear:`) so Processed Detection still matches.
|
||||
|
||||
### Label / status updates
|
||||
|
||||
When a later sweep needs to flip a ticket (e.g. a PR opened after initial ingest, so add `pr-open` and link):
|
||||
|
||||
```text
|
||||
mcp__plugin_slack_slack__slack_send_message({
|
||||
channel_id: "C0A4XMHANP3",
|
||||
thread_ts: "<parent-ts>",
|
||||
text: "@Linear FE-4521 add-labels pr-open"
|
||||
})
|
||||
```
|
||||
|
||||
Status changes are rarely driven by this skill directly — Linear auto-moves issues to `In Review` when a PR with `Fixes FE-NNNN` is opened, and the `red-green-fix` skill handles that PR body.
|
||||
|
||||
### Captured fields per create
|
||||
|
||||
Every successful create must produce, via the Linear bot's reply card:
|
||||
|
||||
- `identifier` — e.g. `FE-4710`, used in `Fixes <LIN-ID>` references and session log
|
||||
- `url` — `https://linear.app/.../issue/FE-4710`, included verbatim in the `:white_check_mark:` reply
|
||||
- `ts` of the Linear bot's card reply — recorded in session log for audit
|
||||
|
||||
If the card is missing the URL or identifier, fall through to the failure path below — do NOT fabricate either value.
|
||||
|
||||
### Failure path
|
||||
|
||||
If the Linear bot does not reply within the poll window, OR replies with a parse error (`couldn't parse`, `no team matched`, `failed`):
|
||||
|
||||
1. Write a draft markdown file to `~/temp/bug-dump-ingest/drafts/NN-short-slug.md` containing the full `@Linear create` text that was sent plus any partial bot reply.
|
||||
2. Post a thread reply that is explicit about the failure — do NOT include `:white_check_mark:` or a fake Linear URL:
|
||||
```text
|
||||
:warning: bug-dump-ingest: @Linear did not respond. Drafted at ~/temp/bug-dump-ingest/drafts/<slug>.md — please file manually and reply with the FE-NNNN.
|
||||
```
|
||||
3. Skip auto-fix for this candidate (no Linear ID = no `Fixes` reference).
|
||||
4. Log the failure in the session log.
|
||||
|
||||
Never invent a Linear URL. Never post `:white_check_mark: Filed to Linear: ...` without a real URL parsed from a real Linear bot card.
|
||||
|
||||
## Slack Thread Reply (Ingested Marker) — MANDATORY TOOL CALL
|
||||
|
||||
Every approved candidate produces **two** mandatory `slack_send_message` calls in the parent thread:
|
||||
|
||||
1. The `@Linear create` (or `@Linear link`) command — see § Linear Slack Bot Integration.
|
||||
2. The `:white_check_mark:` confirmation reply described below, posted after a real `FE-NNNN` + URL have been parsed from the Linear bot's card.
|
||||
|
||||
The second reply is what future sweeps grep for via `has::white_check_mark: from:me`. Even though the Linear bot's own card already contains the URL, the `:white_check_mark:` prefix is the canonical Processed Detection marker — without it, a future sweep may re-ingest the same bug.
|
||||
|
||||
The skill is not done with a candidate until BOTH calls have succeeded. If either fails, do not claim the candidate is ingested.
|
||||
|
||||
### Required call shape
|
||||
|
||||
```text
|
||||
mcp__plugin_slack_slack__slack_send_message({
|
||||
channel_id: "C0A4XMHANP3",
|
||||
thread_ts: "<parent-message-ts>", // dotted form, e.g. "1776714531.990509"
|
||||
text: ":white_check_mark: Filed to Linear: <LINEAR_URL>\nReporter: <@USER_ID>\nSev: <severity> • Area: <area>"
|
||||
})
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- `thread_ts` MUST be the parent message ts — never the channel ts, never omitted. An omitted `thread_ts` posts at channel level, which pollutes `#bug-dump` and breaks Processed Detection.
|
||||
- The text MUST start with `:white_check_mark:` followed by a space and `Filed to Linear:`. This exact prefix is what future sweeps grep for via `has::white_check_mark: from:me`.
|
||||
- The Linear URL MUST be present. No URL = not ingested; future sweeps will re-file the same bug.
|
||||
- Plain text only — no markdown tables, no bold, no code fences. Slack renders the emoji shortcode into a real `:white_check_mark:` only when the message is plain text.
|
||||
- Capture the returned `ts` and record it in the session log for audit.
|
||||
|
||||
### NEVER-do list (common failure mode)
|
||||
|
||||
- **Do NOT** print `@Linear create ...` or `:white_check_mark: Filed to Linear: <URL>` into the Claude CLI chat response as a substitute for calling `slack_send_message`. The CLI output is not seen by Slack. If you find yourself typing either into a plain assistant message, stop and issue the tool call instead.
|
||||
- **Do NOT** claim the thread reply was posted until the `slack_send_message` tool call has returned a success with a `ts`. If the tool call errors, surface the error and halt the batch — do not fabricate a reply.
|
||||
- **Do NOT** use any other tool (e.g. `slack_schedule_message`, `slack_send_message_draft`) as a substitute. Only an immediate `slack_send_message` with `thread_ts` set counts — the Linear Slack app does not trigger on scheduled/draft messages.
|
||||
- **Do NOT** substitute any direct Linear API call (MCP, GraphQL, curl) for the `@Linear` mention. The Slack thread is intentionally the single audit trail.
|
||||
|
||||
### Fix-path reply (after red-green-fix opens a PR)
|
||||
|
||||
When `red-green-fix` returns a PR URL for an auto-fixed candidate, the skill MUST post a second thread reply on the same parent — again via `slack_send_message`:
|
||||
|
||||
```text
|
||||
mcp__plugin_slack_slack__slack_send_message({
|
||||
channel_id: "C0A4XMHANP3",
|
||||
thread_ts: "<same parent ts>",
|
||||
text: ":pr-open: Fix PR: <PR_URL>\nRed-green verified: <unit|e2e> test proves the regression.\nFixes <LIN-ID>"
|
||||
})
|
||||
```
|
||||
|
||||
Same "tool call, not chat output" rule applies.
|
||||
|
||||
### Parent reaction — optional visibility nudge (not on critical path)
|
||||
|
||||
The Slack MCP does not expose `reactions.add`, so the skill cannot set a `:white_check_mark:` reaction on the parent. The thread reply above is sufficient for Processed Detection; the parent reaction is a human-only "visible in channel" nudge. At the end of the run, the skill MAY print a compact list for the human:
|
||||
|
||||
```text
|
||||
Optional: add :white_check_mark: to parent messages for in-channel visibility.
|
||||
LIN-4710 → <permalink>
|
||||
LIN-4711 → <permalink>
|
||||
```
|
||||
|
||||
This is a convenience, not a deliverable — a missing parent reaction does not cause re-ingestion.
|
||||
|
||||
## Fix Workflow (auto-invoke red-green-fix)
|
||||
|
||||
For every `Y` row whose `Rec` resolved to auto-fix (dedupe clean, verify clean, not on handoff-exclusion list), the skill MUST — after Linear creation and the `:white_check_mark:` thread reply — invoke the `red-green-fix` skill via the `Skill` tool. This is a real tool call, not a narrative handoff.
|
||||
|
||||
### Required Skill tool call
|
||||
|
||||
```text
|
||||
Skill({
|
||||
skill: "red-green-fix",
|
||||
args: "<composed prompt — see below>"
|
||||
})
|
||||
```
|
||||
|
||||
Compose `args` as a single self-contained prompt so the sub-invocation has everything it needs without re-reading the Linear issue:
|
||||
|
||||
```text
|
||||
Bug: <title>
|
||||
Linear: <LIN-ID> (<LINEAR_URL>)
|
||||
Source: Slack <permalink>
|
||||
Reporter: <display-name>
|
||||
Env: <env tags>
|
||||
Area: <area>
|
||||
Branch: fix/<lin-id-lowercase>-<short-slug>
|
||||
|
||||
Repro:
|
||||
1. <step>
|
||||
2. <step>
|
||||
|
||||
Expected: <expected behavior>
|
||||
Actual: <actual behavior>
|
||||
|
||||
Test layer (inferred from area):
|
||||
- ui → Vitest colocated + Playwright e2e tagged @regression
|
||||
- node-system → Playwright e2e primarily
|
||||
- workflow / templates → Playwright e2e
|
||||
- cloud → Vitest if client-side; otherwise STOP and label the Linear issue "needs-backend"
|
||||
|
||||
Test naming:
|
||||
- describe('<LIN-ID>: <one-line bug summary>', ...)
|
||||
- Playwright test title must include the LIN-ID.
|
||||
|
||||
PR body must include:
|
||||
- "Fixes <LIN-ID>"
|
||||
- "Source: Slack <permalink>"
|
||||
|
||||
Follow the red-green-fix two-commit sequence exactly. Do NOT skip the red commit.
|
||||
```
|
||||
|
||||
The skill MUST wait for `red-green-fix` to return before moving to the next candidate. Process one auto-fix at a time so branch state is deterministic.
|
||||
|
||||
### Verifying the invocation ran
|
||||
|
||||
After the `Skill` call returns, the skill MUST confirm at least one of:
|
||||
|
||||
1. A new git branch named `fix/<lin-id>-*` exists (`git branch --list "fix/<lin-id>-*"`).
|
||||
2. A PR URL is present in `red-green-fix`'s return payload.
|
||||
|
||||
If neither is true, the invocation silently no-op'd. Log the failure to the session log as `auto-fix skipped: invocation returned without branch or PR` and continue — do NOT post the `:pr-open:` thread reply.
|
||||
|
||||
### Inputs summary
|
||||
|
||||
- **Bug description** — the Linear description (includes repro, env, source permalink).
|
||||
- **Linear ID** — inserted into the PR body as `Fixes <LIN-ID>`.
|
||||
- **Branch name** — `fix/<lin-id>-<short-slug>` (e.g. `fix/lin-4711-pro-plan-30min-timeout`).
|
||||
- **Test layer** — inferred from `area`:
|
||||
- `ui` → unit (Vitest) + e2e (Playwright)
|
||||
- `node-system` → e2e primarily; unit if isolable
|
||||
- `workflow` / `templates` → e2e
|
||||
- `cloud` → unit if client-side logic, otherwise flag "backend — out of scope for this repo"
|
||||
|
||||
### Handoff-Exclusion list (do NOT auto-invoke red-green-fix)
|
||||
|
||||
These rows still get a Linear ticket + `:white_check_mark:` thread reply, but the skill MUST skip the `Skill(skill="red-green-fix")` call and instead post a thread nudge explaining why:
|
||||
|
||||
- Repro steps are incomplete (no clear numbered steps, no env) — reply in thread: "Need clearer repro before I can write a failing test. What's the shortest path to reproduce?"
|
||||
- Fix requires backend / ComfyUI repo changes (not frontend) — label Linear `needs-backend`.
|
||||
- Linear ticket was dedupe-linked rather than newly created — existing owner may already be fixing.
|
||||
- Severity is cosmetic AND reporter hasn't asked for a fix — file ticket only.
|
||||
- Fix would touch `LGraphNode`, `LGraphCanvas`, `LGraph`, or `Subgraph` god-objects (ADR-0003/0008 — always human decision).
|
||||
- Pre-flight Dedupe Gate found an open PR (`pr-open`) or a matching merged PR (`fixed`).
|
||||
|
||||
When a row is excluded, record the reason in the session log under `auto-fix excluded: <reason>`.
|
||||
|
||||
### Test authoring rules
|
||||
|
||||
Both tests MUST be written in the "red" commit BEFORE any fix code (per red-green-fix). Rules specific to bug-dump ingestion:
|
||||
|
||||
- **Unit test (Vitest)** — colocated next to the implementation, `<file>.test.ts`. Exercise the specific logic path reproduced by the reporter. One `describe` block named after the Linear ID:
|
||||
|
||||
```typescript
|
||||
// src/components/node/UnetDropdown.test.ts
|
||||
describe('LIN-4710: unet dropdown missing selected model', () => {
|
||||
it('includes the currently-selected model in the list even when not in available models', () => {
|
||||
// ...
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
- **E2E test (Playwright)** — under `browser_tests/tests/`, follow `writing-playwright-tests` skill. Tag with `@regression` and include the Linear ID in the test title:
|
||||
|
||||
```typescript
|
||||
test.describe(
|
||||
'LIN-4710 unet dropdown regression',
|
||||
{ tag: ['@regression'] },
|
||||
() => {
|
||||
test('keeps selected model visible in the dropdown', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// ...
|
||||
})
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
- **Mock data types** — follow `docs/guidance/playwright.md`: mock responses typed from `packages/ingest-types`, `packages/registry-types`, `src/schemas/` — never `as any`.
|
||||
|
||||
(The Handoff-Exclusion list above governs when `red-green-fix` is NOT invoked.)
|
||||
|
||||
### PR body template
|
||||
|
||||
The red-green-fix skill's PR template is extended with a `Source` line:
|
||||
|
||||
```markdown
|
||||
## Summary
|
||||
|
||||
<Root cause>
|
||||
|
||||
- Fixes LIN-NNN
|
||||
- Source: Slack <permalink>
|
||||
|
||||
## Red-Green Verification
|
||||
|
||||
| Commit | CI Status | Purpose |
|
||||
| ------------------------------------------ | -------------------- | ------------------------------- |
|
||||
| `test: LIN-NNN add failing test for <bug>` | :red_circle: Red | Proves the test catches the bug |
|
||||
| `fix: <bug summary>` | :green_circle: Green | Proves the fix resolves the bug |
|
||||
|
||||
## Test Plan
|
||||
|
||||
- [ ] Unit regression test passes locally
|
||||
- [ ] E2E regression test passes locally (if UI)
|
||||
- [ ] Manual repro no longer reproduces
|
||||
- [ ] Linear ticket linked
|
||||
```
|
||||
|
||||
After the PR merges, post the second thread reply on Slack (see Slack Thread Reply § Fix-path reply).
|
||||
|
||||
## Emoji Reaction Hints (read-only)
|
||||
|
||||
The agent cannot add reactions, but respects human-set reactions when filtering. The canonical team scheme (primary):
|
||||
|
||||
| Reaction | Meaning | Action |
|
||||
| -------------------- | ------------------ | -------------------------------------------------------- |
|
||||
| `:white_check_mark:` | Ticket created | Skip — already ingested |
|
||||
| `:pr-open:` | PR open | Skip creation; record PR link in session log |
|
||||
| `:question:` | Needs more context | Skip creation; agent may post a thread reply asking |
|
||||
| `:repeat:` | Duplicate | Skip creation; link existing Linear issue in session log |
|
||||
|
||||
Incidental reactions observed in the channel — treat as soft hints only, do NOT skip solely on these:
|
||||
|
||||
| Reaction | Meaning | Action |
|
||||
| -------- | ------------------- | -------------------------------------------------- |
|
||||
| `:eyes:` | Someone is triaging | Still ingestable |
|
||||
| `:done:` | Reporter resolved | Demote to `resolved` in verify, but still show row |
|
||||
| `:+1:` | Acknowledged | Ignore |
|
||||
|
||||
Approval-table response code `R` (new) corresponds to `:repeat:` — if you pick `R`, the skill treats it as duplicate and asks for the target Linear ID.
|
||||
|
||||
## Session Log
|
||||
|
||||
Append to `~/temp/bug-dump-ingest/session-YYYY-MM-DD.md`:
|
||||
|
||||
```text
|
||||
Bug Dump Ingest Session -- 2026-04-20 11:40 KST
|
||||
|
||||
Window: 2026-04-18 00:00 — 2026-04-20 12:00 KST
|
||||
Scanned: 28 top-level messages
|
||||
Skipped (meta/discussion/processed): 14
|
||||
Proposed: 14
|
||||
Approved: 11
|
||||
Created in Linear: 10
|
||||
Draft-only (creation failed): 1
|
||||
Linked-only (dedupe): 1
|
||||
Thread replies posted: 11
|
||||
|
||||
Created:
|
||||
- LIN-4710 Unet model dropdown missing selected model -- wavey -- low/ui
|
||||
- LIN-4711 Pro plan jobs end at 30 minutes -- Denys -- high/cloud
|
||||
- ...
|
||||
|
||||
Skipped with reason:
|
||||
- 1776592837.616399 -- design discussion in thread, not a bug
|
||||
- ...
|
||||
```
|
||||
|
||||
## Gotchas
|
||||
|
||||
### Thread summaries, not raw dumps
|
||||
|
||||
Pulling the full thread often adds noise. Summarize replies to: (a) confirmed reproductions by other users, (b) env/version details added in replies, (c) links to related PRs/commits. Drop emojis-only replies, joined-channel notifications, and off-topic chatter.
|
||||
|
||||
### Cross-posts are not bugs
|
||||
|
||||
When the top-level message is just a link to a Slack message in another channel (e.g. "X posting" with a URL and nothing else), follow the link to the original source and ingest from there — do NOT create a ticket from the cross-post itself.
|
||||
|
||||
### Resolved-in-thread messages
|
||||
|
||||
If the reporter replies `"No action needed, this is solved"` (see wavey 2026-04-20 08:06), mark the ticket for SKIP in the approval table, not auto-skip. The human may still want a regression test ticket.
|
||||
|
||||
### Permalinks
|
||||
|
||||
Construct Slack permalinks as:
|
||||
|
||||
```text
|
||||
https://comfy-organization.slack.com/archives/{CHANNEL_ID}/p{TS_WITH_DOT_REMOVED}
|
||||
```
|
||||
|
||||
E.g. `1776510375.473579` → `p1776510375473579`.
|
||||
|
||||
### Attachment handling
|
||||
|
||||
Slack file IDs (e.g. `F0AT...`) are private. Do NOT link them directly in Linear. Instead, list the filename and type in the Linear description and include the Slack permalink — anyone with Slack access can see the attachments from the thread.
|
||||
|
||||
### No auto-create without approval
|
||||
|
||||
Never create Linear issues without a human `Y`. This is a hard rule — the skill exists to reduce human toil, not to replace triage judgment.
|
||||
|
||||
## Reference Files
|
||||
|
||||
- `reference/linear-api.md` — `@Linear` Slack bot command reference (create, search, link, labels, status).
|
||||
- `reference/schema.md` — full ticket schema with field-by-field extraction notes.
|
||||
- `reference/examples.md` — worked examples drawn from real #bug-dump messages.
|
||||
- `reference/verify-commands.md` — cookbook of false-defect verification commands per bug class.
|
||||
|
||||
## Related Skills
|
||||
|
||||
- `red-green-fix` — auto-invoked via the `Skill` tool for every eligible `Y` candidate to produce a failing test + fix + PR with the red-green CI proof.
|
||||
- `writing-playwright-tests` — used by red-green-fix when an e2e test is needed.
|
||||
- `hardening-flaky-e2e-tests` — if the e2e test added in the fix PR starts flaking, jump to this skill.
|
||||
123
.claude/skills/bug-dump-ingest/reference/examples.md
Normal file
123
.claude/skills/bug-dump-ingest/reference/examples.md
Normal file
@@ -0,0 +1,123 @@
|
||||
# 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.
|
||||
160
.claude/skills/bug-dump-ingest/reference/linear-api.md
Normal file
160
.claude/skills/bug-dump-ingest/reference/linear-api.md
Normal file
@@ -0,0 +1,160 @@
|
||||
# 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.
|
||||
94
.claude/skills/bug-dump-ingest/reference/schema.md
Normal file
94
.claude/skills/bug-dump-ingest/reference/schema.md
Normal file
@@ -0,0 +1,94 @@
|
||||
# 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.
|
||||
99
.claude/skills/bug-dump-ingest/reference/verify-commands.md
Normal file
99
.claude/skills/bug-dump-ingest/reference/verify-commands.md
Normal file
@@ -0,0 +1,99 @@
|
||||
# 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.
|
||||
@@ -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/fixtures/utils/perfReporter.ts` |
|
||||
| Perf reporter | `browser_tests/helpers/perfReporter.ts` |
|
||||
| CI workflow | `.github/workflows/ci-perf-report.yaml` |
|
||||
| Report generator | `scripts/perf-report.ts` |
|
||||
| Stats utilities | `scripts/perf-stats.ts` |
|
||||
|
||||
@@ -1,156 +0,0 @@
|
||||
---
|
||||
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) |
|
||||
23
.github/actions/ashby-pull/action.yaml
vendored
23
.github/actions/ashby-pull/action.yaml
vendored
@@ -1,23 +0,0 @@
|
||||
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
|
||||
87
.github/actions/changes-filter/action.yaml
vendored
87
.github/actions/changes-filter/action.yaml
vendored
@@ -1,87 +0,0 @@
|
||||
# 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'
|
||||
BIN
.github/pr-images/fe-237-before-after.png
vendored
BIN
.github/pr-images/fe-237-before-after.png
vendored
Binary file not shown.
|
Before Width: | Height: | Size: 44 KiB |
147
.github/workflows/ci-deploy-preview.yaml
vendored
Normal file
147
.github/workflows/ci-deploy-preview.yaml
vendored
Normal file
@@ -0,0 +1,147 @@
|
||||
# Description: Builds ComfyUI frontend and deploys previews to Cloudflare Pages
|
||||
name: 'CI: Deploy Preview'
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# Post starting comment for non-forked PRs
|
||||
comment-on-pr-start:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Post starting comment
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
chmod +x scripts/cicd/pr-preview-deploy-and-comment.sh
|
||||
./scripts/cicd/pr-preview-deploy-and-comment.sh \
|
||||
"${{ github.event.pull_request.number }}" \
|
||||
"starting"
|
||||
|
||||
# Build frontend for all PRs and pushes
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
conclusion: ${{ steps.job-status.outputs.conclusion }}
|
||||
workflow-url: ${{ steps.workflow-url.outputs.url }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup frontend
|
||||
uses: ./.github/actions/setup-frontend
|
||||
|
||||
- name: Build frontend
|
||||
env:
|
||||
FRONTEND_COMMIT_HASH: ${{ github.sha }}
|
||||
CI_BRANCH: ${{ github.head_ref || github.ref_name }}
|
||||
CI_PR_NUMBER: ${{ github.event.pull_request.number || '' }}
|
||||
CI_PR_AUTHOR: ${{ github.event.pull_request.user.login || '' }}
|
||||
CI_RUN_ID: ${{ github.run_id }}
|
||||
CI_JOB_ID: ${{ github.job }}
|
||||
USE_PROD_CONFIG: 'true'
|
||||
run: pnpm build
|
||||
|
||||
- name: Set job status
|
||||
id: job-status
|
||||
if: always()
|
||||
run: |
|
||||
echo "conclusion=${{ job.status }}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Get workflow URL
|
||||
id: workflow-url
|
||||
if: always()
|
||||
run: |
|
||||
echo "url=${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Upload build artifact
|
||||
if: success() && github.event.pull_request.head.repo.fork == false
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: dist
|
||||
path: dist/
|
||||
retention-days: 7
|
||||
|
||||
# Deploy and comment for non-forked PRs only
|
||||
deploy-and-comment:
|
||||
needs: [comment-on-pr-start, build]
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false && always()
|
||||
permissions:
|
||||
pull-requests: write
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
|
||||
- name: Download build artifact
|
||||
if: needs.build.outputs.conclusion == 'success'
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: dist
|
||||
path: dist
|
||||
|
||||
- name: Make deployment script executable
|
||||
run: chmod +x scripts/cicd/pr-preview-deploy-and-comment.sh
|
||||
|
||||
- name: Deploy preview and comment on PR
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
WORKFLOW_CONCLUSION: ${{ needs.build.outputs.conclusion }}
|
||||
WORKFLOW_URL: ${{ needs.build.outputs.workflow-url }}
|
||||
BRANCH_NAME: ${{ github.head_ref }}
|
||||
run: |
|
||||
./scripts/cicd/pr-preview-deploy-and-comment.sh \
|
||||
"${{ github.event.pull_request.number }}" \
|
||||
"completed"
|
||||
|
||||
# Deploy to production URL on main branch push
|
||||
deploy-production:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup frontend
|
||||
uses: ./.github/actions/setup-frontend
|
||||
|
||||
- name: Build frontend
|
||||
env:
|
||||
FRONTEND_COMMIT_HASH: ${{ github.sha }}
|
||||
CI_BRANCH: ${{ github.ref_name }}
|
||||
CI_RUN_ID: ${{ github.run_id }}
|
||||
CI_JOB_ID: ${{ github.job }}
|
||||
run: pnpm build
|
||||
|
||||
- name: Deploy to Cloudflare Pages (production)
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
run: |
|
||||
pnpm dlx wrangler@^4.0.0 pages deploy dist \
|
||||
--project-name=comfy-ui \
|
||||
--branch=main
|
||||
17
.github/workflows/ci-dist-telemetry-scan.yaml
vendored
17
.github/workflows/ci-dist-telemetry-scan.yaml
vendored
@@ -12,30 +12,17 @@ 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@v6
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
cache: 'pnpm'
|
||||
|
||||
23
.github/workflows/ci-oss-assets-validation.yaml
vendored
23
.github/workflows/ci-oss-assets-validation.yaml
vendored
@@ -14,29 +14,16 @@ 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@v6
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
cache: 'pnpm'
|
||||
@@ -81,17 +68,15 @@ 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@v6
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
cache: 'pnpm'
|
||||
|
||||
18
.github/workflows/ci-perf-report.yaml
vendored
18
.github/workflows/ci-perf-report.yaml
vendored
@@ -3,8 +3,10 @@ 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 }}
|
||||
@@ -14,24 +16,12 @@ 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:
|
||||
needs: changes
|
||||
if: ${{ needs.changes.outputs.should-run == 'true' && github.repository == 'Comfy-Org/ComfyUI_frontend' }}
|
||||
if: github.repository == 'Comfy-Org/ComfyUI_frontend'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
container:
|
||||
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.17
|
||||
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.12
|
||||
credentials:
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
15
.github/workflows/ci-size-data.yaml
vendored
15
.github/workflows/ci-size-data.yaml
vendored
@@ -16,21 +16,8 @@ permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
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' }}
|
||||
if: github.repository == 'Comfy-Org/ComfyUI_frontend'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
|
||||
39
.github/workflows/ci-tests-e2e.yaml
vendored
39
.github/workflows/ci-tests-e2e.yaml
vendored
@@ -4,6 +4,7 @@ name: 'CI: Tests E2E'
|
||||
on:
|
||||
push:
|
||||
branches: [main, master, core/*, desktop/*]
|
||||
paths-ignore: ['**/*.md']
|
||||
pull_request:
|
||||
branches-ignore: [wip/*, draft/*, temp/*]
|
||||
merge_group:
|
||||
@@ -14,20 +15,36 @@ 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: ${{ steps.changes.outputs.should-run }}
|
||||
should_run: ${{ github.event_name != 'pull_request' || steps.filter.outputs.e2e }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- id: changes
|
||||
uses: ./.github/actions/changes-filter
|
||||
- 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'
|
||||
|
||||
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
|
||||
@@ -65,7 +82,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
container:
|
||||
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.17
|
||||
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.16
|
||||
credentials:
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -123,7 +140,7 @@ jobs:
|
||||
needs: setup
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.17
|
||||
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.16
|
||||
credentials:
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -177,7 +194,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
|
||||
@@ -216,7 +233,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: |
|
||||
@@ -234,7 +251,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
|
||||
}}
|
||||
@@ -261,7 +278,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
|
||||
}}
|
||||
|
||||
47
.github/workflows/ci-tests-storybook.yaml
vendored
47
.github/workflows/ci-tests-storybook.yaml
vendored
@@ -8,29 +8,10 @@ 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
|
||||
&& (needs.changes.outputs.storybook-changes == 'true'
|
||||
|| needs.changes.outputs.app-frontend-changes == 'true'
|
||||
|| needs.changes.outputs.packages-changes == 'true')
|
||||
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false
|
||||
permissions:
|
||||
pull-requests: write
|
||||
steps:
|
||||
@@ -49,13 +30,8 @@ jobs:
|
||||
|
||||
# Build Storybook for all PRs (free Cloudflare deployment)
|
||||
storybook-build:
|
||||
needs: changes
|
||||
runs-on: ubuntu-latest
|
||||
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')
|
||||
if: github.event_name == 'pull_request'
|
||||
outputs:
|
||||
conclusion: ${{ steps.job-status.outputs.conclusion }}
|
||||
workflow-url: ${{ steps.workflow-url.outputs.url }}
|
||||
@@ -91,15 +67,8 @@ 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-')
|
||||
&& (needs.changes.outputs.storybook-changes == 'true'
|
||||
|| needs.changes.outputs.app-frontend-changes == 'true'
|
||||
|| needs.changes.outputs.packages-changes == 'true'))
|
||||
if: github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && startsWith(github.head_ref, 'version-bump-'))
|
||||
outputs:
|
||||
conclusion: ${{ steps.job-status.outputs.conclusion }}
|
||||
workflow-url: ${{ steps.workflow-url.outputs.url }}
|
||||
@@ -138,15 +107,9 @@ jobs:
|
||||
|
||||
# Deploy and comment for non-forked PRs only
|
||||
deploy-and-comment:
|
||||
needs: [changes, storybook-build]
|
||||
needs: [storybook-build]
|
||||
runs-on: ubuntu-latest
|
||||
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')
|
||||
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false && always()
|
||||
permissions:
|
||||
pull-requests: write
|
||||
contents: read
|
||||
|
||||
15
.github/workflows/ci-tests-unit.yaml
vendored
15
.github/workflows/ci-tests-unit.yaml
vendored
@@ -4,8 +4,10 @@ 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:
|
||||
@@ -13,20 +15,7 @@ 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:
|
||||
|
||||
@@ -52,9 +52,6 @@ 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
|
||||
@@ -149,9 +146,6 @@ 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
|
||||
|
||||
25
.github/workflows/ci-website-build.yaml
vendored
25
.github/workflows/ci-website-build.yaml
vendored
@@ -4,29 +4,23 @@ 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:
|
||||
@@ -36,7 +30,4 @@ 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
|
||||
|
||||
36
.github/workflows/ci-website-e2e.yaml
vendored
36
.github/workflows/ci-website-e2e.yaml
vendored
@@ -2,30 +2,26 @@ name: 'CI: Website E2E'
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
branches: [main, website/*]
|
||||
paths:
|
||||
- 'apps/website/**'
|
||||
- 'packages/design-system/**'
|
||||
- 'packages/tailwind-utils/**'
|
||||
- 'pnpm-lock.yaml'
|
||||
pull_request:
|
||||
branches-ignore: [wip/*, draft/*, temp/*]
|
||||
paths:
|
||||
- 'apps/website/**'
|
||||
- 'packages/design-system/**'
|
||||
- 'packages/tailwind-utils/**'
|
||||
- 'pnpm-lock.yaml'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.repository }}-${{ github.head_ref || github.ref }}
|
||||
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
|
||||
|
||||
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
|
||||
@@ -49,8 +45,6 @@ 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
|
||||
@@ -167,11 +161,7 @@ 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.
|
||||
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')
|
||||
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: write
|
||||
|
||||
@@ -77,7 +77,7 @@ jobs:
|
||||
needs: setup
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.17
|
||||
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.16
|
||||
credentials:
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -18,7 +18,6 @@ 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.
|
||||
@@ -87,8 +86,6 @@ jobs:
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build website
|
||||
env:
|
||||
WEBSITE_GITHUB_STARS_OVERRIDE: 110000
|
||||
run: pnpm --filter @comfyorg/website build
|
||||
|
||||
- name: Update screenshots
|
||||
@@ -140,10 +137,7 @@ jobs:
|
||||
name: 'Update Website Screenshots'
|
||||
})
|
||||
} catch (e) {
|
||||
if (e.status !== 404) {
|
||||
throw e
|
||||
}
|
||||
core.info('Label "Update Website Screenshots" was already removed')
|
||||
// Label may already be removed
|
||||
}
|
||||
|
||||
post-starting-comment:
|
||||
|
||||
59
.github/workflows/release-website.yaml
vendored
59
.github/workflows/release-website.yaml
vendored
@@ -1,59 +0,0 @@
|
||||
# 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
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -99,4 +99,5 @@ vitest.config.*.timestamp*
|
||||
# Weekly docs check output
|
||||
/output.txt
|
||||
|
||||
.amp
|
||||
.amp
|
||||
.claude/scheduled_tasks.lock
|
||||
|
||||
@@ -1,13 +1,5 @@
|
||||
#!/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
|
||||
|
||||
|
||||
@@ -20,15 +20,15 @@
|
||||
}
|
||||
|
||||
.p-button-danger {
|
||||
background-color: var(--color-coral-700);
|
||||
background-color: var(--color-coral-red-600);
|
||||
}
|
||||
|
||||
.p-button-danger:hover {
|
||||
background-color: var(--color-coral-600);
|
||||
background-color: var(--color-coral-red-500);
|
||||
}
|
||||
|
||||
.p-button-danger:active {
|
||||
background-color: var(--color-coral-500);
|
||||
background-color: var(--color-coral-red-400);
|
||||
}
|
||||
|
||||
.task-div .p-card {
|
||||
|
||||
@@ -76,7 +76,7 @@ const executeTask = async (task: MaintenanceTask) => {
|
||||
|
||||
message = t('maintenance.error.taskFailed')
|
||||
} catch (error) {
|
||||
message = error instanceof Error ? error.message : undefined
|
||||
message = (error as Error)?.message
|
||||
}
|
||||
|
||||
toast.add({
|
||||
|
||||
@@ -66,7 +66,7 @@ class MaintenanceTaskRunner {
|
||||
this.error = undefined
|
||||
return true
|
||||
} catch (error) {
|
||||
this.error = error instanceof Error ? error.message : String(error)
|
||||
this.error = (error as Error)?.message
|
||||
throw error
|
||||
} finally {
|
||||
this.executing = false
|
||||
|
||||
@@ -113,31 +113,6 @@ 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
|
||||
|
||||
@@ -69,50 +69,6 @@ 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()
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
@@ -26,7 +26,6 @@
|
||||
"cva": "catalog:",
|
||||
"gsap": "catalog:",
|
||||
"lenis": "catalog:",
|
||||
"posthog-js": "catalog:",
|
||||
"vue": "catalog:",
|
||||
"zod": "catalog:"
|
||||
},
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,33 +1,4 @@
|
||||
# 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
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,110 +0,0 @@
|
||||
#!/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."
|
||||
@@ -1,51 +0,0 @@
|
||||
# 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.
|
||||
@@ -88,7 +88,7 @@ const contactColumn = {
|
||||
{ label: t('footer.sales', locale), href: routes.contact },
|
||||
{
|
||||
label: t('footer.support', locale),
|
||||
href: externalLinks.support,
|
||||
href: externalLinks.discord,
|
||||
external: true
|
||||
},
|
||||
{ label: t('footer.press', locale), href: 'mailto:press@comfy.org' }
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
<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>
|
||||
@@ -1,12 +1,13 @@
|
||||
<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
|
||||
@@ -16,6 +17,30 @@ 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>()
|
||||
@@ -30,6 +55,10 @@ useHeroAnimation({
|
||||
video: formRef,
|
||||
parallax: false
|
||||
})
|
||||
|
||||
function handleSubmit() {
|
||||
// TODO: implement form submission
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -76,7 +105,160 @@ useHeroAnimation({
|
||||
|
||||
<!-- Right column: form -->
|
||||
<div ref="formRef" class="mt-12 lg:mt-0 lg:w-1/2">
|
||||
<HubspotFormEmbed :locale />
|
||||
<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>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
<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>
|
||||
@@ -35,10 +35,7 @@ const routes = getRoutes(locale)
|
||||
</div>
|
||||
|
||||
<!-- Right: content -->
|
||||
<div
|
||||
data-testid="case-study-content"
|
||||
class="flex flex-col justify-between p-6 lg:flex-1"
|
||||
>
|
||||
<div 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"
|
||||
@@ -55,8 +52,12 @@ const routes = getRoutes(locale)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 flex flex-col items-start gap-3 sm:flex-row lg:mt-0">
|
||||
<BrandButton :href="routes.customers" variant="outline">
|
||||
<div class="flex flex-col gap-3 sm:flex-row">
|
||||
<BrandButton
|
||||
:href="routes.customers"
|
||||
variant="outline"
|
||||
class="flex-1 text-center"
|
||||
>
|
||||
{{ t('caseStudy.seeAll', locale) }}
|
||||
</BrandButton>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
<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>
|
||||
@@ -34,15 +32,6 @@ 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>
|
||||
|
||||
@@ -106,11 +106,6 @@ 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
|
||||
|
||||
@@ -121,29 +116,31 @@ 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,
|
||||
mediaQuery: parallaxMediaQuery
|
||||
end: pinScrubEnd
|
||||
})
|
||||
useParallax([leftImgRef], {
|
||||
trigger: sectionRef,
|
||||
fromY: (el) => travelRange(el),
|
||||
y: (el) => -travelRange(el),
|
||||
start: 'top top',
|
||||
end: pinScrubEnd,
|
||||
mediaQuery: parallaxMediaQuery
|
||||
end: pinScrubEnd
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
ref="sectionRef"
|
||||
class="bg-primary-comfy-ink relative isolate overflow-x-clip pt-20 lg:h-[calc(100vh+60px)] lg:py-24"
|
||||
:class="
|
||||
cn(
|
||||
'bg-primary-comfy-ink relative isolate overflow-x-clip pt-20 lg:py-24',
|
||||
isEnabled && 'lg:h-[calc(100vh+60px)]'
|
||||
)
|
||||
"
|
||||
>
|
||||
<svg class="absolute size-0" width="0" height="0" aria-hidden="true">
|
||||
<defs>
|
||||
@@ -205,8 +202,6 @@ useParallax([leftImgRef], {
|
||||
"
|
||||
:aria-current="index === activeCategory ? 'true' : undefined"
|
||||
@click="scrollToIndex(index)"
|
||||
@mouseenter="onCategoryHover(index)"
|
||||
@focus="onCategoryHover(index)"
|
||||
>
|
||||
{{ category.label }}
|
||||
</button>
|
||||
|
||||
@@ -101,9 +101,17 @@ const features: IncludedFeature[] = [
|
||||
class="mt-0.5 size-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<p class="text-primary-comfy-canvas text-sm font-medium">
|
||||
{{ t(feature.titleKey, locale) }}
|
||||
</p>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
|
||||
@@ -20,9 +20,6 @@ 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[],
|
||||
@@ -69,8 +66,7 @@ export function usePinScrub(refs: PinScrubRefs, options: PinScrubOptions) {
|
||||
!refs.section.value ||
|
||||
!refs.content.value ||
|
||||
!refs.nav.value ||
|
||||
prefersReducedMotion() ||
|
||||
!window.matchMedia(PIN_SCRUB_MEDIA_QUERY).matches
|
||||
prefersReducedMotion()
|
||||
)
|
||||
return
|
||||
const section: HTMLElement = refs.section.value
|
||||
|
||||
@@ -52,15 +52,6 @@ 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'
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -35,7 +35,6 @@ 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
|
||||
|
||||
@@ -1,10 +1,24 @@
|
||||
{
|
||||
"fetchedAt": "2026-05-02T20:15:18.321Z",
|
||||
"fetchedAt": "2026-04-24T18:59:03.989Z",
|
||||
"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",
|
||||
@@ -19,6 +33,13 @@
|
||||
"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",
|
||||
@@ -94,13 +115,6 @@
|
||||
"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"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -108,27 +122,6 @@
|
||||
"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",
|
||||
@@ -151,7 +144,7 @@
|
||||
"roles": [
|
||||
{
|
||||
"id": "ec68ae44dd5943c9",
|
||||
"title": "Talent Lead",
|
||||
"title": "Senior Technical Recruiter",
|
||||
"department": "Operations",
|
||||
"location": "San Francisco",
|
||||
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/d5008532-c45d-46e6-ba2c-20489d364362/application"
|
||||
|
||||
@@ -4,16 +4,12 @@ const translations = {
|
||||
// HeroSection
|
||||
'hero.title': {
|
||||
en: 'Professional Control\nof Visual AI',
|
||||
'zh-CN': '视觉 AI 的\n最强可控性'
|
||||
'zh-CN': '视觉 AI 的\n专业控制'
|
||||
},
|
||||
'hero.subtitle': {
|
||||
en: 'Comfy is the AI creation engine for visual professionals who demand control over every model, every parameter, and every output.',
|
||||
'zh-CN':
|
||||
'Comfy 是面向专业视觉人士的 AI 创作引擎。您可以精确掌控每个模型、每个参数和每个输出。'
|
||||
},
|
||||
'hero.runFirstWorkflow': {
|
||||
en: 'Run your first workflow',
|
||||
'zh-CN': '运行你的第一个工作流'
|
||||
'Comfy 是面向视觉专业人士的 AI 创作引擎,让您掌控每个模型、每个参数和每个输出。'
|
||||
},
|
||||
|
||||
// ProductShowcaseSection
|
||||
@@ -24,11 +20,11 @@ const translations = {
|
||||
},
|
||||
'showcase.subtitle2': {
|
||||
en: 'Start from a community template or build from scratch.',
|
||||
'zh-CN': '从工作流模板开始,或从零构建。'
|
||||
'zh-CN': '从社区模板开始,或从零构建。'
|
||||
},
|
||||
'showcase.feature1.title': {
|
||||
en: 'Full Control with Nodes',
|
||||
'zh-CN': '节点带来的可控性'
|
||||
'zh-CN': '节点式完全控制'
|
||||
},
|
||||
'showcase.feature1.description': {
|
||||
en: 'Build powerful AI pipelines by connecting nodes on an infinite canvas. Every model, parameter, and processing step is visible and adjustable.',
|
||||
@@ -53,8 +49,8 @@ const translations = {
|
||||
'zh-CN':
|
||||
'浏览和混搭数千个社区共享的工作流。从经过验证的模板开始,按需自定义。'
|
||||
},
|
||||
'showcase.badgeHow': { en: 'HOW', 'zh-CN': '了解' },
|
||||
'showcase.badgeWorks': { en: 'WORKS', 'zh-CN': '运行方式' },
|
||||
'showcase.badgeHow': { en: 'HOW', 'zh-CN': '如何' },
|
||||
'showcase.badgeWorks': { en: 'WORKS', 'zh-CN': '运作' },
|
||||
|
||||
// UseCaseSection
|
||||
'useCase.label': {
|
||||
@@ -87,7 +83,8 @@ const translations = {
|
||||
},
|
||||
'useCase.body': {
|
||||
en: 'Powered by 60,000+ nodes, thousands of workflows,\nand a community that builds faster than any one company could.',
|
||||
'zh-CN': '60,000+ 节点,数千条工作流,\n一个比任何公司速度都更快的社区。'
|
||||
'zh-CN':
|
||||
'由 60,000+ 节点、数千个工作流\n和一个比任何公司都更快构建的社区驱动。'
|
||||
},
|
||||
'useCase.cta': {
|
||||
en: 'EXPLORE WORKFLOWS',
|
||||
@@ -167,7 +164,7 @@ const translations = {
|
||||
},
|
||||
'products.local.cta': {
|
||||
en: 'SEE LOCAL FEATURES',
|
||||
'zh-CN': '查看本地版属性'
|
||||
'zh-CN': '查看本地版特性'
|
||||
},
|
||||
'products.cloud.title': {
|
||||
en: 'Comfy\nCloud',
|
||||
@@ -179,7 +176,7 @@ const translations = {
|
||||
},
|
||||
'products.cloud.cta': {
|
||||
en: 'SEE CLOUD FEATURES',
|
||||
'zh-CN': '查看云端属性'
|
||||
'zh-CN': '查看云端特性'
|
||||
},
|
||||
'products.api.title': {
|
||||
en: 'Comfy\nAPI',
|
||||
@@ -191,7 +188,7 @@ const translations = {
|
||||
},
|
||||
'products.api.cta': {
|
||||
en: 'SEE API FEATURES',
|
||||
'zh-CN': '查看 API 属性'
|
||||
'zh-CN': '查看 API 特性'
|
||||
},
|
||||
'products.enterprise.title': {
|
||||
en: 'Comfy\nEnterprise',
|
||||
@@ -203,7 +200,7 @@ const translations = {
|
||||
},
|
||||
'products.enterprise.cta': {
|
||||
en: 'SEE ENTERPRISE FEATURES',
|
||||
'zh-CN': '查看企业版属性'
|
||||
'zh-CN': '查看企业版特性'
|
||||
},
|
||||
|
||||
// CaseStudySpotlightSection
|
||||
@@ -914,9 +911,9 @@ const translations = {
|
||||
'zh-CN': '我应该选择 Comfy Cloud 还是本地 ComfyUI(自托管)?'
|
||||
},
|
||||
'cloud.faq.3.a': {
|
||||
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.",
|
||||
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.",
|
||||
'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?',
|
||||
@@ -1218,7 +1215,7 @@ const translations = {
|
||||
'pricing.included.feature4.description': {
|
||||
en: 'All plans will include a monthly pool of credits that are spent on active workflow runtime and <a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">Partner Nodes</a> like Nano Banana Pro.',
|
||||
'zh-CN':
|
||||
'所有计划均包含每月积分池,可用于工作流运行和<a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">合作伙伴节点</a>(如 Nano Banana Pro)。'
|
||||
'所有计划均包含每月积分池,可用于工作流运行和<a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">合作节点</a>(如 Nano Banana Pro)。'
|
||||
},
|
||||
'pricing.included.feature5.title': {
|
||||
en: 'Add more credits anytime',
|
||||
@@ -1248,12 +1245,12 @@ const translations = {
|
||||
},
|
||||
'pricing.included.feature8.title': {
|
||||
en: 'Partner Nodes',
|
||||
'zh-CN': '合作伙伴节点'
|
||||
'zh-CN': '合作节点'
|
||||
},
|
||||
'pricing.included.feature8.description': {
|
||||
en: 'Run <strong>proprietary models</strong> through Comfy\'s <a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">Partner Nodes</a>, such as Nano Banana. The amount of credits each node uses depends on the model and parameters you set in the node, but these credits are the same ones that your monthly subscription comes with. These credits can also be used across Comfy Cloud and local ComfyUI. Read more about Partner nodes <a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">here</a>.',
|
||||
'zh-CN':
|
||||
'通过 Comfy 的<a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">合作伙伴节点</a>运行<strong>专有模型</strong>,如 Nano Banana。每个节点消耗的积分取决于所用模型和参数设置,且与月度订阅积分通用。积分可在 Comfy Cloud 和本地 ComfyUI 间通用。了解更多关于合作伙伴节点的信息请点击<a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">此处</a>。'
|
||||
'通过 Comfy 的<a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">合作节点</a>运行<strong>专有模型</strong>,如 Nano Banana。每个节点消耗的积分取决于所用模型和参数设置,且与月度订阅积分通用。积分可在 Comfy Cloud 和本地 ComfyUI 间通用。了解更多关于合作节点的信息请点击<a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">此处</a>。'
|
||||
},
|
||||
'pricing.included.feature9.title': {
|
||||
en: 'Job queue',
|
||||
@@ -1280,6 +1277,10 @@ 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,20 +2244,6 @@ 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': '阅读更多相关内容'
|
||||
@@ -3290,227 +3277,6 @@ 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',
|
||||
@@ -3533,13 +3299,82 @@ const translations = {
|
||||
en: 'Find your answer here',
|
||||
'zh-CN': '在这里找到答案'
|
||||
},
|
||||
'contact.form.embedLoadErrorPrefix': {
|
||||
en: 'Unable to load the contact form. Email us at',
|
||||
'zh-CN': '联系表单无法加载。请发送邮件至'
|
||||
'contact.form.firstName': {
|
||||
en: 'First name',
|
||||
'zh-CN': '名'
|
||||
},
|
||||
'contact.form.embedLoadErrorSuffix': {
|
||||
en: "and we'll route your request.",
|
||||
'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'
|
||||
},
|
||||
|
||||
'customers.story.whatsNext': {
|
||||
|
||||
@@ -133,15 +133,9 @@ 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()
|
||||
})
|
||||
|
||||
@@ -10,7 +10,7 @@ import GetStartedSection from '../../components/home/GetStartedSection.vue'
|
||||
import BuildWhatSection from '../../components/home/BuildWhatSection.vue'
|
||||
---
|
||||
|
||||
<BaseLayout title="Comfy — 视觉 AI 的最强可控性">
|
||||
<BaseLayout title="Comfy — 视觉 AI 的专业控制">
|
||||
<HeroSection locale="zh-CN" client:load />
|
||||
<SocialProofBarSection />
|
||||
<ProductShowcaseSection locale="zh-CN" client:load />
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -2,9 +2,6 @@ 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' }
|
||||
@@ -28,17 +25,3 @@ 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
|
||||
}
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
export const MARKETING_FORMATS = ['avif', 'webp'] as const
|
||||
|
||||
export const MARKETING_WIDTHS = [640, 960, 1280, 1920] as const
|
||||
@@ -1,111 +0,0 @@
|
||||
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))
|
||||
})
|
||||
})
|
||||
@@ -1,49 +0,0 @@
|
||||
/** @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('|')
|
||||
}
|
||||
@@ -7,15 +7,6 @@
|
||||
"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",
|
||||
|
||||
@@ -15,15 +15,11 @@ 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 classes (locators, user interactions)
|
||||
│ │ ├── Actionbar.ts
|
||||
│ ├── components/ - Page object components (locators, user interactions)
|
||||
│ │ ├── ContextMenu.ts
|
||||
│ │ ├── ManageGroupNode.ts
|
||||
│ │ ├── SettingDialog.ts
|
||||
│ │ ├── SidebarTab.ts
|
||||
│ │ ├── Templates.ts
|
||||
│ │ ├── Topbar.ts
|
||||
│ │ └── ...
|
||||
│ │ └── Topbar.ts
|
||||
│ ├── helpers/ - Focused helper classes (domain-specific actions)
|
||||
│ │ ├── CanvasHelper.ts
|
||||
│ │ ├── CommandHelper.ts
|
||||
@@ -32,36 +28,17 @@ browser_tests/
|
||||
│ │ ├── SettingsHelper.ts
|
||||
│ │ ├── WorkflowHelper.ts
|
||||
│ │ └── ...
|
||||
│ └── utils/ - Standalone utility functions (used by tests or fixtures)
|
||||
│ ├── builderTestUtils.ts
|
||||
│ ├── clipboardSpy.ts
|
||||
│ ├── fitToView.ts
|
||||
│ ├── perfReporter.ts
|
||||
│ └── ...
|
||||
│ └── utils/ - Pure utility functions (no page dependency)
|
||||
├── helpers/ - Test-specific utilities
|
||||
└── 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. 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/]
|
||||
```
|
||||
- **`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.
|
||||
|
||||
## Page Object Locator Style
|
||||
|
||||
|
||||
@@ -51,7 +51,6 @@ 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
|
||||
@@ -96,17 +95,6 @@ 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:
|
||||
@@ -151,9 +139,12 @@ 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
|
||||
- **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`)
|
||||
- **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
|
||||
|
||||
Most common testing needs are already addressed by these helpers, which will make your tests more consistent and reliable.
|
||||
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
@@ -123,7 +123,7 @@
|
||||
"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",
|
||||
"url": "https://huggingface.co/Comfy-Org/stable-diffusion-v1-5-archive/resolve/main/v1-5-pruned-emaonly-fp16.safetensors?download=true",
|
||||
"directory": "checkpoints"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
{
|
||||
"id": "2f54e2f0-6db4-4bdf-84a8-9c3ea3ec0123",
|
||||
"revision": 0,
|
||||
"last_node_id": 13,
|
||||
"last_link_id": 9,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 11,
|
||||
"type": "422723e8-4bf6-438c-823f-881ca81acead",
|
||||
"pos": [120, 180],
|
||||
"size": [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
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Locator, Mouse } from '@playwright/test'
|
||||
import type { Mouse } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import type { Position } from '@e2e/fixtures/types'
|
||||
@@ -72,22 +72,6 @@ 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)
|
||||
|
||||
@@ -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/fixtures/components/Actionbar'
|
||||
import { ComfyTemplates } from '@e2e/fixtures/components/Templates'
|
||||
import { ComfyActionbar } from '@e2e/helpers/actionbar'
|
||||
import { ComfyTemplates } from '@e2e/helpers/templates'
|
||||
import { ComfyMouse } from '@e2e/fixtures/ComfyMouse'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import { comfyExpect } from '@e2e/fixtures/utils/customMatchers'
|
||||
@@ -22,7 +22,6 @@ 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,
|
||||
@@ -55,13 +54,11 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,7 +137,6 @@ 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
|
||||
@@ -163,7 +159,6 @@ 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
|
||||
@@ -200,7 +195,6 @@ 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)
|
||||
@@ -210,14 +204,13 @@ export class ComfyPage {
|
||||
this.workflowUploadInput = page.locator('#comfy-file-input')
|
||||
|
||||
this.searchBox = new ComfyNodeSearchBox(page)
|
||||
this.searchBoxV2 = new ComfyNodeSearchBoxV2(this)
|
||||
this.searchBoxV2 = new ComfyNodeSearchBoxV2(page)
|
||||
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)
|
||||
@@ -243,7 +236,7 @@ export class ComfyPage {
|
||||
}
|
||||
|
||||
async setupUser(username: string) {
|
||||
const res = await this.request.get(`${this.apiUrl}/api/users`)
|
||||
const res = await this.request.get(`${this.url}/api/users`)
|
||||
if (res.status() !== 200)
|
||||
throw new Error(`Failed to retrieve users: ${await res.text()}`)
|
||||
|
||||
@@ -257,7 +250,7 @@ export class ComfyPage {
|
||||
}
|
||||
|
||||
async createUser(username: string) {
|
||||
const resp = await this.request.post(`${this.apiUrl}/api/users`, {
|
||||
const resp = await this.request.post(`${this.url}/api/users`, {
|
||||
data: { username }
|
||||
})
|
||||
|
||||
@@ -269,7 +262,7 @@ export class ComfyPage {
|
||||
|
||||
async setupSettings(settings: Record<string, unknown>) {
|
||||
const resp = await this.request.post(
|
||||
`${this.apiUrl}/api/devtools/set_settings`,
|
||||
`${this.url}/api/devtools/set_settings`,
|
||||
{
|
||||
data: settings
|
||||
}
|
||||
|
||||
@@ -30,13 +30,6 @@ 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.
|
||||
@@ -126,9 +119,10 @@ export class VueNodeHelpers {
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the data-node-id of the first rendered node matching the title.
|
||||
* 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 getNodeIdByTitle(title: string): Promise<string> {
|
||||
async getFixtureByTitle(title: string): Promise<VueNodeFixture> {
|
||||
const node = this.getNodeByTitle(title).first()
|
||||
await node.waitFor({ state: 'visible' })
|
||||
|
||||
@@ -139,15 +133,6 @@ 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))
|
||||
}
|
||||
|
||||
|
||||
@@ -1,112 +1,29 @@
|
||||
import type { Locator } from '@playwright/test'
|
||||
import type { Locator, Page } 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
|
||||
readonly input: Locator
|
||||
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(private comfyPage: ComfyPage) {
|
||||
const page = comfyPage.page
|
||||
constructor(readonly page: 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(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
|
||||
)
|
||||
this.input = this.dialog.locator('input[type="text"]')
|
||||
this.results = this.dialog.getByTestId('result-item')
|
||||
this.filterOptions = this.dialog.getByTestId('filter-option')
|
||||
}
|
||||
|
||||
/** Sidebar category tree button (e.g. `sampling`, `sampling/custom_sampling`). */
|
||||
categoryButton(categoryId: string): Locator {
|
||||
return this.dialog.getByTestId(searchBoxV2.category(categoryId))
|
||||
return this.dialog.getByTestId(`category-${categoryId}`)
|
||||
}
|
||||
|
||||
/** Top filter-bar root category chip (e.g. `comfy`, `essentials`). */
|
||||
rootCategoryButton(id: RootCategoryId): Locator {
|
||||
return this.dialog.getByTestId(searchBoxV2.rootCategory(id))
|
||||
filterBarButton(name: string): Locator {
|
||||
return this.dialog.getByRole('button', { name })
|
||||
}
|
||||
|
||||
/** 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'
|
||||
)
|
||||
async reload(comfyPage: ComfyPage) {
|
||||
await comfyPage.settings.setSetting('Comfy.NodeSearchBoxImpl', 'default')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,13 +4,11 @@ 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')
|
||||
}
|
||||
|
||||
@@ -41,10 +39,7 @@ export class ContextMenu {
|
||||
const litegraphVisible = await this.litegraphMenu
|
||||
.isVisible()
|
||||
.catch(() => false)
|
||||
const litegraphContextVisible = await this.litegraphContextMenu
|
||||
.isVisible()
|
||||
.catch(() => false)
|
||||
return primeVueVisible || litegraphVisible || litegraphContextVisible
|
||||
return primeVueVisible || litegraphVisible
|
||||
}
|
||||
|
||||
async assertHasItems(items: string[]): Promise<void> {
|
||||
@@ -76,8 +71,7 @@ export class ContextMenu {
|
||||
async waitForHidden(): Promise<void> {
|
||||
await Promise.all([
|
||||
this.primeVueMenu.waitFor({ state: 'hidden' }),
|
||||
this.litegraphMenu.waitFor({ state: 'hidden' }),
|
||||
this.litegraphContextMenu.waitFor({ state: 'hidden' })
|
||||
this.litegraphMenu.waitFor({ state: 'hidden' })
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -250,26 +250,6 @@ export class ModelLibrarySidebarTab extends SidebarTab {
|
||||
}
|
||||
}
|
||||
|
||||
type MediaFilterKind = 'image' | 'video' | 'audio' | '3d'
|
||||
type MediaFilterLabel = 'Image' | 'Video' | 'Audio' | '3D'
|
||||
|
||||
function getMediaFilterLabel(
|
||||
filter: MediaFilterKind | MediaFilterLabel
|
||||
): MediaFilterLabel {
|
||||
switch (filter) {
|
||||
case 'image':
|
||||
return 'Image'
|
||||
case 'video':
|
||||
return 'Video'
|
||||
case 'audio':
|
||||
return 'Audio'
|
||||
case '3d':
|
||||
return '3D'
|
||||
default:
|
||||
return filter
|
||||
}
|
||||
}
|
||||
|
||||
export class AssetsSidebarTab extends SidebarTab {
|
||||
// --- Tab navigation ---
|
||||
public readonly generatedTab: Locator
|
||||
@@ -283,12 +263,6 @@ export class AssetsSidebarTab extends SidebarTab {
|
||||
public readonly settingsButton: Locator
|
||||
public readonly filterButton: Locator
|
||||
|
||||
// --- Filter menu checkboxes (cloud-only, shown inside filter popover) ---
|
||||
public readonly filterImageCheckbox: Locator
|
||||
public readonly filterVideoCheckbox: Locator
|
||||
public readonly filterAudioCheckbox: Locator
|
||||
public readonly filter3DCheckbox: Locator
|
||||
|
||||
// --- View mode ---
|
||||
public readonly listViewOption: Locator
|
||||
public readonly gridViewOption: Locator
|
||||
@@ -296,8 +270,6 @@ export class AssetsSidebarTab extends SidebarTab {
|
||||
// --- Sort options (cloud-only, shown inside settings popover) ---
|
||||
public readonly sortNewestFirst: Locator
|
||||
public readonly sortOldestFirst: Locator
|
||||
public readonly sortLongestFirst: Locator
|
||||
public readonly sortFastestFirst: Locator
|
||||
|
||||
// --- Asset cards ---
|
||||
public readonly assetCards: Locator
|
||||
@@ -329,16 +301,10 @@ export class AssetsSidebarTab extends SidebarTab {
|
||||
this.searchInput = page.getByPlaceholder('Search Assets...')
|
||||
this.settingsButton = page.getByRole('button', { name: 'View settings' })
|
||||
this.filterButton = page.getByRole('button', { name: 'Filter by' })
|
||||
this.filterImageCheckbox = page.getByRole('checkbox', { name: 'Image' })
|
||||
this.filterVideoCheckbox = page.getByRole('checkbox', { name: 'Video' })
|
||||
this.filterAudioCheckbox = page.getByRole('checkbox', { name: 'Audio' })
|
||||
this.filter3DCheckbox = page.getByRole('checkbox', { name: '3D' })
|
||||
this.listViewOption = page.getByText('List view')
|
||||
this.gridViewOption = page.getByText('Grid view')
|
||||
this.sortNewestFirst = page.getByText('Newest first')
|
||||
this.sortOldestFirst = page.getByText('Oldest first')
|
||||
this.sortLongestFirst = page.getByText('Generation time (longest first)')
|
||||
this.sortFastestFirst = page.getByText('Generation time (fastest first)')
|
||||
this.assetCards = page
|
||||
.getByRole('button')
|
||||
.and(page.locator('[data-selected]'))
|
||||
@@ -370,10 +336,8 @@ export class AssetsSidebarTab extends SidebarTab {
|
||||
return this.page.getByText(title)
|
||||
}
|
||||
|
||||
filterCheckbox(filter: MediaFilterKind | MediaFilterLabel) {
|
||||
return this.page.getByRole('checkbox', {
|
||||
name: getMediaFilterLabel(filter)
|
||||
})
|
||||
filterCheckbox(label: string) {
|
||||
return this.page.getByRole('checkbox', { name: label })
|
||||
}
|
||||
|
||||
getAssetCardByName(name: string) {
|
||||
@@ -428,26 +392,13 @@ export class AssetsSidebarTab extends SidebarTab {
|
||||
async openFilterMenu() {
|
||||
await this.dismissToasts()
|
||||
await this.filterButton.click()
|
||||
// Wait for popover content with checkboxes to render
|
||||
await this.filterCheckbox('Image').waitFor({
|
||||
state: 'visible',
|
||||
timeout: 3000
|
||||
})
|
||||
}
|
||||
|
||||
async toggleMediaTypeFilter(
|
||||
filter: MediaFilterKind | MediaFilterLabel
|
||||
): Promise<void> {
|
||||
const checkbox = this.filterCheckbox(filter)
|
||||
const before = await checkbox.getAttribute('aria-checked')
|
||||
await checkbox.click()
|
||||
const expected = before === 'true' ? 'false' : 'true'
|
||||
await expect(checkbox).toHaveAttribute('aria-checked', expected)
|
||||
}
|
||||
|
||||
async getAssetCardOrder(): Promise<string[]> {
|
||||
return await this.assetCards.allInnerTexts()
|
||||
}
|
||||
|
||||
async rightClickAsset(name: string) {
|
||||
const card = this.getAssetCardByName(name)
|
||||
await card.click({ button: 'right' })
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
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')
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,72 +1,32 @@
|
||||
import type { Page, Route } from '@playwright/test'
|
||||
import type {
|
||||
CreateAssetExportData,
|
||||
CreateAssetExportResponse,
|
||||
JobsListResponse,
|
||||
ListAssetsResponse
|
||||
} from '@comfyorg/ingest-types'
|
||||
import type { JobsListResponse } from '@comfyorg/ingest-types'
|
||||
|
||||
import type {
|
||||
JobDetail,
|
||||
RawJobListItem
|
||||
} from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||
import type { RawJobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||
|
||||
const jobsListRoutePattern = '**/api/jobs?*'
|
||||
const assetsListRoutePattern = /\/api\/assets(?:\?.*)?$/
|
||||
const assetExportRoutePattern = '**/api/assets/export'
|
||||
const jobsListRoutePattern = /\/api\/jobs(?:\?.*)?$/
|
||||
const inputFilesRoutePattern = /\/internal\/files\/input(?:\?.*)?$/
|
||||
const historyRoutePattern = /\/api\/history$/
|
||||
|
||||
/**
|
||||
* Media kinds supported by the assets sidebar filter UI. The string values
|
||||
* match what the backend stores on `preview_output.mediaType` (`images` is
|
||||
* intentionally plural to match existing API conventions; the others are
|
||||
* singular as emitted by `useMediaAssetGalleryStore`).
|
||||
*
|
||||
* The sidebar filter ultimately matches on the filename extension, so the
|
||||
* fixture also picks an extension-appropriate filename for each kind.
|
||||
*/
|
||||
export type MediaKindFixture = 'images' | 'video' | 'audio' | '3D'
|
||||
|
||||
const DEFAULT_EXTENSION: Record<MediaKindFixture, string> = {
|
||||
images: 'png',
|
||||
video: 'mp4',
|
||||
audio: 'wav',
|
||||
'3D': 'glb'
|
||||
}
|
||||
|
||||
/** Factory to create a mock completed job with preview output. */
|
||||
export function createMockJob(
|
||||
overrides: Partial<RawJobListItem> & {
|
||||
id: string
|
||||
/**
|
||||
* Optional shorthand to set both `preview_output.mediaType` and an
|
||||
* extension-appropriate filename. Ignored when `preview_output` is also
|
||||
* supplied via `overrides`.
|
||||
*/
|
||||
mediaKind?: MediaKindFixture
|
||||
}
|
||||
overrides: Partial<RawJobListItem> & { id: string }
|
||||
): RawJobListItem {
|
||||
const { mediaKind, ...rest } = overrides
|
||||
const now = Date.now()
|
||||
const extension = mediaKind ? DEFAULT_EXTENSION[mediaKind] : 'png'
|
||||
const mediaType = mediaKind ?? 'images'
|
||||
|
||||
return {
|
||||
status: 'completed',
|
||||
create_time: now,
|
||||
execution_start_time: now,
|
||||
execution_end_time: now + 5000,
|
||||
preview_output: {
|
||||
filename: `output_${rest.id}.${extension}`,
|
||||
filename: `output_${overrides.id}.png`,
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
nodeId: '1',
|
||||
mediaType
|
||||
mediaType: 'images'
|
||||
},
|
||||
outputs_count: 1,
|
||||
priority: 0,
|
||||
...rest
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,46 +54,6 @@ export function createMockJobs(
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create one job per requested media kind, in the order supplied. Jobs share
|
||||
* a stable timestamp ordering (newer first) so callers can rely on the result
|
||||
* order when mediaType filters are inactive.
|
||||
*/
|
||||
export function createMixedMediaJobs(
|
||||
kinds: MediaKindFixture[]
|
||||
): RawJobListItem[] {
|
||||
const now = Date.now()
|
||||
return kinds.map((kind, i) =>
|
||||
createMockJob({
|
||||
id: `${kind}-${String(i + 1).padStart(3, '0')}`,
|
||||
mediaKind: kind,
|
||||
create_time: now - i * 60_000,
|
||||
execution_start_time: now - i * 60_000,
|
||||
execution_end_time: now - i * 60_000 + 5000
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create jobs with explicit `(create_time, execution duration)` pairs so that
|
||||
* sort assertions for newest/oldest and longest/fastest are unambiguous.
|
||||
*
|
||||
* Each spec entry yields a job whose `execution_end_time - execution_start_time`
|
||||
* equals `durationMs`. The first spec becomes id `job-001`, etc.
|
||||
*/
|
||||
export function createJobsWithExecutionTimes(
|
||||
specs: ReadonlyArray<{ createTime: number; durationMs: number }>
|
||||
): RawJobListItem[] {
|
||||
return specs.map((spec, i) =>
|
||||
createMockJob({
|
||||
id: `job-${String(i + 1).padStart(3, '0')}`,
|
||||
create_time: spec.createTime,
|
||||
execution_start_time: spec.createTime,
|
||||
execution_end_time: spec.createTime + spec.durationMs
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
/** Create mock imported file names with various media types. */
|
||||
export function createMockImportedFiles(count: number): string[] {
|
||||
const extensions = ['png', 'jpg', 'mp4', 'wav', 'glb', 'txt']
|
||||
@@ -168,23 +88,12 @@ 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) {}
|
||||
|
||||
@@ -261,82 +170,6 @@ 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]
|
||||
|
||||
@@ -392,9 +225,6 @@ export class AssetsHelper {
|
||||
|
||||
async clearMocks(): Promise<void> {
|
||||
this.generatedJobs = []
|
||||
this.cloudAssetsResponse = null
|
||||
this.assetExportRequests = []
|
||||
this.assetExportResponse = null
|
||||
this.importedFiles = []
|
||||
|
||||
if (this.jobsRouteHandler) {
|
||||
@@ -402,22 +232,6 @@ 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,
|
||||
@@ -433,10 +247,5 @@ export class AssetsHelper {
|
||||
)
|
||||
this.deleteHistoryRouteHandler = null
|
||||
}
|
||||
|
||||
for (const [pattern, handler] of this.jobDetailRouteHandlers) {
|
||||
await this.page.unroute(pattern, handler)
|
||||
}
|
||||
this.jobDetailRouteHandlers.clear()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
async toAbsolute(position: Position): Promise<Position> {
|
||||
private 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,28 +150,6 @@ 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]> {
|
||||
@@ -264,39 +242,11 @@ export class CanvasHelper {
|
||||
await this.page.mouse.up({ button: 'middle' })
|
||||
}
|
||||
|
||||
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 disconnectEdge(): Promise<void> {
|
||||
await this.dragAndDrop(
|
||||
DefaultGraphPositions.clipTextEncodeNode1InputSlot,
|
||||
DefaultGraphPositions.emptySpace
|
||||
)
|
||||
}
|
||||
|
||||
async connectEdge(options: { reverse?: boolean } = {}): Promise<void> {
|
||||
|
||||
@@ -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/utils/mimeTypeUtil'
|
||||
import { getMimeType } from '@e2e/fixtures/helpers/mimeTypeUtil'
|
||||
|
||||
export class ClipboardHelper {
|
||||
constructor(
|
||||
|
||||
@@ -13,11 +13,7 @@ import type { Page } from '@playwright/test'
|
||||
* so the SDK believes a user is signed in. Must be called before navigation.
|
||||
*/
|
||||
export class CloudAuthHelper {
|
||||
private readonly appUrl: string
|
||||
|
||||
constructor(private readonly page: Page) {
|
||||
this.appUrl = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
|
||||
}
|
||||
constructor(private readonly page: Page) {}
|
||||
|
||||
/**
|
||||
* Set up all auth mocks. Must be called before `comfyPage.setup()`.
|
||||
@@ -38,7 +34,7 @@ export class CloudAuthHelper {
|
||||
*/
|
||||
private async seedFirebaseIndexedDB(): Promise<void> {
|
||||
// Navigate to a lightweight endpoint to get a same-origin context
|
||||
await this.page.goto(`${this.appUrl}/api/users`)
|
||||
await this.page.goto('http://localhost:8188/api/users')
|
||||
|
||||
await this.page.evaluate(() => {
|
||||
const MOCK_USER_DATA = {
|
||||
|
||||
@@ -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/utils/mimeTypeUtil'
|
||||
import { getMimeType } from '@e2e/fixtures/helpers/mimeTypeUtil'
|
||||
import { assetPath } from '@e2e/fixtures/utils/paths'
|
||||
import { nextFrame } from '@e2e/fixtures/utils/timing'
|
||||
|
||||
|
||||
@@ -1,35 +1,9 @@
|
||||
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.
|
||||
*/
|
||||
@@ -42,23 +16,13 @@ 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.
|
||||
@@ -75,7 +39,7 @@ export class ExecutionHelper {
|
||||
})
|
||||
|
||||
await this.page.route(
|
||||
PROMPT_ROUTE_PATTERN,
|
||||
'**/api/prompt',
|
||||
async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
@@ -96,31 +60,6 @@ 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.
|
||||
@@ -150,12 +89,12 @@ export class ExecutionHelper {
|
||||
new Uint8Array(buf, 8, metadataBytes.length).set(metadataBytes)
|
||||
new Uint8Array(buf, 8 + metadataBytes.length).set(png)
|
||||
|
||||
this.requireWs().send(Buffer.from(buf))
|
||||
this.ws.send(Buffer.from(buf))
|
||||
}
|
||||
|
||||
/** Send `execution_start` WS event. */
|
||||
executionStart(jobId: string): void {
|
||||
this.requireWs().send(
|
||||
this.ws.send(
|
||||
JSON.stringify({
|
||||
type: 'execution_start',
|
||||
data: { prompt_id: jobId, timestamp: Date.now() }
|
||||
@@ -165,7 +104,7 @@ export class ExecutionHelper {
|
||||
|
||||
/** Send `executing` WS event to signal which node is currently running. */
|
||||
executing(jobId: string, nodeId: string | null): void {
|
||||
this.requireWs().send(
|
||||
this.ws.send(
|
||||
JSON.stringify({
|
||||
type: 'executing',
|
||||
data: { prompt_id: jobId, node: nodeId }
|
||||
@@ -179,7 +118,7 @@ export class ExecutionHelper {
|
||||
nodeId: string,
|
||||
output: Record<string, unknown>
|
||||
): void {
|
||||
this.requireWs().send(
|
||||
this.ws.send(
|
||||
JSON.stringify({
|
||||
type: 'executed',
|
||||
data: {
|
||||
@@ -194,7 +133,7 @@ export class ExecutionHelper {
|
||||
|
||||
/** Send `execution_success` WS event. */
|
||||
executionSuccess(jobId: string): void {
|
||||
this.requireWs().send(
|
||||
this.ws.send(
|
||||
JSON.stringify({
|
||||
type: 'execution_success',
|
||||
data: { prompt_id: jobId, timestamp: Date.now() }
|
||||
@@ -204,7 +143,7 @@ export class ExecutionHelper {
|
||||
|
||||
/** Send `execution_error` WS event. */
|
||||
executionError(jobId: string, nodeId: string, message: string): void {
|
||||
this.requireWs().send(
|
||||
this.ws.send(
|
||||
JSON.stringify({
|
||||
type: 'execution_error',
|
||||
data: {
|
||||
@@ -222,7 +161,7 @@ export class ExecutionHelper {
|
||||
|
||||
/** Send `progress` WS event. */
|
||||
progress(jobId: string, nodeId: string, value: number, max: number): void {
|
||||
this.requireWs().send(
|
||||
this.ws.send(
|
||||
JSON.stringify({
|
||||
type: 'progress',
|
||||
data: { prompt_id: jobId, node: nodeId, value, max }
|
||||
@@ -262,7 +201,7 @@ export class ExecutionHelper {
|
||||
|
||||
/** Send `status` WS event to update queue count. */
|
||||
status(queueRemaining: number): void {
|
||||
this.requireWs().send(
|
||||
this.ws.send(
|
||||
JSON.stringify({
|
||||
type: 'status',
|
||||
data: { status: { exec_info: { queue_remaining: queueRemaining } } }
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import type { Locator } from '@playwright/test'
|
||||
|
||||
import type {
|
||||
GraphAddOptions,
|
||||
LGraph,
|
||||
LGraphNode
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type {
|
||||
ComfyWorkflowJSON,
|
||||
NodeId
|
||||
@@ -43,48 +39,6 @@ export class NodeOperationsHelper {
|
||||
})
|
||||
}
|
||||
|
||||
async getSelectedNodeIds(): Promise<NodeId[]> {
|
||||
return await this.page.evaluate(() => {
|
||||
const selected = window.app?.canvas?.selected_nodes
|
||||
if (!selected) return []
|
||||
return Object.keys(selected).map(Number)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 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'>,
|
||||
position?: Position
|
||||
): Promise<NodeReference> {
|
||||
const id = await this.page.evaluate(
|
||||
([nodeType, opts, pos]) => {
|
||||
const node = window.LiteGraph!.createNode(nodeType)!
|
||||
const addOpts: Record<string, unknown> = { ...opts }
|
||||
if (opts?.ghost && pos) {
|
||||
addOpts.dragEvent = new MouseEvent('click', {
|
||||
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 ?? {}, position ?? null] as const
|
||||
)
|
||||
return new NodeReference(id, this.comfyPage)
|
||||
}
|
||||
|
||||
/** Remove all nodes from the graph and clean. */
|
||||
async clearGraph() {
|
||||
await this.comfyPage.settings.setSetting('Comfy.ConfirmClear', false)
|
||||
|
||||
@@ -1,231 +0,0 @@
|
||||
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))
|
||||
}
|
||||
})
|
||||
@@ -59,9 +59,6 @@ export const TestIds = {
|
||||
missingModelCopyName: 'missing-model-copy-name',
|
||||
missingModelCopyUrl: 'missing-model-copy-url',
|
||||
missingModelDownload: 'missing-model-download',
|
||||
missingModelActions: 'missing-model-actions',
|
||||
missingModelDownloadAll: 'missing-model-download-all',
|
||||
missingModelRefresh: 'missing-model-refresh',
|
||||
missingModelImportUnsupported: 'missing-model-import-unsupported',
|
||||
missingMediaGroup: 'error-group-missing-media',
|
||||
missingMediaRow: 'missing-media-row',
|
||||
@@ -86,11 +83,7 @@ export const TestIds = {
|
||||
queueButton: 'queue-button',
|
||||
queueModeMenuTrigger: 'queue-mode-menu-trigger',
|
||||
saveButton: 'save-workflow-button',
|
||||
subscribeButton: 'topbar-subscribe-button',
|
||||
loginButton: 'login-button',
|
||||
loginButtonPopover: 'login-button-popover',
|
||||
loginButtonPopoverLearnMore: 'login-button-popover-learn-more',
|
||||
actionBarButtons: 'action-bar-buttons'
|
||||
subscribeButton: 'topbar-subscribe-button'
|
||||
},
|
||||
nodeLibrary: {
|
||||
bookmarksSection: 'node-library-bookmarks-section'
|
||||
@@ -210,26 +203,12 @@ export const TestIds = {
|
||||
},
|
||||
queue: {
|
||||
overlayToggle: 'queue-overlay-toggle',
|
||||
clearHistoryAction: 'clear-history-action',
|
||||
jobAssetsList: 'job-assets-list',
|
||||
notificationBanner: 'queue-notification-banner'
|
||||
clearHistoryAction: 'clear-history-action'
|
||||
},
|
||||
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'
|
||||
},
|
||||
@@ -255,20 +234,6 @@ 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
|
||||
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
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/fixtures/components/ManageGroupNode'
|
||||
import { ManageGroupNode } from '@e2e/helpers/manageGroupNode'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import type { Position, Size } from '@e2e/fixtures/types'
|
||||
import { VueNodeFixture } from '@e2e/fixtures/utils/vueNodeFixtures'
|
||||
@@ -170,36 +169,6 @@ 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 {
|
||||
@@ -357,23 +326,6 @@ 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
|
||||
}
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
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 titleEditor: TitleEditor
|
||||
public readonly titleInput: Locator
|
||||
public readonly body: Locator
|
||||
public readonly pinIndicator: Locator
|
||||
public readonly collapseButton: Locator
|
||||
@@ -17,7 +16,7 @@ export class VueNodeFixture {
|
||||
constructor(private readonly locator: Locator) {
|
||||
this.header = locator.locator('[data-testid^="node-header-"]')
|
||||
this.title = locator.getByTestId('node-title')
|
||||
this.titleEditor = new TitleEditor(locator)
|
||||
this.titleInput = locator.getByTestId('node-title-input')
|
||||
this.body = locator.locator('[data-testid^="node-body-"]')
|
||||
this.pinIndicator = locator.getByTestId(TestIds.node.pinIndicator)
|
||||
this.collapseButton = locator.getByTestId('node-collapse-button')
|
||||
@@ -31,8 +30,17 @@ export class VueNodeFixture {
|
||||
|
||||
async setTitle(value: string): Promise<void> {
|
||||
await this.header.dblclick()
|
||||
await this.titleEditor.expectVisible()
|
||||
await this.titleEditor.setTitle(value)
|
||||
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')
|
||||
}
|
||||
|
||||
async toggleCollapse(): Promise<void> {
|
||||
|
||||
@@ -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/fixtures/utils/perfReporter'
|
||||
import { writePerfReport } from '@e2e/helpers/perfReporter'
|
||||
import { restorePath } from '@e2e/utils/backupUtils'
|
||||
|
||||
dotenvConfig()
|
||||
|
||||
@@ -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/fixtures/utils/fitToView'
|
||||
import { fitToViewInstant } from '@e2e/helpers/fitToView'
|
||||
|
||||
interface BuilderSetupResult {
|
||||
inputNodeTitle: string
|
||||
@@ -56,13 +56,7 @@ export function writePerfReport(
|
||||
gitSha = process.env.GITHUB_SHA ?? 'local',
|
||||
branch = process.env.GITHUB_HEAD_REF ?? 'local'
|
||||
) {
|
||||
let entries
|
||||
try {
|
||||
entries = readdirSync('test-results', { withFileTypes: true })
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
if (!entries.length) return
|
||||
if (!readdirSync('test-results', { withFileTypes: true }).length) return
|
||||
|
||||
let tempFiles: string[]
|
||||
try {
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
export type PromotedWidgetEntry = [string, string]
|
||||
type PromotedWidgetEntry = [string, string]
|
||||
|
||||
function isPromotedWidgetEntry(entry: unknown): entry is PromotedWidgetEntry {
|
||||
return (
|
||||
@@ -1,140 +0,0 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -2,7 +2,7 @@ import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import { setupBuilder } from '@e2e/fixtures/utils/builderTestUtils'
|
||||
import { setupBuilder } from '@e2e/helpers/builderTestUtils'
|
||||
|
||||
test.describe('App mode arrange step', { tag: '@ui' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user