mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-22 15:29:44 +00:00
Compare commits
96 Commits
main
...
cloud/1.42
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f8cd817c6d | ||
|
|
0c45196480 | ||
|
|
83cd22a46f | ||
|
|
a49146483a | ||
|
|
184bd586b5 | ||
|
|
e0ff37b52c | ||
|
|
9be4d54b26 | ||
|
|
1d4bcf44bb | ||
|
|
ea06d8fd6f | ||
|
|
5fc32c3006 | ||
|
|
228c2805f9 | ||
|
|
1f67eafa40 | ||
|
|
1280a4e37f | ||
|
|
4b78e7d97a | ||
|
|
d83a48dffb | ||
|
|
f938da4a62 | ||
|
|
f1930b7fe8 | ||
|
|
26b1ce23fa | ||
|
|
7ae7d3721f | ||
|
|
909779f44f | ||
|
|
8c5887be93 | ||
|
|
c766f9624c | ||
|
|
4fdfb99c1c | ||
|
|
a3093db382 | ||
|
|
40129d8f97 | ||
|
|
f97a23ea4e | ||
|
|
3ca9c8a4d8 | ||
|
|
81aad3ff86 | ||
|
|
cedc48fffa | ||
|
|
3e610115a2 | ||
|
|
34541fa6d5 | ||
|
|
a2a0bda47f | ||
|
|
fb206615a8 | ||
|
|
31fb994f7a | ||
|
|
cc9da4e06b | ||
|
|
5235cf0b56 | ||
|
|
12182216a9 | ||
|
|
c7515f7e19 | ||
|
|
a28fa3a0c0 | ||
|
|
366668b104 | ||
|
|
0babdb4fe3 | ||
|
|
5f49e77b26 | ||
|
|
37b45792c2 | ||
|
|
8ac4b78e53 | ||
|
|
3a5b751dc0 | ||
|
|
f2c6b80f40 | ||
|
|
d8856f3703 | ||
|
|
ce017ff7a9 | ||
|
|
4a4d101a15 | ||
|
|
18932a79f4 | ||
|
|
0ca838bb2a | ||
|
|
ff8ff206e0 | ||
|
|
8312d2af4e | ||
|
|
7758099c4b | ||
|
|
b053786b03 | ||
|
|
8b67ac9b62 | ||
|
|
698a43cc0d | ||
|
|
35aced5c9f | ||
|
|
72a7dab9bb | ||
|
|
994dcbb7f5 | ||
|
|
2faff7b7d2 | ||
|
|
25204af108 | ||
|
|
7c54627215 | ||
|
|
0a65309dd2 | ||
|
|
01b6f5258c | ||
|
|
60dc9797e8 | ||
|
|
c789897e36 | ||
|
|
47876596b5 | ||
|
|
ffe067a9f8 | ||
|
|
988ca85ab3 | ||
|
|
a891593494 | ||
|
|
97619f2f69 | ||
|
|
e44a1ad782 | ||
|
|
7c92f4bd01 | ||
|
|
5fc5948d75 | ||
|
|
c3e98d548c | ||
|
|
4f715f3a23 | ||
|
|
812ef639ad | ||
|
|
e5fcca95a9 | ||
|
|
8de7795219 | ||
|
|
57a1d6cfa3 | ||
|
|
5718e584a9 | ||
|
|
25a353aa16 | ||
|
|
9f904b1e44 | ||
|
|
55710dfbba | ||
|
|
ed96ffba93 | ||
|
|
32801ccc21 | ||
|
|
cfadc35b18 | ||
|
|
6fc9d1c0e3 | ||
|
|
e11d5548a1 | ||
|
|
ccb3e33eb8 | ||
|
|
ed40779cdc | ||
|
|
d809f51831 | ||
|
|
a20258abd1 | ||
|
|
53458b5ada | ||
|
|
08b501b18f |
@@ -1,118 +0,0 @@
|
||||
---
|
||||
name: adr-compliance
|
||||
description: Checks code changes against Architecture Decision Records, with emphasis on ECS (ADR 0008) and command-pattern (ADR 0003) compliance
|
||||
severity-default: medium
|
||||
tools: [Read, Grep, glob]
|
||||
---
|
||||
|
||||
Check that code changes are consistent with the project's Architecture Decision Records in `docs/adr/`.
|
||||
|
||||
## Priority 1: ECS and Command-Pattern Compliance (ADR 0008 + ADR 0003)
|
||||
|
||||
These are the primary architectural guardrails. Every entity/litegraph change must be checked against them.
|
||||
|
||||
### Command Pattern (ADR 0003)
|
||||
|
||||
All entity state mutations MUST be expressible as **serializable, idempotent, deterministic commands**. This is required for CRDT sync, undo/redo, cross-environment portability, and gateway backends.
|
||||
|
||||
Flag:
|
||||
|
||||
- **Direct spatial mutation** — `node.pos = ...`, `node.size = ...`, `group.pos = ...` outside of a store or command. All spatial data flows through `layoutStore` commands.
|
||||
- **Imperative fire-and-forget mutation** — Any new API that mutates entity state as a side effect rather than producing a serializable command object. Systems should produce command batches, not execute mutations directly.
|
||||
- **Void-returning mutation APIs** — New entity mutation functions that return `void` instead of a result type (`{ status: 'applied' | 'rejected' | 'no-op' }`). Commands need error/rejection semantics.
|
||||
- **Auto-incrementing IDs in new entity code** — New entity creation using auto-increment counters without acknowledging the CRDT collision problem. Concurrent environments need globally unique, stable identifiers.
|
||||
|
||||
### ECS Architecture (ADR 0008)
|
||||
|
||||
The graph domain model is migrating to ECS. New code must not make the migration harder.
|
||||
|
||||
Flag:
|
||||
|
||||
- **God-object growth** — New methods/properties added to `LGraphNode` (~4k lines), `LGraphCanvas` (~9k lines), `LGraph` (~3k lines), or `Subgraph`. Extract to systems, stores, or composables instead.
|
||||
- **Mixed data and behavior** — New component-like data structures that contain methods or back-references to parent entities. ECS components are plain data objects.
|
||||
- **New circular entity dependencies** — New circular imports between `LGraph` ↔ `Subgraph`, `LGraphNode` ↔ `LGraphCanvas`, or similar entity classes.
|
||||
- **Direct `graph._version++`** — Mutating the private version counter directly instead of through a public API. Extensions already depend on this side-channel; it must become a proper API.
|
||||
|
||||
### Centralized Registries and ECS-Style Access
|
||||
|
||||
All entity data access should move toward centralized query patterns, not instance property access.
|
||||
|
||||
Flag:
|
||||
|
||||
- **New instance method/property patterns** — Adding `node.someProperty` or `node.someMethod()` for data that should be a component in the World, queried via `world.getComponent(entityId, ComponentType)`.
|
||||
- **OOP inheritance for entity modeling** — Extending entity classes with new subclasses instead of composing behavior through components and systems.
|
||||
- **Scattered state** — New entity state stored in multiple locations (class properties, stores, local variables) instead of being consolidated in the World or in a single store.
|
||||
|
||||
### Extension Ecosystem Impact
|
||||
|
||||
Entity API changes affect 40+ custom node repos. Changes to these patterns require an extension migration path.
|
||||
|
||||
Flag when changed without migration guidance:
|
||||
|
||||
- `onConnectionsChange`, `onRemoved`, `onAdded`, `onConfigure` callbacks
|
||||
- `onConnectInput` / `onConnectOutput` validation hooks
|
||||
- `onWidgetChanged` handlers
|
||||
- `node.widgets.find(w => w.name === ...)` patterns
|
||||
- `node.serialize` overrides
|
||||
- `graph._version++` direct mutation
|
||||
- `getNodeById` usage patterns
|
||||
|
||||
## Priority 2: General ADR Compliance
|
||||
|
||||
For all other ADRs, iterate through each file in `docs/adr/` and extract the core lesson. Ensure changed code does not contradict accepted ADRs. Flag contradictions with proposed ADRs as directional guidance.
|
||||
|
||||
### How to Apply
|
||||
|
||||
1. Read `docs/adr/README.md` to get the full ADR index
|
||||
2. For each ADR, read the Decision and Consequences sections
|
||||
3. Check the diff against each ADR's constraints
|
||||
4. Only flag ACTUAL violations in changed code, not pre-existing patterns
|
||||
|
||||
### Skip List
|
||||
|
||||
These ADRs can be skipped for most reviews (they cover completed or narrow-scope decisions):
|
||||
|
||||
- **ADR 0004** (Rejected — Fork PrimeVue) — only relevant if someone proposes forking PrimeVue again
|
||||
|
||||
## How to Check
|
||||
|
||||
1. Identify changed files in the entity/litegraph layer: `src/lib/litegraph/`, `src/ecs/`, `src/platform/`, entity-related stores
|
||||
2. For Priority 1 patterns, use targeted searches:
|
||||
|
||||
```
|
||||
# Direct position mutation
|
||||
Grep: pattern="\.pos\s*=" path="src/lib/litegraph"
|
||||
Grep: pattern="\.size\s*=" path="src/lib/litegraph"
|
||||
|
||||
# God object growth (new methods)
|
||||
Grep: pattern="(class LGraphNode|class LGraphCanvas|class LGraph\b)" path="src/lib/litegraph"
|
||||
|
||||
# Version mutation
|
||||
Grep: pattern="_version\+\+" path="src/lib/litegraph"
|
||||
|
||||
# Extension callback changes
|
||||
Grep: pattern="on(ConnectionsChange|Removed|Added|Configure|ConnectInput|ConnectOutput|WidgetChanged)" path="src/lib/litegraph"
|
||||
```
|
||||
|
||||
3. For Priority 2, read `docs/adr/` files and check for contradictions
|
||||
|
||||
## Severity Guidelines
|
||||
|
||||
| Issue | Severity |
|
||||
| -------------------------------------------------------- | -------- |
|
||||
| Imperative mutation API without command-pattern wrapper | high |
|
||||
| New god-object method on LGraphNode/LGraphCanvas/LGraph | high |
|
||||
| Breaking extension callback without migration path | high |
|
||||
| New circular entity dependency | high |
|
||||
| Direct spatial mutation bypassing command pattern | medium |
|
||||
| Mixed data/behavior in component-like structures | medium |
|
||||
| New OOP inheritance pattern for entities | medium |
|
||||
| Contradicts accepted ADR direction | medium |
|
||||
| Contradicts proposed ADR direction without justification | low |
|
||||
|
||||
## Rules
|
||||
|
||||
- Only flag ACTUAL violations in changed code, not pre-existing patterns
|
||||
- If a change explicitly acknowledges an ADR tradeoff in comments or PR description, lower severity
|
||||
- Proposed ADRs carry less weight than accepted ones — flag as directional guidance
|
||||
- Reference the specific ADR number in every finding
|
||||
@@ -1,74 +0,0 @@
|
||||
---
|
||||
name: playwright-e2e
|
||||
description: Reviews Playwright E2E test code for ComfyUI-specific patterns, flakiness risks, and fixture misuse
|
||||
severity-default: medium
|
||||
tools: [Read, Grep]
|
||||
---
|
||||
|
||||
You are reviewing Playwright E2E test code in `browser_tests/`. Focus on issues a **reviewer** would catch that an author might miss — flakiness risks, fixture misuse, test isolation problems, and convention violations.
|
||||
|
||||
Reference docs (read if you need full context):
|
||||
|
||||
- `browser_tests/README.md` — setup, patterns, screenshot workflow
|
||||
- `browser_tests/AGENTS.md` — directory structure, fixture overview
|
||||
- `docs/guidance/playwright.md` — type assertion rules, test tags, forbidden patterns
|
||||
- `.claude/skills/writing-playwright-tests/SKILL.md` — anti-patterns, retry patterns, Vue Nodes vs LiteGraph decision guide
|
||||
|
||||
## Checks
|
||||
|
||||
### Flakiness Risks (Major)
|
||||
|
||||
1. **`waitForTimeout` usage** — Always wrong. Must use retrying assertions (`toBeVisible`, `toHaveText`), `expect.poll()`, or `expect().toPass()`. See retry patterns in `.claude/skills/writing-playwright-tests/SKILL.md`.
|
||||
|
||||
2. **Missing `nextFrame()` after canvas ops** — Any `drag`, `click` on canvas, `resizeNode`, `pan`, `zoom`, or programmatic graph mutation via `page.evaluate` that changes visual state needs `await comfyPage.nextFrame()` before assertions. `loadWorkflow()` does NOT need it. Prefer encapsulating `nextFrame()` calls inside Page Object methods so tests don't manage frame timing directly.
|
||||
|
||||
3. **Keyboard actions without prior focus** — `page.keyboard.press()` without a preceding `comfyPage.canvas.click()` or element `.focus()` will silently send keys to nothing.
|
||||
|
||||
4. **Coordinate-based interactions where node refs exist** — Raw `{ x, y }` clicks on canvas are fragile. If the test targets a node, use `comfyPage.nodeOps.getNodeRefById()` / `getNodeRefsByTitle()` / `getNodeRefsByType()` instead.
|
||||
|
||||
5. **Shared mutable state between tests** — Variables declared outside `test()` blocks, `let` state mutated across tests, or tests depending on execution order. Each test must be independently runnable.
|
||||
|
||||
6. **Missing cleanup of server-persisted state** — Settings changed via `comfyPage.settings.setSetting()` persist across tests. Must be reset in `afterEach` or at test start. Same for uploaded files or saved workflows. Prefer moving cleanup into [fixture options](https://playwright.dev/docs/test-fixtures#fixtures-options) so individual tests don't manage reset logic.
|
||||
|
||||
7. **Double-click without `{ delay }` option** — `dblclick()` without `{ delay: 5 }` or similar can be too fast for the canvas event handler.
|
||||
|
||||
### Fixture & API Misuse (Medium)
|
||||
|
||||
8. **Reimplementing existing fixture helpers** — Before flagging, grep `browser_tests/fixtures/` for the functionality. Common missed helpers:
|
||||
- `comfyPage.command.executeCommand()` for menu/command actions
|
||||
- `comfyPage.workflow.loadWorkflow()` for loading test workflows
|
||||
- `comfyPage.canvasOps.resetView()` for view reset
|
||||
- `comfyPage.settings.setSetting()` for settings
|
||||
- Component page objects in `browser_tests/fixtures/components/`
|
||||
|
||||
9. **Building workflows programmatically when a JSON asset would work** — Complex `page.evaluate` chains to construct a graph should use a premade JSON workflow in `browser_tests/assets/` loaded via `comfyPage.workflow.loadWorkflow()`.
|
||||
|
||||
10. **Selectors not using `TestIds`** — Hard-coded `data-testid` strings should reference `browser_tests/fixtures/selectors.ts` when a matching entry exists. Check `selectors.ts` before flagging.
|
||||
|
||||
### Convention Violations (Minor)
|
||||
|
||||
11. **Missing test tags** — Every `test.describe` should have `tag` with at least one of: `@smoke`, `@slow`, `@screenshot`, `@canvas`, `@node`, `@widget`, `@mobile`, `@2x`. See `.claude/skills/writing-playwright-tests/SKILL.md` for when to use each.
|
||||
|
||||
12. **`as any` type assertions** — Forbidden in E2E tests. Use specific type assertions or test-local type helpers. See `docs/guidance/playwright.md` for acceptable patterns.
|
||||
|
||||
13. **Screenshot tests without masking dynamic content** — Timestamps, version numbers, or other non-deterministic content in screenshots will cause flakes. Use `mask` option.
|
||||
|
||||
14. **`test.describe` without `afterEach` cleanup when canvas state changes** — Tests that manipulate canvas view (drag, zoom, pan) should include `afterEach` with `comfyPage.canvasOps.resetView()`. Prefer moving canvas reset into the fixture so individual tests don't manage cleanup.
|
||||
|
||||
15. **Debug helpers left in committed code** — `debugAddMarker`, `debugAttachScreenshot`, `debugShowCanvasOverlay`, `debugGetCanvasDataURL` are for local debugging only.
|
||||
|
||||
### Test Design (Nitpick)
|
||||
|
||||
16. **Screenshot-only assertions where functional assertions are possible** — Prefer `expect(await node.isPinned()).toBe(true)` over screenshot comparison when testing non-visual behavior.
|
||||
|
||||
17. **Overly large test workflows** — Test should load the minimal workflow needed. If a test only needs one node, don't load the full default graph.
|
||||
|
||||
18. **Vue Nodes / LiteGraph mismatch** — If testing Vue-rendered node UI (DOM widgets, CSS states), should use `comfyPage.vueNodes.*`. If testing canvas interactions/connections, should use `comfyPage.nodeOps.*`. Mixing both in one test is a smell.
|
||||
|
||||
## Rules
|
||||
|
||||
- Only review `.spec.ts` files and supporting code in `browser_tests/`
|
||||
- Do NOT flag patterns in fixture/helper code (`browser_tests/fixtures/`) — those are shared infrastructure with different rules
|
||||
- "Major" for flakiness risks (items 1-7), "medium" for fixture misuse (8-10), "minor" for convention violations (11-15), "nitpick" for test design (16-18)
|
||||
- When flagging missing fixture usage (item 8), confirm the helper exists by checking the fixture code — don't assume
|
||||
- Existing tests that predate conventions are acceptable to modify but not required to fix
|
||||
@@ -1,94 +0,0 @@
|
||||
# ADR Compliance Audit
|
||||
|
||||
Audit the current changes (or a specified PR) for compliance with Architecture Decision Records.
|
||||
|
||||
## Step 1: Gather the Diff
|
||||
|
||||
- If a PR number is provided, run: `gh pr diff $PR_NUMBER`
|
||||
- Otherwise, run: `git diff origin/main...HEAD` (or `git diff --cached` for staged changes)
|
||||
|
||||
## Step 2: Priority 1 — ECS and Command-Pattern Compliance
|
||||
|
||||
Read these documents for context:
|
||||
|
||||
```
|
||||
docs/adr/0003-crdt-based-layout-system.md
|
||||
docs/adr/0008-entity-component-system.md
|
||||
docs/architecture/ecs-target-architecture.md
|
||||
docs/architecture/ecs-migration-plan.md
|
||||
docs/architecture/appendix-critical-analysis.md
|
||||
```
|
||||
|
||||
### Check A: Command Pattern (ADR 0003)
|
||||
|
||||
Every entity state mutation must be a **serializable, idempotent, deterministic command** — replayable, undoable, transmittable over CRDT.
|
||||
|
||||
Flag:
|
||||
|
||||
1. **Direct spatial mutation** — `node.pos = ...`, `node.size = ...`, `group.pos = ...` outside a store/command
|
||||
2. **Imperative fire-and-forget APIs** — Functions that mutate entity state as side effects rather than producing serializable command objects. Systems should produce command batches, not execute mutations directly.
|
||||
3. **Void-returning mutation APIs** — Entity mutations returning `void` instead of `{ status: 'applied' | 'rejected' | 'no-op' }`
|
||||
4. **Auto-increment IDs** — New entity creation via counters without addressing CRDT collision. Concurrent environments need globally unique identifiers.
|
||||
5. **Missing transaction semantics** — Multi-entity operations without atomic grouping (e.g., node removal = 10+ deletes with no rollback on failure)
|
||||
|
||||
### Check B: ECS Architecture (ADR 0008)
|
||||
|
||||
Flag:
|
||||
|
||||
1. **God-object growth** — New methods/properties on `LGraphNode`, `LGraphCanvas`, `LGraph`, `Subgraph`
|
||||
2. **Mixed data/behavior** — Component-like structures with methods or back-references
|
||||
3. **OOP instance patterns** — New `node.someProperty` or `node.someMethod()` for data that should be a World component
|
||||
4. **OOP inheritance** — New entity subclasses instead of component composition
|
||||
5. **Circular entity deps** — New `LGraph` ↔ `Subgraph`, `LGraphNode` ↔ `LGraphCanvas` circular imports
|
||||
6. **Direct `_version++`** — Mutating private version counter instead of through public API
|
||||
|
||||
### Check C: Extension Ecosystem Impact
|
||||
|
||||
If any of these patterns are changed, flag and require migration guidance:
|
||||
|
||||
- `onConnectionsChange`, `onRemoved`, `onAdded`, `onConfigure` callbacks
|
||||
- `onConnectInput` / `onConnectOutput` validation hooks
|
||||
- `onWidgetChanged` handlers
|
||||
- `node.widgets.find(w => w.name === ...)` access patterns
|
||||
- `node.serialize` overrides
|
||||
- `graph._version++` direct mutation
|
||||
|
||||
Reference: 40+ custom node repos depend on these (rgthree-comfy, ComfyUI-Impact-Pack, cg-use-everywhere, etc.)
|
||||
|
||||
## Step 3: Priority 2 — General ADR Compliance
|
||||
|
||||
1. Read `docs/adr/README.md` for the full ADR index
|
||||
2. For each ADR (except skip list), read the Decision section
|
||||
3. Check the diff for contradictions
|
||||
4. Only flag ACTUAL violations in changed code
|
||||
|
||||
**Skip list**: ADR 0004 (Rejected — Fork PrimeVue)
|
||||
|
||||
## Step 4: Generate Report
|
||||
|
||||
```
|
||||
## ADR Compliance Audit Report
|
||||
|
||||
### Summary
|
||||
- Files audited: N
|
||||
- Priority 1 findings: N (command-pattern: N, ECS: N, ecosystem: N)
|
||||
- Priority 2 findings: N
|
||||
|
||||
### Priority 1: Command Pattern & ECS
|
||||
(List each with ADR reference, file, line, description)
|
||||
|
||||
### Priority 1: Extension Ecosystem Impact
|
||||
(List each changed callback/API with affected custom node repos)
|
||||
|
||||
### Priority 2: General ADR Compliance
|
||||
(List each with ADR reference, file, line, description)
|
||||
|
||||
### Compliant Patterns
|
||||
(Note changes that positively align with ADR direction)
|
||||
```
|
||||
|
||||
## Severity
|
||||
|
||||
- **Must fix**: Contradicts accepted ADR, or introduces imperative mutation API without command-pattern wrapper, or breaks extension callback without migration path
|
||||
- **Should discuss**: Contradicts proposed ADR direction — either align or propose ADR amendment
|
||||
- **Note**: Surfaces open architectural question not yet addressed by ADRs
|
||||
@@ -1,86 +1,9 @@
|
||||
{
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "Bash",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"if": "Bash(tsc *)",
|
||||
"command": "echo 'Use `pnpm typecheck` instead of running tsc directly.' >&2 && exit 2"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"if": "Bash(vue-tsc *)",
|
||||
"command": "echo 'Use `pnpm typecheck` instead of running vue-tsc directly.' >&2 && exit 2"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"if": "Bash(npx tsc *)",
|
||||
"command": "echo 'Use `pnpm typecheck` instead of running tsc via npx.' >&2 && exit 2"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"if": "Bash(pnpx tsc *)",
|
||||
"command": "echo 'Use `pnpm typecheck` instead of running tsc via pnpx.' >&2 && exit 2"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"if": "Bash(pnpm exec tsc *)",
|
||||
"command": "echo 'Use `pnpm typecheck` instead of `pnpm exec tsc`.' >&2 && exit 2"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"if": "Bash(npx vitest *)",
|
||||
"command": "echo 'Use `pnpm test:unit` (or `pnpm test:unit -- <path>`) instead of npx vitest.' >&2 && exit 2"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"if": "Bash(pnpx vitest *)",
|
||||
"command": "echo 'Use `pnpm test:unit` (or `pnpm test:unit -- <path>`) instead of pnpx vitest.' >&2 && exit 2"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"if": "Bash(npx eslint *)",
|
||||
"command": "echo 'Use `pnpm lint` or `pnpm lint:fix` instead of npx eslint.' >&2 && exit 2"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"if": "Bash(pnpx eslint *)",
|
||||
"command": "echo 'Use `pnpm lint` or `pnpm lint:fix` instead of pnpx eslint.' >&2 && exit 2"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"if": "Bash(npx prettier *)",
|
||||
"command": "echo 'This project uses oxfmt, not prettier. Use `pnpm format` or `pnpm format:check`.' >&2 && exit 2"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"if": "Bash(pnpx prettier *)",
|
||||
"command": "echo 'This project uses oxfmt, not prettier. Use `pnpm format` or `pnpm format:check`.' >&2 && exit 2"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"if": "Bash(npx oxlint *)",
|
||||
"command": "echo 'Use `pnpm oxlint` instead of npx oxlint.' >&2 && exit 2"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"if": "Bash(npx stylelint *)",
|
||||
"command": "echo 'Use `pnpm stylelint` instead of npx stylelint.' >&2 && exit 2"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"if": "Bash(npx knip *)",
|
||||
"command": "echo 'Use `pnpm knip` instead of npx knip.' >&2 && exit 2"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"if": "Bash(pnpx knip *)",
|
||||
"command": "echo 'Use `pnpm knip` instead of pnpx knip.' >&2 && exit 2"
|
||||
}
|
||||
]
|
||||
}
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(pnpx vitest run --testPathPattern=\"draftCacheV2.property\")",
|
||||
"Bash(pnpx vitest run \"draftCacheV2.property\")",
|
||||
"Bash(node -e \"const fc = require\\(''fast-check''\\); console.log\\(Object.keys\\(fc\\).filter\\(k => k.includes\\(''string''\\)\\).join\\('', ''\\)\\)\")"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
---
|
||||
name: adding-deprecation-warnings
|
||||
description: 'Adds deprecation warnings for renamed or removed properties/APIs. Searches custom node ecosystem for usage, applies defineDeprecatedProperty helper, adds JSDoc. Triggers on: deprecate, deprecation warning, rename property, backward compatibility.'
|
||||
---
|
||||
|
||||
# Adding Deprecation Warnings
|
||||
|
||||
Adds backward-compatible deprecation warnings for renamed or removed
|
||||
properties using the `defineDeprecatedProperty` helper in
|
||||
`src/lib/litegraph/src/utils/feedback.ts`.
|
||||
|
||||
## When to Use
|
||||
|
||||
- A property or API has been renamed and custom nodes still use the old name
|
||||
- A property is being removed but needs a grace period
|
||||
- Backward compatibility must be preserved while nudging adoption
|
||||
|
||||
## Steps
|
||||
|
||||
### 1. Search the Custom Node Ecosystem
|
||||
|
||||
Before implementing, assess impact by searching for usage of the
|
||||
deprecated property across ComfyUI custom nodes:
|
||||
|
||||
```text
|
||||
Use the comfy_codesearch tool to search for the old property name.
|
||||
Search for both `widget.oldProp` and just `oldProp` to catch all patterns.
|
||||
```
|
||||
|
||||
Document the usage patterns found (property access, truthiness checks,
|
||||
caching to local vars, style mutation, etc.) — these all must continue
|
||||
working.
|
||||
|
||||
### 2. Apply the Deprecation
|
||||
|
||||
Use `defineDeprecatedProperty` from `src/lib/litegraph/src/utils/feedback.ts`:
|
||||
|
||||
```typescript
|
||||
import { defineDeprecatedProperty } from '@/lib/litegraph/src/utils/feedback'
|
||||
|
||||
/** @deprecated Use {@link obj.newProp} instead. */
|
||||
defineDeprecatedProperty(
|
||||
obj,
|
||||
'oldProp',
|
||||
'newProp',
|
||||
'obj.oldProp is deprecated. Use obj.newProp instead.'
|
||||
)
|
||||
```
|
||||
|
||||
### 3. Checklist
|
||||
|
||||
- [ ] Ecosystem search completed — all usage patterns are compatible
|
||||
- [ ] `defineDeprecatedProperty` call added after the new property is assigned
|
||||
- [ ] JSDoc `@deprecated` tag added above the call for IDE support
|
||||
- [ ] Warning message names both old and new property clearly
|
||||
- [ ] `pnpm typecheck` passes
|
||||
- [ ] `pnpm lint` passes
|
||||
|
||||
### 4. PR Comment
|
||||
|
||||
Add a PR comment summarizing the ecosystem search results: which repos
|
||||
use the deprecated property, what access patterns were found, and
|
||||
confirmation that all patterns are compatible with the ODP getter/setter.
|
||||
|
||||
## How `defineDeprecatedProperty` Works
|
||||
|
||||
- Creates an `Object.defineProperty` getter/setter on the target object
|
||||
- Getter returns `this[currentKey]`, setter assigns `this[currentKey]`
|
||||
- Both log via `warnDeprecated`, which deduplicates (once per unique
|
||||
message per session via a `Set`)
|
||||
- `enumerable: false` keeps the alias out of `Object.keys()` / `for...in`
|
||||
/ `JSON.stringify`
|
||||
- `configurable: true` allows further redefinition if needed
|
||||
|
||||
## Edge Cases
|
||||
|
||||
- **Truthiness checks** (`if (widget.oldProp)`) — works, getter fires
|
||||
- **Caching to local var** (`const el = widget.oldProp`) — works, warns
|
||||
once then the cached ref is used directly
|
||||
- **Style/property mutation** (`widget.oldProp.style.color = 'red'`) —
|
||||
works, getter returns the real object
|
||||
- **Serialization** (`JSON.stringify`) — `enumerable: false` excludes it
|
||||
- **Heavy access in loops** — `warnDeprecated` deduplicates, only warns
|
||||
once per session regardless of call count
|
||||
@@ -11,56 +11,34 @@ Cherry-pick backport management for Comfy-Org/ComfyUI_frontend stable release br
|
||||
|
||||
1. **Discover** — Collect candidates from Slack bot + git log gap (`reference/discovery.md`)
|
||||
2. **Analyze** — Categorize MUST/SHOULD/SKIP, check deps (`reference/analysis.md`)
|
||||
3. **Human Review** — Present candidates in batches for interactive approval (see Interactive Approval Flow)
|
||||
4. **Plan** — Order by dependency (leaf fixes first), group into waves per branch
|
||||
5. **Execute** — Label-driven automation → worktree fallback for conflicts (`reference/execution.md`)
|
||||
6. **Verify** — After each wave, verify branch integrity before proceeding
|
||||
7. **Log & Report** — Generate session report (`reference/logging.md`)
|
||||
3. **Plan** — Order by dependency (leaf fixes first), group into waves per branch
|
||||
4. **Execute** — Label-driven automation → worktree fallback for conflicts (`reference/execution.md`)
|
||||
5. **Verify** — After each wave, verify branch integrity before proceeding
|
||||
6. **Log & Report** — Generate session report with mermaid diagram (`reference/logging.md`)
|
||||
|
||||
## System Context
|
||||
|
||||
| Item | Value |
|
||||
| -------------- | --------------------------------------------------------------------------- |
|
||||
| Repo | `~/ComfyUI_frontend` (Comfy-Org/ComfyUI_frontend) |
|
||||
| Merge strategy | Auto-merge via workflow (`--auto --squash`); `--admin` only after CI passes |
|
||||
| Automation | `pr-backport.yaml` GitHub Action (label-driven, auto-merge enabled) |
|
||||
| Tracking dir | `~/temp/backport-session/` |
|
||||
|
||||
## CI Safety Rules
|
||||
|
||||
**NEVER merge a backport PR without all CI checks passing.** This applies to both automation-created and manual cherry-pick PRs.
|
||||
|
||||
- **Automation PRs:** The `pr-backport.yaml` workflow now enables `gh pr merge --auto --squash`, so clean PRs auto-merge once CI passes. Monitor with polling (`gh pr list --base TARGET_BRANCH --state open`). Do not intervene unless CI fails.
|
||||
- **Manual cherry-pick PRs:** After `gh pr create`, wait for CI before merging. Poll with `gh pr checks $PR --watch` or use a sleep+check loop. Only merge after all checks pass.
|
||||
- **CI failures:** DO NOT use `--admin` to bypass failing CI. Analyze the failure, present it to the user with possible causes (test backported without implementation, missing dependency, flaky test), and let the user decide the next step.
|
||||
| Item | Value |
|
||||
| -------------- | ------------------------------------------------- |
|
||||
| Repo | `~/ComfyUI_frontend` (Comfy-Org/ComfyUI_frontend) |
|
||||
| Merge strategy | Squash merge (`gh pr merge --squash --admin`) |
|
||||
| Automation | `pr-backport.yaml` GitHub Action (label-driven) |
|
||||
| Tracking dir | `~/temp/backport-session/` |
|
||||
|
||||
## Branch Scope Rules
|
||||
|
||||
**Critical: Match PRs to the correct target branches.**
|
||||
|
||||
| Branch prefix | Scope | Example |
|
||||
| ------------- | ------------------------------ | ------------------------------------------------- |
|
||||
| `cloud/*` | Cloud-hosted ComfyUI only | Team workspaces, cloud queue, cloud-only login |
|
||||
| `core/*` | Local/self-hosted ComfyUI only | Core editor, local workflows, node system |
|
||||
| Both | Shared infrastructure | App mode, Firebase auth (API nodes), payment URLs |
|
||||
| Branch prefix | Scope | Example |
|
||||
| ------------- | ------------------------------ | ----------------------------------------- |
|
||||
| `cloud/*` | Cloud-hosted ComfyUI only | App mode, cloud auth, cloud-specific UI |
|
||||
| `core/*` | Local/self-hosted ComfyUI only | Core editor, local workflows, node system |
|
||||
|
||||
### What Goes Where
|
||||
**⚠️ NEVER backport cloud-only PRs to `core/*` branches.** Cloud-only changes (app mode, cloud auth, cloud billing UI, cloud-specific API calls) are irrelevant to local users and waste effort. Before backporting any PR to a `core/*` branch, check:
|
||||
|
||||
**Both core + cloud:**
|
||||
|
||||
- **App mode** PRs — app mode is NOT cloud-only
|
||||
- **Firebase auth** PRs — Firebase auth is on core for API nodes
|
||||
- **Payment redirect** PRs — payment infrastructure shared
|
||||
- **Bug fixes** touching shared components
|
||||
|
||||
**Cloud-only (skip for core):**
|
||||
|
||||
- Team workspaces
|
||||
- Cloud queue virtualization
|
||||
- Hide API key login
|
||||
- Cloud-specific UI behind cloud feature flags
|
||||
|
||||
**⚠️ NEVER backport cloud-only PRs to `core/*` branches.** But do NOT assume "app mode" or "Firebase" = cloud-only. Check the actual files changed.
|
||||
- Does the PR title/description mention "app mode", "cloud", or cloud-specific features?
|
||||
- Does the PR only touch files like `appModeStore.ts`, cloud auth, or cloud-specific components?
|
||||
- If yes → skip for `core/*` branches (may still apply to `cloud/*` branches)
|
||||
|
||||
## ⚠️ Gotchas (Learn from Past Sessions)
|
||||
|
||||
@@ -81,32 +59,6 @@ The `pr-backport.yaml` action reports more conflicts than reality. `git cherry-p
|
||||
|
||||
12 or 27 conflicting files can be trivial (snapshots, new files). **Categorize conflicts first**, then decide. See Conflict Triage below.
|
||||
|
||||
### Accept-Theirs Can Produce Broken Hybrids
|
||||
|
||||
When a PR **rewrites a component** (e.g., PrimeVue → Reka UI), the accept-theirs regex produces a broken mix of old and new code. The template may reference new APIs while the script still has old imports, or vice versa.
|
||||
|
||||
**Detection:** Content conflicts with 4+ conflict markers in a single `.vue` file, especially when imports change between component libraries.
|
||||
|
||||
**Fix:** Instead of accept-theirs regex, use `git show MERGE_SHA:path/to/file > path/to/file` to get the complete correct version from the merge commit on main. This bypasses the conflict entirely.
|
||||
|
||||
### Cherry-Picks Can Reference Missing Dependencies
|
||||
|
||||
When PR A on main depends on code introduced by PR B (which was merged before A), cherry-picking A brings in code that references B's additions. The cherry-pick succeeds but the branch is broken.
|
||||
|
||||
**Common pattern:** Composables, component files, or type definitions introduced by an earlier PR and used by the cherry-picked PR.
|
||||
|
||||
**Detection:** `pnpm typecheck` fails with "Cannot find module" or "is not defined" errors after cherry-pick.
|
||||
|
||||
**Fix:** Use `git show MERGE_SHA:path/to/missing/file > path/to/missing/file` to bring the missing files from main. Always verify with typecheck.
|
||||
|
||||
### Use `--no-verify` for Worktree Pushes
|
||||
|
||||
Husky hooks fail in worktrees (can't find lint-staged config). Always use `git push --no-verify` and `git commit --no-verify` when working in `/tmp/` worktrees.
|
||||
|
||||
### Automation Success Varies Wildly by Branch
|
||||
|
||||
In the 2026-04-06 session: core/1.42 got 18/26 auto-PRs, cloud/1.42 got only 1/25. The cloud branch has more divergence. **Always plan for manual fallback** — don't assume automation will handle most PRs.
|
||||
|
||||
## Conflict Triage
|
||||
|
||||
**Always categorize before deciding to skip. High conflict count ≠ hard conflicts.**
|
||||
@@ -117,8 +69,6 @@ In the 2026-04-06 session: core/1.42 got 18/26 auto-PRs, cloud/1.42 got only 1/2
|
||||
| **Modify/delete (new file)** | PR introduces files not on target | `git add $FILE` — keep the new file |
|
||||
| **Modify/delete (removed)** | Target removed files the PR modifies | `git rm $FILE` — file no longer relevant |
|
||||
| **Content conflicts** | Marker-based (`<<<<<<<`) | Accept theirs via python regex (see below) |
|
||||
| **Component rewrites** | 4+ markers in `.vue`, library change | Use `git show SHA:path > path` — do NOT accept-theirs |
|
||||
| **Import-only conflicts** | Only import lines differ | Keep both imports if both used; remove unused after |
|
||||
| **Add/add** | Both sides added same file | Accept theirs, verify no logic conflict |
|
||||
| **Locale/JSON files** | i18n key additions | Accept theirs, validate JSON after |
|
||||
|
||||
@@ -145,7 +95,7 @@ Skip these without discussion:
|
||||
- **Test-only / lint rule changes** — Not user-facing
|
||||
- **Revert pairs** — If PR A reverted by PR B, skip both. If fixed version (PR C) exists, backport only C.
|
||||
- **Features not on target branch** — e.g., Painter, GLSLShader, appModeStore on core/1.40
|
||||
- **Cloud-only PRs on core/\* branches** — Team workspaces, cloud queue, cloud-only login. (Note: app mode and Firebase auth are NOT cloud-only — see Branch Scope Rules)
|
||||
- **Cloud-only PRs on core/\* branches** — App mode, cloud auth, cloud billing. These only affect cloud-hosted ComfyUI.
|
||||
|
||||
## Wave Verification
|
||||
|
||||
@@ -158,27 +108,11 @@ git fetch origin TARGET_BRANCH
|
||||
# Quick smoke check: does the branch build?
|
||||
git worktree add /tmp/verify-TARGET origin/TARGET_BRANCH
|
||||
cd /tmp/verify-TARGET
|
||||
source ~/.nvm/nvm.sh && nvm use 24 && pnpm install && pnpm typecheck && pnpm test:unit
|
||||
source ~/.nvm/nvm.sh && nvm use 24 && pnpm install && pnpm typecheck
|
||||
git worktree remove /tmp/verify-TARGET --force
|
||||
```
|
||||
|
||||
If typecheck or tests fail, stop and investigate before continuing. A broken branch after wave N means all subsequent waves will compound the problem.
|
||||
|
||||
### Fix PRs Are Normal
|
||||
|
||||
Expect to create 1 fix PR per branch after verification. Common issues:
|
||||
|
||||
1. **Component rewrite hybrids** — accept-theirs produced broken `.vue` files. Fix: overwrite with correct version from merge commit via `git show SHA:path > path`
|
||||
2. **Missing dependency files** — cherry-pick brought in code referencing composables/components not on the branch. Fix: add missing files from merge commit
|
||||
3. **Missing type properties** — cherry-picked code uses interface properties not yet on the branch (e.g., `key` on `ConfirmDialogOptions`). Fix: add the property to the interface
|
||||
4. **Unused imports** — conflict resolution kept imports that the branch doesn't use. Fix: remove unused imports
|
||||
5. **Wrong types from conflict resolution** — e.g., `{ top: number; right: number }` vs `{ top: number; left: number }`. Fix: match the return type of the actual function
|
||||
|
||||
Create a fix PR on a branch from the target, verify typecheck passes, then merge with `--squash --admin`.
|
||||
|
||||
### Never Admin-Merge Without CI
|
||||
|
||||
In a previous bulk session, all 69 backport PRs were merged with `gh pr merge --squash --admin`, bypassing required CI checks. This shipped 3 test failures to a release branch. **Lesson: `--admin` skips all branch protection, including required status checks.** Only use `--admin` after confirming CI has passed (e.g., `gh pr checks $PR` shows all green), or rely on auto-merge (`--auto --squash`) which waits for CI by design.
|
||||
If typecheck fails, stop and investigate before continuing. A broken branch after wave N means all subsequent waves will compound the problem.
|
||||
|
||||
## Continuous Backporting Recommendation
|
||||
|
||||
@@ -189,43 +123,6 @@ Large backport sessions (50+ PRs) are expensive and error-prone. Prefer continuo
|
||||
- Reserve session-style bulk backporting for catching up after gaps
|
||||
- When a release branch is created, immediately start the continuous process
|
||||
|
||||
## Interactive Approval Flow
|
||||
|
||||
After analysis, present ALL candidates (MUST, SHOULD, and borderline) to the human for interactive review before execution. Do not write a static decisions.md — collect approvals in conversation.
|
||||
|
||||
### Batch Presentation
|
||||
|
||||
Present PRs in batches of 5-10, grouped by theme (visual bugs, interaction bugs, cloud/auth, data correctness, etc.). Use this table format:
|
||||
|
||||
```
|
||||
# | PR | Title | Target | Rec | Context
|
||||
----+--------+------------------------------------------+---------------+------+--------
|
||||
1 | #12345 | fix: broken thing | core+cloud/42 | Y | Description here. Why it matters. Agent reasoning.
|
||||
2 | #12346 | fix: another issue | core/42 | N | Only affects removed feature. Not on target branch.
|
||||
```
|
||||
|
||||
Each row includes:
|
||||
|
||||
- PR number and title
|
||||
- Target branches
|
||||
- Agent recommendation: `Rec: Y` or `Rec: N` with brief reasoning
|
||||
- 2-3 sentence context: what the PR does, why it matters (or doesn't)
|
||||
|
||||
### Human Response Format
|
||||
|
||||
- `Y` — approve for backport
|
||||
- `N` — skip
|
||||
- `?` — investigate (agent shows PR description, files changed, detailed take, then re-asks)
|
||||
- Any freeform question or comment triggers discussion before moving on
|
||||
- Bulk responses accepted (e.g. `1 Y, 2 Y, 3 N, 4 ?`)
|
||||
|
||||
### Rules
|
||||
|
||||
- ALL candidates are reviewed, not just MUST items
|
||||
- When human responds `?`, show the PR description, files changed, and agent's detailed analysis, then re-ask for their decision
|
||||
- When human asks a question about a PR, answer with context and recommendation, then wait for their decision
|
||||
- Do not proceed to execution until all batches are reviewed and every candidate has a Y or N
|
||||
|
||||
## Quick Reference
|
||||
|
||||
### Label-Driven Automation (default path)
|
||||
@@ -241,96 +138,13 @@ gh api repos/Comfy-Org/ComfyUI_frontend/issues/$PR/labels \
|
||||
```bash
|
||||
git worktree add /tmp/backport-$BRANCH origin/$BRANCH
|
||||
cd /tmp/backport-$BRANCH
|
||||
|
||||
# For each PR:
|
||||
git fetch origin $BRANCH
|
||||
git checkout -b backport-$PR-to-$BRANCH origin/$BRANCH
|
||||
git cherry-pick -m 1 $MERGE_SHA
|
||||
# Resolve conflicts (see Conflict Triage)
|
||||
git push origin backport-$PR-to-$BRANCH --no-verify
|
||||
gh pr create --base $BRANCH --head backport-$PR-to-$BRANCH \
|
||||
--title "[backport $BRANCH] $TITLE (#$PR)" \
|
||||
--body "Backport of #$PR. [conflict notes]"
|
||||
gh pr merge $NEW_PR --squash --admin
|
||||
sleep 25
|
||||
# Resolve conflicts, push, create PR, merge
|
||||
```
|
||||
|
||||
### Efficient Batch: Test-Then-Resolve Pattern
|
||||
|
||||
When many PRs need manual cherry-pick (e.g., cloud branches), test all first:
|
||||
|
||||
```bash
|
||||
cd /tmp/backport-$BRANCH
|
||||
for pr in "${ORDER[@]}"; do
|
||||
git checkout -b test-$pr origin/$BRANCH
|
||||
if git cherry-pick -m 1 $SHA 2>/dev/null; then
|
||||
echo "CLEAN: $pr"
|
||||
else
|
||||
echo "CONFLICT: $pr"
|
||||
git cherry-pick --abort
|
||||
fi
|
||||
git checkout --detach HEAD
|
||||
git branch -D test-$pr
|
||||
done
|
||||
```
|
||||
|
||||
Then process clean PRs in a batch loop, conflicts individually.
|
||||
|
||||
### PR Title Convention
|
||||
|
||||
```
|
||||
[backport TARGET_BRANCH] Original Title (#ORIGINAL_PR)
|
||||
```
|
||||
|
||||
## Final Deliverables (Slack-Compatible)
|
||||
|
||||
After execution completes, generate two files in `~/temp/backport-session/`. Both must be **Slack-compatible plain text** — no emojis, no markdown tables, no headers (`#`), no bold (`**`), no inline code. Use plain dashes, indentation, and line breaks only.
|
||||
|
||||
### 1. Author Accountability Report
|
||||
|
||||
File: `backport-author-accountability.md`
|
||||
|
||||
Lists all backported PRs grouped by original author (via `gh pr view $PR --json author`). Surfaces who should be self-labeling.
|
||||
|
||||
```
|
||||
Backport Session YYYY-MM-DD -- PRs that should have been labeled by authors
|
||||
|
||||
- author-login
|
||||
- #1234 fix: short title
|
||||
- #5678 fix: another title
|
||||
- other-author
|
||||
- #9012 fix: some other fix
|
||||
```
|
||||
|
||||
Authors sorted alphabetically, 4-space indent for nested items.
|
||||
|
||||
### 2. Slack Status Update
|
||||
|
||||
File: `slack-status-update.md`
|
||||
|
||||
A shareable summary of the session. Structure:
|
||||
|
||||
```
|
||||
Backport session complete -- YYYY-MM-DD
|
||||
|
||||
[1-sentence summary: N PRs backported to which branches. All pass typecheck.]
|
||||
|
||||
Branches updated:
|
||||
- core/X.XX: N PRs + N fix PRs (N auto, N manual)
|
||||
- cloud/X.XX: N PRs + N fix PRs (N auto, N manual)
|
||||
- ...
|
||||
|
||||
N total PRs created and merged (N backports + N fix PRs).
|
||||
|
||||
Notable fixes included:
|
||||
- [category]: [list of fixes]
|
||||
- ...
|
||||
|
||||
Conflict patterns encountered:
|
||||
- [pattern and how it was resolved]
|
||||
- ...
|
||||
|
||||
N authors had PRs backported. See author accountability list for details.
|
||||
```
|
||||
|
||||
No emojis, no tables, no bold, no headers. Plain text that pastes cleanly into Slack.
|
||||
|
||||
@@ -23,10 +23,10 @@ For SHOULD items with conflicts: if conflict resolution requires more than trivi
|
||||
|
||||
**Before categorizing, filter by branch scope:**
|
||||
|
||||
| Target branch | Skip if PR is... |
|
||||
| ------------- | ----------------------------------------------------------------------------------------------------------------- |
|
||||
| `core/*` | Cloud-only (team workspaces, cloud queue, cloud-only login). Note: app mode and Firebase auth are NOT cloud-only. |
|
||||
| `cloud/*` | Local-only features not present on cloud branch |
|
||||
| Target branch | Skip if PR is... |
|
||||
| ------------- | ------------------------------------------------------------------- |
|
||||
| `core/*` | Cloud-only (app mode, cloud auth, cloud billing, cloud-specific UI) |
|
||||
| `cloud/*` | Local-only features not present on cloud branch |
|
||||
|
||||
Cloud-only PRs backported to `core/*` are wasted effort — `core/*` branches serve local/self-hosted users who never see cloud features. Check PR titles, descriptions, and files changed for cloud-specific indicators.
|
||||
|
||||
@@ -61,6 +61,8 @@ done
|
||||
|
||||
## Human Review Checkpoint
|
||||
|
||||
Use the Interactive Approval Flow (see SKILL.md) to review all candidates interactively. Do not write a static decisions.md for the human to edit — instead, present batches of 5-10 PRs with context and recommendations, and collect Y/N/? responses in conversation.
|
||||
Present decisions.md before execution. Include:
|
||||
|
||||
All candidates must be reviewed (MUST, SHOULD, and borderline items), not just a subset.
|
||||
1. All MUST/SHOULD/SKIP categorizations with rationale
|
||||
2. Questions for human (feature existence, scope, deps)
|
||||
3. Estimated effort per branch
|
||||
|
||||
@@ -19,44 +19,23 @@ done
|
||||
# Wait 3 minutes for automation
|
||||
sleep 180
|
||||
|
||||
# Check which got auto-PRs (auto-merge is enabled, so clean ones will self-merge after CI)
|
||||
# Check which got auto-PRs
|
||||
gh pr list --base TARGET_BRANCH --state open --limit 50 --json number,title
|
||||
```
|
||||
|
||||
> **Note:** The `pr-backport.yaml` workflow now enables `gh pr merge --auto --squash` on automation-created PRs. Clean PRs will auto-merge once CI passes — no manual merge needed for those.
|
||||
|
||||
## Step 2: Wait for CI & Merge Clean Auto-PRs
|
||||
|
||||
Most automation PRs will auto-merge once CI passes (via `--auto --squash` in the workflow). Monitor and handle failures:
|
||||
## Step 2: Review & Merge Clean Auto-PRs
|
||||
|
||||
```bash
|
||||
# Wait for CI to complete (~45 minutes for full suite)
|
||||
sleep 2700
|
||||
|
||||
# Check which PRs are still open (CI may have failed, or auto-merge succeeded)
|
||||
STILL_OPEN_PRS=$(gh pr list --base TARGET_BRANCH --state open --limit 50 --json number --jq '.[].number')
|
||||
RECENTLY_MERGED=$(gh pr list --base TARGET_BRANCH --state merged --limit 50 --json number,title,mergedAt)
|
||||
|
||||
# For PRs still open, check CI status
|
||||
for pr in $STILL_OPEN_PRS; do
|
||||
CI_FAILED=$(gh pr checks $pr --json name,state --jq '[.[] | select(.state == "FAILURE")] | length')
|
||||
CI_PENDING=$(gh pr checks $pr --json name,state --jq '[.[] | select(.state == "PENDING" or .state == "QUEUED")] | length')
|
||||
if [ "$CI_FAILED" != "0" ]; then
|
||||
# CI failed — collect details for triage
|
||||
echo "PR #$pr — CI FAILED:"
|
||||
gh pr checks $pr --json name,state,link --jq '.[] | select(.state == "FAILURE") | "\(.name): \(.state)"'
|
||||
elif [ "$CI_PENDING" != "0" ]; then
|
||||
echo "PR #$pr — CI still running ($CI_PENDING checks pending)"
|
||||
else
|
||||
# All checks passed but didn't auto-merge (race condition or label issue)
|
||||
gh pr merge $pr --squash --admin
|
||||
sleep 3
|
||||
fi
|
||||
for pr in $AUTO_PRS; do
|
||||
# Check size
|
||||
gh pr view $pr --json title,additions,deletions,changedFiles \
|
||||
--jq '"Files: \(.changedFiles), +\(.additions)/-\(.deletions)"'
|
||||
# Admin merge
|
||||
gh pr merge $pr --squash --admin
|
||||
sleep 3
|
||||
done
|
||||
```
|
||||
|
||||
**⚠️ If CI fails: DO NOT admin-merge to bypass.** See "CI Failure Triage" below.
|
||||
|
||||
## Step 3: Manual Worktree for Conflicts
|
||||
|
||||
```bash
|
||||
@@ -73,32 +52,17 @@ for PR in ${CONFLICT_PRS[@]}; do
|
||||
git cherry-pick -m 1 $MERGE_SHA
|
||||
|
||||
# If conflict — NEVER skip based on file count alone!
|
||||
# Categorize conflicts first: binary PNGs, modify/delete, content, add/add, component rewrites
|
||||
# Categorize conflicts first: binary PNGs, modify/delete, content, add/add
|
||||
# See SKILL.md Conflict Triage table for resolution per type.
|
||||
|
||||
# For component rewrites (4+ markers in a .vue file, library migration):
|
||||
# DO NOT use accept-theirs regex — it produces broken hybrids.
|
||||
# Instead, use the complete file from the merge commit:
|
||||
# git show $MERGE_SHA:path/to/file > path/to/file
|
||||
|
||||
# For simple content conflicts, accept theirs:
|
||||
# python3 -c "import re; ..."
|
||||
|
||||
# Resolve all conflicts, then:
|
||||
git add .
|
||||
GIT_EDITOR=true git cherry-pick --continue
|
||||
|
||||
git push origin backport-$PR-to-TARGET --no-verify
|
||||
git push origin backport-$PR-to-TARGET
|
||||
NEW_PR=$(gh pr create --base TARGET_BRANCH --head backport-$PR-to-TARGET \
|
||||
--title "[backport TARGET] TITLE (#$PR)" \
|
||||
--body "Backport of #$PR..." | grep -oP '\d+$')
|
||||
|
||||
# Wait for CI before merging — NEVER admin-merge without CI passing
|
||||
echo "Waiting for CI on PR #$NEW_PR..."
|
||||
gh pr checks $NEW_PR --watch --fail-fast || {
|
||||
echo "⚠️ CI failed on PR #$NEW_PR — skipping merge, needs triage"
|
||||
continue
|
||||
}
|
||||
gh pr merge $NEW_PR --squash --admin
|
||||
sleep 3
|
||||
done
|
||||
@@ -118,34 +82,11 @@ After completing all PRs in a wave for a target branch:
|
||||
git fetch origin TARGET_BRANCH
|
||||
git worktree add /tmp/verify-TARGET origin/TARGET_BRANCH
|
||||
cd /tmp/verify-TARGET
|
||||
source ~/.nvm/nvm.sh && nvm use 24 && pnpm install && pnpm typecheck && pnpm test:unit
|
||||
source ~/.nvm/nvm.sh && nvm use 24 && pnpm install && pnpm typecheck
|
||||
git worktree remove /tmp/verify-TARGET --force
|
||||
```
|
||||
|
||||
If verification fails, **do not skip** — create a fix PR:
|
||||
|
||||
```bash
|
||||
# Stay in the verify worktree
|
||||
git checkout -b fix-backport-TARGET origin/TARGET_BRANCH
|
||||
|
||||
# Common fixes:
|
||||
# 1. Component rewrite hybrids: overwrite with merge commit version
|
||||
git show MERGE_SHA:path/to/Component.vue > path/to/Component.vue
|
||||
|
||||
# 2. Missing dependency files
|
||||
git show MERGE_SHA:path/to/missing.ts > path/to/missing.ts
|
||||
|
||||
# 3. Missing type properties: edit the interface
|
||||
# 4. Unused imports: delete the import lines
|
||||
|
||||
git add -A
|
||||
git commit --no-verify -m "fix: resolve backport typecheck issues on TARGET"
|
||||
git push origin fix-backport-TARGET --no-verify
|
||||
gh pr create --base TARGET --head fix-backport-TARGET --title "fix: resolve backport typecheck issues on TARGET" --body "..."
|
||||
gh pr merge $PR --squash --admin
|
||||
```
|
||||
|
||||
Do not proceed to the next branch until typecheck passes.
|
||||
If verification fails, stop and fix before proceeding to the next wave. Do not compound problems across waves.
|
||||
|
||||
## Conflict Resolution Patterns
|
||||
|
||||
@@ -173,35 +114,7 @@ git rm $FILE
|
||||
git checkout --theirs $FILE && git add $FILE
|
||||
```
|
||||
|
||||
### 4. Component Rewrites (DO NOT accept-theirs)
|
||||
|
||||
When a PR completely rewrites a component (e.g., PrimeVue → Reka UI), accept-theirs produces
|
||||
a broken hybrid with mismatched template/script sections.
|
||||
|
||||
```bash
|
||||
# Use the complete correct file from the merge commit instead:
|
||||
git show $MERGE_SHA:src/components/input/MultiSelect.vue > src/components/input/MultiSelect.vue
|
||||
git show $MERGE_SHA:src/components/input/SingleSelect.vue > src/components/input/SingleSelect.vue
|
||||
git add src/components/input/MultiSelect.vue src/components/input/SingleSelect.vue
|
||||
```
|
||||
|
||||
**Detection:** 4+ conflict markers in a single `.vue` file, imports changing between component
|
||||
libraries (PrimeVue → Reka UI, etc.), template structure completely different on each side.
|
||||
|
||||
### 5. Missing Dependencies After Cherry-Pick
|
||||
|
||||
Cherry-picks can succeed but leave the branch broken because the PR's code on main
|
||||
references composables/components introduced by an earlier PR.
|
||||
|
||||
```bash
|
||||
# Add the missing file from the merge commit:
|
||||
git show $MERGE_SHA:src/composables/queue/useJobDetailsHover.ts > src/composables/queue/useJobDetailsHover.ts
|
||||
git show $MERGE_SHA:src/components/builder/BuilderSaveDialogContent.vue > src/components/builder/BuilderSaveDialogContent.vue
|
||||
```
|
||||
|
||||
**Detection:** `pnpm typecheck` fails with "Cannot find module" or "X is not defined" after cherry-pick succeeds cleanly.
|
||||
|
||||
### 6. Locale Files
|
||||
### 4. Locale Files
|
||||
|
||||
Usually adding new i18n keys — accept theirs, validate JSON:
|
||||
|
||||
@@ -219,8 +132,7 @@ git rebase origin/TARGET_BRANCH
|
||||
# Resolve new conflicts
|
||||
git push --force origin backport-$PR-to-TARGET
|
||||
sleep 20 # Wait for GitHub to recompute merge state
|
||||
# Wait for CI after rebase before merging
|
||||
gh pr checks $PR --watch --fail-fast && gh pr merge $PR --squash --admin
|
||||
gh pr merge $PR --squash --admin
|
||||
```
|
||||
|
||||
## Lessons Learned
|
||||
@@ -234,37 +146,5 @@ gh pr checks $PR --watch --fail-fast && gh pr merge $PR --squash --admin
|
||||
7. **appModeStore.ts, painter files, GLSLShader files** don't exist on core/1.40 — `git rm` these
|
||||
8. **Always validate JSON** after resolving locale file conflicts
|
||||
9. **Dep refresh PRs** — skip on stable branches. Risk of transitive dep regressions outweighs audit cleanup. Cherry-pick individual CVE fixes instead.
|
||||
10. **Verify after each wave** — run `pnpm typecheck && pnpm test:unit` on the target branch after merging a batch. Catching breakage early prevents compounding errors.
|
||||
11. **App mode and Firebase auth are NOT cloud-only** — they go to both core and cloud branches. Only team workspaces, cloud queue, and cloud-specific login are cloud-only.
|
||||
12. **Never admin-merge without CI** — `--admin` bypasses all branch protections including required status checks. A bulk session of 69 admin-merges shipped 3 test failures. Always wait for CI to pass first, or use `--auto --squash` which waits by design.
|
||||
13. **Accept-theirs regex breaks component rewrites** — when a PR migrates between component libraries (PrimeVue → Reka UI), the regex produces a broken hybrid. Use `git show SHA:path > path` to get the complete correct version instead.
|
||||
14. **Cherry-picks can silently bring in missing-dependency code** — if PR A references a composable introduced by PR B, cherry-picking A succeeds but typecheck fails. Always run typecheck after each wave and add missing files from the merge commit.
|
||||
15. **Fix PRs are expected** — plan for 1 fix PR per branch to resolve typecheck issues from conflict resolutions. This is normal, not a failure.
|
||||
16. **Use `--no-verify` in worktrees** — husky hooks fail in `/tmp/` worktrees. Always push/commit with `--no-verify`.
|
||||
17. **Automation success varies by branch** — core/1.42 got 18/26 auto-PRs (69%), cloud/1.42 got 1/25 (4%). Cloud branches diverge more. Plan for manual fallback.
|
||||
18. **Test-then-resolve pattern** — for branches with low automation success, run a dry-run loop to classify clean vs conflict PRs before processing. This is much faster than resolving conflicts serially.
|
||||
|
||||
## CI Failure Triage
|
||||
|
||||
When CI fails on a backport PR, present failures to the user using this template:
|
||||
|
||||
```markdown
|
||||
### PR #XXXX — CI Failed
|
||||
|
||||
- **Failing check:** test / lint / typecheck
|
||||
- **Error:** (summary of the failure message)
|
||||
- **Likely cause:** test backported without implementation / missing dependency / flaky test / snapshot mismatch
|
||||
- **Recommendation:** backport PR #YYYY first / skip this PR / rerun CI after fixing prerequisites
|
||||
```
|
||||
|
||||
Common failure categories:
|
||||
|
||||
| Category | Example | Resolution |
|
||||
| --------------------------- | ---------------------------------------- | ----------------------------------------- |
|
||||
| Test without implementation | Test references function not on branch | Backport the implementation PR first |
|
||||
| Missing dependency | Import from module not on branch | Backport the dependency PR first, or skip |
|
||||
| Snapshot mismatch | Screenshot test differs | Usually safe — update snapshots on branch |
|
||||
| Flaky test | Passes on retry | Re-run CI, merge if green on retry |
|
||||
| Type error | Interface changed on main but not branch | May need manual adaptation |
|
||||
|
||||
**Never assume a failure is safe to skip.** Present all failures to the user with analysis.
|
||||
10. **Verify after each wave** — run `pnpm typecheck` on the target branch after merging a batch. Catching breakage early prevents compounding errors.
|
||||
11. **Cloud-only PRs don't belong on core/\* branches** — app mode, cloud auth, and cloud-specific UI changes are irrelevant to local users. Always check PR scope against branch scope before backporting.
|
||||
|
||||
@@ -2,25 +2,25 @@
|
||||
|
||||
## During Execution
|
||||
|
||||
Maintain `execution-log.md` with per-branch tables (this is internal, markdown tables are fine here):
|
||||
Maintain `execution-log.md` with per-branch tables:
|
||||
|
||||
```markdown
|
||||
| PR# | Title | Status | Backport PR | Notes |
|
||||
| ----- | ----- | ------ | ----------- | ------- |
|
||||
| #XXXX | Title | merged | #YYYY | Details |
|
||||
| PR# | Title | Status | Backport PR | Notes |
|
||||
| ----- | ----- | --------------------------------- | ----------- | ------- |
|
||||
| #XXXX | Title | ✅ Merged / ⏭️ Skip / ⏸️ Deferred | #YYYY | Details |
|
||||
```
|
||||
|
||||
## Wave Verification Log
|
||||
|
||||
Track verification results per wave within execution-log.md:
|
||||
Track verification results per wave:
|
||||
|
||||
```markdown
|
||||
Wave N Verification -- TARGET_BRANCH
|
||||
## Wave N Verification — TARGET_BRANCH
|
||||
|
||||
- PRs merged: #A, #B, #C
|
||||
- Typecheck: pass / fail
|
||||
- Fix PR: #YYYY (if needed)
|
||||
- Typecheck: ✅ Pass / ❌ Fail
|
||||
- Issues found: (if any)
|
||||
- Human review needed: (list any non-trivial conflict resolutions)
|
||||
```
|
||||
|
||||
## Session Report Template
|
||||
@@ -41,11 +41,6 @@ Wave N Verification -- TARGET_BRANCH
|
||||
|
||||
| PR# | Branch | Conflict Type | Resolution Summary |
|
||||
|
||||
## CI Failure Report
|
||||
|
||||
| PR# | Branch | Failing Check | Error Summary | Cause | Resolution |
|
||||
| --- | ------ | ------------- | ------------- | ----- | ---------- |
|
||||
|
||||
## Automation Performance
|
||||
|
||||
| Metric | Value |
|
||||
@@ -62,42 +57,40 @@ Wave N Verification -- TARGET_BRANCH
|
||||
- Feature branches that need tracking for future sessions?
|
||||
```
|
||||
|
||||
## Final Deliverables
|
||||
## Final Deliverable: Visual Summary
|
||||
|
||||
After all branches are complete and verified, generate these files in `~/temp/backport-session/`:
|
||||
At session end, generate a **mermaid diagram** showing all backported PRs organized by target branch and category (MUST/SHOULD), plus a summary table. Present this to the user as the final output.
|
||||
|
||||
### 1. execution-log.md (internal)
|
||||
```mermaid
|
||||
graph TD
|
||||
subgraph branch1["☁️ cloud/X.XX — N PRs"]
|
||||
C1["#XXXX title"]
|
||||
C2["#XXXX title"]
|
||||
end
|
||||
|
||||
Per-branch tables with PR#, title, status, backport PR#, notes. Markdown tables are fine — this is for internal tracking, not Slack.
|
||||
subgraph branch2must["🔴 core/X.XX MUST — N PRs"]
|
||||
M1["#XXXX title"]
|
||||
end
|
||||
|
||||
### 2. backport-author-accountability.md (Slack-compatible)
|
||||
subgraph branch2should["🟡 core/X.XX SHOULD — N PRs"]
|
||||
S1["#XXXX-#XXXX N auto-merged"]
|
||||
S2["#XXXX-#XXXX N manual picks"]
|
||||
end
|
||||
|
||||
See SKILL.md "Final Deliverables" section. Plain text, no emojis/tables/headers/bold. Authors sorted alphabetically with PRs nested under each.
|
||||
classDef cloudStyle fill:#1a3a5c,stroke:#4da6ff,color:#e0f0ff
|
||||
classDef coreStyle fill:#1a4a2e,stroke:#4dff88,color:#e0ffe8
|
||||
classDef mustStyle fill:#5c1a1a,stroke:#ff4d4d,color:#ffe0e0
|
||||
classDef shouldStyle fill:#4a3a1a,stroke:#ffcc4d,color:#fff5e0
|
||||
```
|
||||
|
||||
### 3. slack-status-update.md (Slack-compatible)
|
||||
|
||||
See SKILL.md "Final Deliverables" section. Plain text summary that pastes cleanly into Slack. Includes branch counts, notable fixes, conflict patterns, author count.
|
||||
|
||||
## Slack Formatting Rules
|
||||
|
||||
Both shareable files (author accountability + status update) must follow these rules:
|
||||
|
||||
- No emojis (no checkmarks, no arrows, no icons)
|
||||
- No markdown tables (use plain lists with dashes)
|
||||
- No headers (no # or ##)
|
||||
- No bold (\*_) or italic (_)
|
||||
- No inline code backticks
|
||||
- Use -- instead of em dash
|
||||
- Use plain dashes (-) for lists with 4-space indent for nesting
|
||||
- Line breaks between sections for readability
|
||||
|
||||
These files should paste directly into a Slack message and look clean.
|
||||
Use the `mermaid` tool to render this diagram and present it alongside the summary table as the session's final deliverable.
|
||||
|
||||
## Files to Track
|
||||
|
||||
All in `~/temp/backport-session/`:
|
||||
- `candidate_list.md` — all candidates per branch
|
||||
- `decisions.md` — MUST/SHOULD/SKIP with rationale
|
||||
- `wave-plan.md` — execution order
|
||||
- `execution-log.md` — real-time status
|
||||
- `backport-session-report.md` — final summary
|
||||
|
||||
- `execution-plan.md` -- approved PRs with merge SHAs (input)
|
||||
- `execution-log.md` -- real-time status with per-branch tables (internal)
|
||||
- `backport-author-accountability.md` -- PRs grouped by author (Slack-compatible)
|
||||
- `slack-status-update.md` -- session summary (Slack-compatible)
|
||||
All in `~/temp/backport-session/`.
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
---
|
||||
name: contain-audit
|
||||
description: 'Detect DOM elements where CSS contain:layout+style would improve rendering performance. Runs a Playwright-based audit on a large workflow, scores candidates by subtree size and sizing constraints, measures performance impact, and generates a ranked report.'
|
||||
---
|
||||
|
||||
# CSS Containment Audit
|
||||
|
||||
Automatically finds DOM elements where adding `contain: layout style` would reduce browser recalculation overhead.
|
||||
|
||||
## What It Does
|
||||
|
||||
1. Loads a large workflow (245 nodes) in a real browser
|
||||
2. Walks the DOM tree and scores every element as a containment candidate
|
||||
3. For each high-scoring candidate, applies `contain: layout style` via JavaScript
|
||||
4. Measures rendering performance (style recalcs, layouts, task duration) before and after
|
||||
5. Takes before/after screenshots to detect visual breakage
|
||||
6. Generates a ranked report with actionable recommendations
|
||||
|
||||
## When to Use
|
||||
|
||||
- After adding new Vue components to the node rendering pipeline
|
||||
- When investigating rendering performance on large workflows
|
||||
- Before and after refactoring node DOM structure
|
||||
- As part of periodic performance audits
|
||||
|
||||
## How to Run
|
||||
|
||||
```bash
|
||||
# Start the dev server first
|
||||
pnpm dev &
|
||||
|
||||
# Run the audit (uses the @audit tag, not included in normal CI runs)
|
||||
pnpm exec playwright test browser_tests/tests/containAudit.spec.ts --project=audit
|
||||
|
||||
# View the HTML report
|
||||
pnpm exec playwright show-report
|
||||
```
|
||||
|
||||
## How to Read Results
|
||||
|
||||
The audit outputs a table to the console:
|
||||
|
||||
```text
|
||||
CSS Containment Audit Results
|
||||
=======================================================
|
||||
Rank | Selector | Subtree | Score | DRecalcs | DLayouts | Visual
|
||||
1 | [data-testid="node-inner-wrap"] | 18 | 72 | -34% | -12% | OK
|
||||
2 | .node-body | 12 | 48 | -8% | -3% | OK
|
||||
3 | .node-header | 4 | 16 | +1% | 0% | OK
|
||||
```
|
||||
|
||||
- **Subtree**: Number of descendant elements (higher = more to skip)
|
||||
- **Score**: Composite heuristic score (subtree size x sizing constraint bonus)
|
||||
- **DRecalcs / DLayouts**: Change in style recalcs / layout counts vs baseline (negative = improvement)
|
||||
- **Visual**: OK if no pixel change, DIFF if screenshot differs (may include subpixel noise — verify manually)
|
||||
|
||||
## Candidate Scoring
|
||||
|
||||
An element is a good containment candidate when:
|
||||
|
||||
1. **Large subtree** -- many descendants that the browser can skip recalculating
|
||||
2. **Externally constrained size** -- width/height determined by CSS variables, flex, or explicit values (not by content)
|
||||
3. **No existing containment** -- `contain` is not already applied
|
||||
4. **Not a leaf** -- has at least a few child elements
|
||||
|
||||
Elements that should NOT get containment:
|
||||
|
||||
- Elements whose children overflow visually beyond bounds (e.g., absolute-positioned overlays with negative inset)
|
||||
- Elements whose height is determined by content and affects sibling layout
|
||||
- Very small subtrees (overhead of containment context outweighs benefit)
|
||||
|
||||
## Limitations
|
||||
|
||||
- Cannot fully guarantee `contain` safety -- visual review of screenshots is required
|
||||
- Performance measurements have natural variance; run multiple times for confidence
|
||||
- Only tests idle and pan scenarios; widget interactions may differ
|
||||
- The audit modifies styles at runtime via JS, which doesn't account for Tailwind purging or build-time optimizations
|
||||
|
||||
## Example PR
|
||||
|
||||
[#9946 — fix: add CSS contain:layout contain:style to node inner wrapper](https://github.com/Comfy-Org/ComfyUI_frontend/pull/9946)
|
||||
|
||||
This PR added `contain-layout contain-style` to the node inner wrapper div in `LGraphNode.vue`. The audit tool would have flagged this element as a high-scoring candidate because:
|
||||
|
||||
- **Large subtree** (18+ descendants: header, slots, widgets, content, badges)
|
||||
- **Externally constrained size** (`w-(--node-width)`, `flex-1` — dimensions set by CSS variables and flex parent)
|
||||
- **Natural isolation boundary** between frequently-changing content (widgets) and infrequently-changing overlays (selection outlines, borders)
|
||||
|
||||
The actual change was a single line: adding `'contain-layout contain-style'` to the inner wrapper's class list at `src/renderer/extensions/vueNodes/components/LGraphNode.vue:79`.
|
||||
|
||||
## Reference
|
||||
|
||||
| Resource | Path |
|
||||
| ----------------- | ------------------------------------------------------- |
|
||||
| Audit test | `browser_tests/tests/containAudit.spec.ts` |
|
||||
| PerformanceHelper | `browser_tests/fixtures/helpers/PerformanceHelper.ts` |
|
||||
| Perf tests | `browser_tests/tests/performance.spec.ts` |
|
||||
| Large workflow | `browser_tests/assets/large-graph-workflow.json` |
|
||||
| Example PR | https://github.com/Comfy-Org/ComfyUI_frontend/pull/9946 |
|
||||
@@ -1,246 +0,0 @@
|
||||
---
|
||||
name: hardening-flaky-e2e-tests
|
||||
description: 'Diagnoses and fixes flaky Playwright e2e tests by replacing race-prone patterns with retry-safe alternatives. Use when triaging CI flakes, hardening spec files, fixing timing races, or asked to stabilize browser tests. Triggers on: flaky, flake, harden, stabilize, race condition in e2e, intermittent failure.'
|
||||
---
|
||||
|
||||
# Hardening Flaky E2E Tests
|
||||
|
||||
Fix flaky Playwright specs by identifying race-prone patterns and replacing them with retry-safe alternatives. This skill covers diagnosis, pattern matching, and mechanical transforms — not writing new tests (see `writing-playwright-tests` for that).
|
||||
|
||||
## Workflow
|
||||
|
||||
### 1. Gather CI Evidence
|
||||
|
||||
```bash
|
||||
gh run list --workflow=ci-test.yaml --limit=5
|
||||
gh run download <run-id> -n playwright-report
|
||||
```
|
||||
|
||||
- Open `report.json` and search for `"status": "flaky"` entries.
|
||||
- Collect file paths, test titles, and error messages.
|
||||
- Do NOT trust green checks alone — flaky tests that passed on retry still need fixing.
|
||||
- Use `error-context.md`, traces, and page snapshots before editing code.
|
||||
- Pull the newest run after each push instead of assuming the flaky set is unchanged.
|
||||
|
||||
### 2. Classify the Flake
|
||||
|
||||
Read the failing assertion and match it against the pattern table. Most flakes fall into one of these categories:
|
||||
|
||||
| # | Pattern | Signature in Code | Fix |
|
||||
| --- | ------------------------------------- | --------------------------------------------------------- | ---------------------------------------------------------------- |
|
||||
| 1 | **Snapshot-then-assert** | `expect(await evaluate()).toBe(x)` | `await expect.poll(() => evaluate()).toBe(x)` |
|
||||
| 2 | **Immediate count** | `const n = await loc.count(); expect(n).toBe(3)` | `await expect(loc).toHaveCount(3)` |
|
||||
| 3 | **nextFrame after menu click** | `clickMenuItem(x); nextFrame()` | `clickMenuItem(x); contextMenu.waitForHidden()` |
|
||||
| 4 | **Tight poll timeout** | `expect.poll(..., { timeout: 250 })` | ≥2000 ms; prefer default 5000 ms |
|
||||
| 5 | **Immediate evaluate after mutation** | `setSetting(k, v); expect(await evaluate()).toBe(x)` | `await expect.poll(() => evaluate()).toBe(x)` |
|
||||
| 6 | **Screenshot without readiness** | `loadWorkflow(); nextFrame(); toHaveScreenshot()` | `waitForNodes()` or poll state first |
|
||||
| 7 | **Non-deterministic node order** | `getNodeRefsByType('X')[0]` with >1 match | `getNodeRefById(id)` or guard `toHaveLength(1)` |
|
||||
| 8 | **Fake readiness helper** | Helper clicks but doesn't assert state | Remove; poll the actual value |
|
||||
| 9 | **Immediate graph state after drop** | `expect(await getLinkCount()).toBe(1)` | `await expect.poll(() => getLinkCount()).toBe(1)` |
|
||||
| 10 | **Immediate boundingBox/layout read** | `const box = await loc.boundingBox(); expect(box!.width)` | `await expect.poll(() => loc.boundingBox().then(b => b?.width))` |
|
||||
|
||||
### 3. Apply the Transform
|
||||
|
||||
#### Rule: Choose the Smallest Correct Assertion
|
||||
|
||||
- **Locator state** → use built-in retrying assertions: `toBeVisible()`, `toHaveText()`, `toHaveCount()`, `toHaveClass()`
|
||||
- **Single async value** → `expect.poll(() => asyncFn()).toBe(expected)`
|
||||
- **Multiple assertions that must settle together** → `expect(async () => { ... }).toPass()`
|
||||
- **Never** use `waitForTimeout()` to hide a race.
|
||||
|
||||
```typescript
|
||||
// ✅ Single value — use expect.poll
|
||||
await expect
|
||||
.poll(() => comfyPage.page.evaluate(() => window.app!.graph.links.length))
|
||||
.toBe(3)
|
||||
|
||||
// ✅ Locator count — use toHaveCount
|
||||
await expect(comfyPage.page.locator('.dom-widget')).toHaveCount(2)
|
||||
|
||||
// ✅ Multiple conditions — use toPass
|
||||
await expect(async () => {
|
||||
expect(await node1.getValue()).toBe('foo')
|
||||
expect(await node2.getValue()).toBe('bar')
|
||||
}).toPass({ timeout: 5000 })
|
||||
```
|
||||
|
||||
#### Rule: Wait for the Real Readiness Boundary
|
||||
|
||||
Visible is not always ready. Prefer user-facing assertions when possible; poll internal state only when there is no UI surface to assert on.
|
||||
|
||||
Common readiness boundaries:
|
||||
|
||||
| After this action... | Wait for... |
|
||||
| -------------------------------------- | ------------------------------------------------------------ |
|
||||
| Canvas interaction (drag, click node) | `await comfyPage.nextFrame()` |
|
||||
| Menu item click | `await contextMenu.waitForHidden()` |
|
||||
| Workflow load | `await comfyPage.workflow.loadWorkflow(...)` (built-in wait) |
|
||||
| Settings write | Poll the setting value with `expect.poll()` |
|
||||
| Node pin/bypass/collapse toggle | `await expect.poll(() => nodeRef.isPinned()).toBe(true)` |
|
||||
| Graph mutation (add/remove node, link) | Poll link/node count |
|
||||
| Clipboard write | Poll pasted value |
|
||||
| Screenshot | Ensure nodes are rendered: `waitForNodes()` or poll state |
|
||||
|
||||
#### Rule: Expose Locators for Retrying Assertions
|
||||
|
||||
When a helper returns a count via `await loc.count()`, callers can't use `toHaveCount()`. Expose the underlying `Locator` as a getter so callers choose between:
|
||||
|
||||
```typescript
|
||||
// Helper exposes locator
|
||||
get domWidgets(): Locator {
|
||||
return this.page.locator('.dom-widget')
|
||||
}
|
||||
|
||||
// Caller uses retrying assertion
|
||||
await expect(comfyPage.domWidgets).toHaveCount(2)
|
||||
```
|
||||
|
||||
Replace count methods with locator getters so callers can use retrying assertions directly.
|
||||
|
||||
#### Rule: Fix Check-then-Act Races in Helpers
|
||||
|
||||
```typescript
|
||||
// ❌ Race: count can change between check and waitFor
|
||||
const count = await locator.count()
|
||||
if (count > 0) {
|
||||
await locator.waitFor({ state: 'hidden' })
|
||||
}
|
||||
|
||||
// ✅ Direct: waitFor handles both cases
|
||||
await locator.waitFor({ state: 'hidden' })
|
||||
```
|
||||
|
||||
#### Rule: Remove force:true from Clicks
|
||||
|
||||
`force: true` bypasses actionability checks, hiding real animation/visibility races. Remove it and fix the underlying timing issue.
|
||||
|
||||
```typescript
|
||||
// ❌ Hides the race
|
||||
await closeButton.click({ force: true })
|
||||
|
||||
// ✅ Surfaces the real issue — fix with proper wait
|
||||
await closeButton.click()
|
||||
await dialog.waitForHidden()
|
||||
```
|
||||
|
||||
#### Rule: Handle Non-deterministic Element Order
|
||||
|
||||
When `getNodeRefsByType` returns multiple nodes, the order is not guaranteed. Don't use index `[0]` blindly.
|
||||
|
||||
```typescript
|
||||
// ❌ Assumes order
|
||||
const node = (await comfyPage.nodeOps.getNodeRefsByType('CLIPTextEncode'))[0]
|
||||
|
||||
// ✅ Find by ID or proximity
|
||||
const nodes = await comfyPage.nodeOps.getNodeRefsByType('CLIPTextEncode')
|
||||
let target = nodes[0]
|
||||
for (const n of nodes) {
|
||||
const pos = await n.getPosition()
|
||||
if (Math.abs(pos.y - expectedY) < minDist) target = n
|
||||
}
|
||||
```
|
||||
|
||||
Or guard the assumption:
|
||||
|
||||
```typescript
|
||||
const nodes = await comfyPage.nodeOps.getNodeRefsByType('CLIPTextEncode')
|
||||
expect(nodes).toHaveLength(1)
|
||||
const node = nodes[0]
|
||||
```
|
||||
|
||||
#### Rule: Use toPass for Timing-sensitive Dismiss Guards
|
||||
|
||||
Some UI elements (e.g. LiteGraph's graphdialog) have built-in dismiss delays. Retry the entire dismiss action:
|
||||
|
||||
```typescript
|
||||
// ✅ Retry click+assert together
|
||||
await expect(async () => {
|
||||
await comfyPage.canvas.click({ position: { x: 10, y: 10 } })
|
||||
await expect(dialog).toBeHidden({ timeout: 500 })
|
||||
}).toPass({ timeout: 5000 })
|
||||
```
|
||||
|
||||
### 4. Keep Changes Narrow
|
||||
|
||||
- Shared helpers should drive setup to a stable boundary.
|
||||
- Do not encode one-spec timing assumptions into generic helpers.
|
||||
- If a race only matters to one spec, prefer a local wait in that spec.
|
||||
- If a helper fails before the real test begins, remove or relax the brittle precondition and let downstream UI interaction prove readiness.
|
||||
|
||||
### 5. Verify Narrowly
|
||||
|
||||
```bash
|
||||
# Targeted rerun with repetition
|
||||
pnpm test:browser:local -- browser_tests/tests/myFile.spec.ts --repeat-each 10
|
||||
|
||||
# Single test by line number (avoids grep quoting issues on Windows)
|
||||
pnpm test:browser:local -- browser_tests/tests/myFile.spec.ts:42
|
||||
```
|
||||
|
||||
- Use `--repeat-each 10` for targeted flake verification (use 20 for single test cases).
|
||||
- Verify with the smallest command that exercises the flaky path.
|
||||
|
||||
### 6. Watch CI E2E Runs
|
||||
|
||||
After pushing, use `gh` to monitor the E2E workflow:
|
||||
|
||||
```bash
|
||||
# Find the run for the current branch
|
||||
gh run list --workflow="CI: Tests E2E" --branch=$(git branch --show-current) --limit=1
|
||||
|
||||
# Watch it live (blocks until complete, streams logs)
|
||||
gh run watch <run-id>
|
||||
|
||||
# One-liner: find and watch the latest E2E run for the current branch
|
||||
gh run list --workflow="CI: Tests E2E" --branch=$(git branch --show-current) --limit=1 --json databaseId --jq ".[0].databaseId" | xargs gh run watch
|
||||
```
|
||||
|
||||
On Windows (PowerShell):
|
||||
|
||||
```powershell
|
||||
# One-liner equivalent
|
||||
gh run watch (gh run list --workflow="CI: Tests E2E" --branch=$(git branch --show-current) --limit=1 --json databaseId --jq ".[0].databaseId")
|
||||
```
|
||||
|
||||
After the run completes:
|
||||
|
||||
```bash
|
||||
# Download the Playwright report artifact
|
||||
gh run download <run-id> -n playwright-report
|
||||
|
||||
# View the run summary in browser
|
||||
gh run view <run-id> --web
|
||||
```
|
||||
|
||||
Also watch the unit test workflow in parallel if you changed helpers:
|
||||
|
||||
```bash
|
||||
gh run list --workflow="CI: Tests Unit" --branch=$(git branch --show-current) --limit=1
|
||||
```
|
||||
|
||||
### 7. Pre-merge Checklist
|
||||
|
||||
Before merging a flaky-test fix, confirm:
|
||||
|
||||
- [ ] The latest CI artifact was inspected directly
|
||||
- [ ] The root cause is stated as a race or readiness mismatch
|
||||
- [ ] The fix waits on the real readiness boundary
|
||||
- [ ] The assertion primitive matches the job (poll vs toHaveCount vs toPass)
|
||||
- [ ] The fix stays local unless a shared helper truly owns the race
|
||||
- [ ] Local verification uses a targeted rerun
|
||||
- [ ] No behavioral changes to the test — only timing/retry strategy updated
|
||||
|
||||
## Local Noise — Do Not Fix
|
||||
|
||||
These are local distractions, not CI root causes:
|
||||
|
||||
- Missing local input fixture files required by the test path
|
||||
- Missing local models directory
|
||||
- Teardown `EPERM` while restoring the local browser-test user data directory
|
||||
- Local screenshot baseline differences on Windows
|
||||
|
||||
Rules:
|
||||
|
||||
- First confirm whether it blocks the exact flaky path under investigation.
|
||||
- Do not commit temporary local assets used only for verification.
|
||||
- Do not commit local screenshot baselines.
|
||||
@@ -1,82 +0,0 @@
|
||||
---
|
||||
name: layer-audit
|
||||
description: 'Detect violations of the layered architecture import rules (base -> platform -> workbench -> renderer). Runs ESLint with the import-x/no-restricted-paths rule and generates a grouped report.'
|
||||
---
|
||||
|
||||
# Layer Architecture Audit
|
||||
|
||||
Finds imports that violate the layered architecture boundary rules enforced by `import-x/no-restricted-paths` in `eslint.config.ts`.
|
||||
|
||||
## Layer Hierarchy (bottom to top)
|
||||
|
||||
```
|
||||
renderer (top -- can import from all lower layers)
|
||||
^
|
||||
workbench
|
||||
^
|
||||
platform
|
||||
^
|
||||
base (bottom -- cannot import from any upper layer)
|
||||
```
|
||||
|
||||
Each layer may only import from layers below it.
|
||||
|
||||
## How to Run
|
||||
|
||||
```bash
|
||||
# Run ESLint filtering for just the layer boundary rule violations
|
||||
pnpm lint 2>&1 | grep 'import-x/no-restricted-paths' -B1 | head -200
|
||||
```
|
||||
|
||||
To get a full structured report, run:
|
||||
|
||||
```bash
|
||||
# Collect all violations from base/, platform/, workbench/ layers
|
||||
pnpm eslint src/base/ src/platform/ src/workbench/ --no-error-on-unmatched-pattern --rule '{"import-x/no-restricted-paths": "warn"}' --format compact 2>&1 | grep 'no-restricted-paths' | sort
|
||||
```
|
||||
|
||||
## How to Read Results
|
||||
|
||||
Each violation line shows:
|
||||
|
||||
- The **file** containing the bad import
|
||||
- The **import path** crossing the boundary
|
||||
- The **message** identifying which layer pair is violated
|
||||
|
||||
### Grouping by Layer Pair
|
||||
|
||||
After collecting violations, group them by the layer pair pattern:
|
||||
|
||||
| Layer pair | Meaning |
|
||||
| --------------------- | ----------------------------------- |
|
||||
| base -> platform | base/ importing from platform/ |
|
||||
| base -> workbench | base/ importing from workbench/ |
|
||||
| base -> renderer | base/ importing from renderer/ |
|
||||
| platform -> workbench | platform/ importing from workbench/ |
|
||||
| platform -> renderer | platform/ importing from renderer/ |
|
||||
| workbench -> renderer | workbench/ importing from renderer/ |
|
||||
|
||||
## When to Use
|
||||
|
||||
- Before creating a PR that adds imports between `src/base/`, `src/platform/`, `src/workbench/`, or `src/renderer/`
|
||||
- When auditing the codebase to find and plan migration of existing violations
|
||||
- After moving files between layers to verify no new violations were introduced
|
||||
|
||||
## Fixing Violations
|
||||
|
||||
Common strategies to resolve a layer violation:
|
||||
|
||||
1. **Move the import target down** -- if the imported module doesn't depend on upper-layer concepts, move it to a lower layer
|
||||
2. **Introduce an interface** -- define an interface/type in the lower layer and implement it in the upper layer via dependency injection or a registration pattern
|
||||
3. **Move the importing file up** -- if the file logically belongs in a higher layer, relocate it
|
||||
4. **Extract shared logic** -- pull the shared functionality into `base/` or a shared utility
|
||||
|
||||
## Reference
|
||||
|
||||
| Resource | Path |
|
||||
| ------------------------------- | ------------------ |
|
||||
| ESLint config (rule definition) | `eslint.config.ts` |
|
||||
| Base layer | `src/base/` |
|
||||
| Platform layer | `src/platform/` |
|
||||
| Workbench layer | `src/workbench/` |
|
||||
| Renderer layer | `src/renderer/` |
|
||||
361
.claude/skills/ticket-intake/SKILL.md
Normal file
361
.claude/skills/ticket-intake/SKILL.md
Normal file
@@ -0,0 +1,361 @@
|
||||
---
|
||||
name: ticket-intake
|
||||
description: 'Parse ticket URL (Notion or GitHub), extract all data, initialize pipeline run. Use when starting work on a new ticket or when asked to pick up a ticket.'
|
||||
---
|
||||
|
||||
# Ticket Intake
|
||||
|
||||
Parses a ticket URL from supported sources (Notion or GitHub), extracts all relevant information, and creates a ticket in the pipeline API.
|
||||
|
||||
> **🚨 CRITICAL REQUIREMENT**: This skill MUST register the ticket in the Pipeline API and update the source (Notion/GitHub). If these steps are skipped, the entire pipeline breaks. See [Mandatory API Calls](#mandatory-api-calls-execute-all-three) below.
|
||||
|
||||
## Supported Sources
|
||||
|
||||
| Source | URL Pattern | Provider File |
|
||||
| ------ | --------------------------------------------------- | --------------------- |
|
||||
| Notion | `https://notion.so/...` `https://www.notion.so/...` | `providers/notion.md` |
|
||||
| GitHub | `https://github.com/{owner}/{repo}/issues/{n}` | `providers/github.md` |
|
||||
|
||||
## Quick Start
|
||||
|
||||
When given a ticket URL:
|
||||
|
||||
1. **Detect source type** from URL pattern
|
||||
2. **Load provider-specific logic** from `providers/` directory
|
||||
3. Fetch ticket content via appropriate API
|
||||
4. Extract and normalize properties to common schema
|
||||
5. **Register ticket in pipeline API** ← MANDATORY
|
||||
6. **Update source** (Notion status / GitHub comment) ← MANDATORY
|
||||
7. **Run verification script** to confirm API registration
|
||||
8. Output summary and handoff to `research-orchestrator`
|
||||
|
||||
## Configuration
|
||||
|
||||
Uses the **production API** by default. No configuration needed for read operations.
|
||||
|
||||
**Defaults (no setup required):**
|
||||
|
||||
- API URL: `https://api-gateway-856475788601.us-central1.run.app`
|
||||
- Read-only endpoints at `/public/*` require no authentication
|
||||
|
||||
**For write operations** (transitions, creating tickets), set:
|
||||
|
||||
```bash
|
||||
export PIPELINE_API_KEY="..." # Get from GCP Secret Manager or ask admin
|
||||
```
|
||||
|
||||
**Optional (for local working artifacts):**
|
||||
|
||||
```bash
|
||||
PIPELINE_DIR="${PIPELINE_DIR:-$HOME/repos/ticket-to-pr-pipeline}"
|
||||
```
|
||||
|
||||
## Mandatory API Calls (Execute ALL Three)
|
||||
|
||||
**⚠️ These three API calls are the ENTIRE POINT of this skill. Without them, the ticket is invisible to the pipeline, downstream skills will fail, and Notion status won't update.**
|
||||
|
||||
**You MUST make these HTTP requests.** Use `curl` from bash — do not just read this as documentation.
|
||||
|
||||
### Call 1: Create Ticket
|
||||
|
||||
```bash
|
||||
API_URL="${PIPELINE_API_URL:-https://api-gateway-856475788601.us-central1.run.app}"
|
||||
API_KEY="${PIPELINE_API_KEY}"
|
||||
|
||||
curl -s -X POST "${API_URL}/v1/tickets" \
|
||||
-H "Authorization: Bearer ${API_KEY}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-Agent-ID: ${AGENT_ID:-amp-agent}" \
|
||||
-d '{
|
||||
"notion_page_id": "NOTION_PAGE_UUID_HERE",
|
||||
"title": "TICKET_TITLE_HERE",
|
||||
"source": "notion",
|
||||
"metadata": {
|
||||
"description": "DESCRIPTION_HERE",
|
||||
"priority": "High",
|
||||
"labels": [],
|
||||
"acceptanceCriteria": []
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
Save the returned `id` — you need it for the next two calls.
|
||||
|
||||
### Call 2: Transition to RESEARCH
|
||||
|
||||
```bash
|
||||
TICKET_ID="id-from-step-1"
|
||||
|
||||
curl -s -X POST "${API_URL}/v1/tickets/${TICKET_ID}/transition" \
|
||||
-H "Authorization: Bearer ${API_KEY}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-Agent-ID: ${AGENT_ID:-amp-agent}" \
|
||||
-d '{
|
||||
"to_state": "RESEARCH",
|
||||
"reason": "Intake complete, starting research"
|
||||
}'
|
||||
```
|
||||
|
||||
### Call 3: Queue Source Update
|
||||
|
||||
```bash
|
||||
curl -s -X POST "${API_URL}/v1/sync/queue" \
|
||||
-H "Authorization: Bearer ${API_KEY}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-Agent-ID: ${AGENT_ID:-amp-agent}" \
|
||||
-d '{
|
||||
"ticket_id": "TICKET_ID_HERE",
|
||||
"action": "update_status",
|
||||
"payload": { "status": "In Progress" },
|
||||
"priority": "normal"
|
||||
}'
|
||||
```
|
||||
|
||||
> **Note:** The action MUST be `"update_status"` (not `"UPDATE_NOTION_STATUS"`). Valid actions: `update_status`, `update_pr_url`, `mark_done`.
|
||||
|
||||
### TypeScript Equivalent (if using pipeline client)
|
||||
|
||||
```typescript
|
||||
import { PipelineClient } from '@pipeline/client'
|
||||
|
||||
const client = new PipelineClient({
|
||||
apiUrl:
|
||||
process.env.PIPELINE_API_URL ||
|
||||
'https://api-gateway-856475788601.us-central1.run.app',
|
||||
agentId: process.env.AGENT_ID!
|
||||
})
|
||||
|
||||
const ticket = await client.createTicket({
|
||||
notion_page_id: pageId,
|
||||
title: ticketTitle,
|
||||
source: 'notion',
|
||||
metadata: { description, priority, labels, acceptanceCriteria }
|
||||
})
|
||||
|
||||
await client.transitionState(
|
||||
ticket.id,
|
||||
'RESEARCH',
|
||||
'Intake complete, starting research'
|
||||
)
|
||||
|
||||
await client.queueSync(ticket.id, 'update_status', { status: 'In Progress' })
|
||||
```
|
||||
|
||||
## Workflow
|
||||
|
||||
### Step 1: Detect Source Type
|
||||
|
||||
Parse the URL to determine source:
|
||||
|
||||
```javascript
|
||||
if (url.includes('notion.so')) {
|
||||
source = 'notion'
|
||||
// Load providers/notion.md
|
||||
} else if (url.match(/github\.com\/[^\/]+\/[^\/]+\/issues\/\d+/)) {
|
||||
source = 'github'
|
||||
// Load providers/github.md
|
||||
} else {
|
||||
// Error: Unsupported URL format
|
||||
}
|
||||
```
|
||||
|
||||
### Step 2: Load Provider and Fetch Data
|
||||
|
||||
Read the appropriate provider file for source-specific instructions:
|
||||
|
||||
- **Notion**: `providers/notion.md` - Uses Notion MCP, handles Slack links
|
||||
- **GitHub**: `providers/github.md` - Uses `gh` CLI, handles Dosu comments
|
||||
|
||||
Follow the provider's instructions for:
|
||||
|
||||
- Fetching content
|
||||
- Extracting properties
|
||||
- **Updating the source** (Notion status → "In Progress", Assignee → pipeline owner)
|
||||
|
||||
### Step 3: Normalize to Common Schema
|
||||
|
||||
All providers must extract normalized ticket data following `schema.md`:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "abc12345",
|
||||
"url": "https://...",
|
||||
"source": "notion | github",
|
||||
"title": "Ticket title",
|
||||
"description": "Full description",
|
||||
"status": "Not Started",
|
||||
"assignee": "username",
|
||||
"priority": "High",
|
||||
"area": "UI",
|
||||
"labels": ["bug", "frontend"],
|
||||
"acceptanceCriteria": ["Criterion 1", "Criterion 2"],
|
||||
"fetchedAt": "2024-01-15T10:30:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
### Step 4: Register Ticket in Pipeline API (MANDATORY — DO NOT SKIP)
|
||||
|
||||
**Execute all three API calls from [Mandatory API Calls](#mandatory-api-calls-execute-all-three) above.**
|
||||
|
||||
This is not optional. This is not documentation. You MUST make these HTTP requests right now.
|
||||
|
||||
1. `createTicket()` → save the returned ticket ID
|
||||
2. `transitionState(id, 'RESEARCH')` → confirm state changed
|
||||
3. `queueSync(id, 'update_status', { status: 'In Progress' })` → confirm queued
|
||||
|
||||
**If any call fails**, retry once. If it still fails, report the error prominently — do NOT silently continue.
|
||||
|
||||
### Step 5: Run Verification Script
|
||||
|
||||
After making the API calls, run the verification script to confirm everything worked:
|
||||
|
||||
```bash
|
||||
bash scripts/verify-intake.sh TICKET_ID_OR_NOTION_PAGE_ID
|
||||
```
|
||||
|
||||
**If the script is not available locally**, verify manually via the public API:
|
||||
|
||||
```bash
|
||||
curl -s "${API_URL}/public/tickets/${TICKET_ID}" | jq '{id, state, title, notion_page_id}'
|
||||
```
|
||||
|
||||
Expected output:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "...",
|
||||
"state": "RESEARCH",
|
||||
"title": "...",
|
||||
"notion_page_id": "..."
|
||||
}
|
||||
```
|
||||
|
||||
**If `state` is not `RESEARCH`, go back to Step 4 and complete the missing calls.**
|
||||
|
||||
### Step 6: Output Summary and Handoff
|
||||
|
||||
Print a clear summary:
|
||||
|
||||
```markdown
|
||||
## Ticket Intake Complete
|
||||
|
||||
**Source:** Notion | GitHub
|
||||
**Title:** [Ticket title]
|
||||
**ID:** abc12345
|
||||
**Status:** In Progress (queued)
|
||||
**Priority:** High
|
||||
**Area:** UI
|
||||
|
||||
### Description
|
||||
|
||||
[Brief description or first 200 chars]
|
||||
|
||||
### Acceptance Criteria
|
||||
|
||||
- [ ] Criterion 1
|
||||
- [ ] Criterion 2
|
||||
|
||||
### Links
|
||||
|
||||
- **Ticket:** [Original URL]
|
||||
- **Slack:** [Slack thread content fetched via slackdump] (Notion only)
|
||||
|
||||
### Pipeline
|
||||
|
||||
- **API Ticket ID:** abc12345
|
||||
- **State:** RESEARCH
|
||||
- **Verified:** ✅ (via verify-intake.sh or public API)
|
||||
```
|
||||
|
||||
**After printing the summary, immediately handoff** to continue the pipeline. Use the `handoff` tool with all necessary context (ticket ID, source, title, description, slack context if any):
|
||||
|
||||
> **Handoff goal:** "Continue pipeline for ticket {ID} ({title}). Ticket is in RESEARCH state. Load skill: `research-orchestrator` to begin research phase. Ticket data: source={source}, notion_page_id={pageId}, priority={priority}. {slack context summary if available}"
|
||||
|
||||
**Do NOT wait for human approval to proceed.** The intake phase is complete — handoff immediately.
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Unsupported URL
|
||||
|
||||
```
|
||||
❌ Unsupported ticket URL format.
|
||||
|
||||
Supported formats:
|
||||
- Notion: https://notion.so/... or https://www.notion.so/...
|
||||
- GitHub: https://github.com/{owner}/{repo}/issues/{number}
|
||||
|
||||
Received: [provided URL]
|
||||
```
|
||||
|
||||
### Provider-Specific Errors
|
||||
|
||||
See individual provider files for source-specific error handling:
|
||||
|
||||
- `providers/notion.md` - Authentication, page not found
|
||||
- `providers/github.md` - Auth, rate limits, issue not found
|
||||
|
||||
### Missing Properties
|
||||
|
||||
Continue with available data and note what's missing:
|
||||
|
||||
```
|
||||
⚠️ Some properties unavailable:
|
||||
- Priority: not found (using default: Medium)
|
||||
- Area: not found
|
||||
|
||||
Proceeding with available data...
|
||||
```
|
||||
|
||||
### API Call Failures
|
||||
|
||||
```
|
||||
❌ Pipeline API call failed: {method} {endpoint}
|
||||
Status: {status}
|
||||
Error: {message}
|
||||
|
||||
Retrying once...
|
||||
|
||||
❌ Retry also failed. INTAKE IS INCOMPLETE.
|
||||
The ticket was NOT registered in the pipeline.
|
||||
Downstream skills will not work until this is fixed.
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- This skill focuses ONLY on intake — it does not do research
|
||||
- Slack thread content is fetched automatically via the `slackdump` skill — no manual copy-paste needed
|
||||
- ALL API calls (createTicket, transitionState, queueSync) are MANDATORY — never skip them
|
||||
- The `queueSync` action must be `"update_status"`, NOT `"UPDATE_NOTION_STATUS"`
|
||||
- Pipeline state is tracked via the API, not local files
|
||||
- Working artifacts (research-report.md, plan.md) can be saved locally to `$PIPELINE_DIR/runs/{ticket-id}/`
|
||||
- The `source` field in the ticket determines which research strategies to use
|
||||
|
||||
## API Client Reference
|
||||
|
||||
### Available Methods
|
||||
|
||||
| Method | Description |
|
||||
| ----------------------------------------------------------- | ------------------------------------------------------------------- |
|
||||
| `createTicket({ notion_page_id, title, source, metadata })` | Create a new ticket in the API |
|
||||
| `getTicket(id)` | Retrieve a ticket by ID |
|
||||
| `findByNotionId(notionPageId)` | Look up a ticket by its Notion page ID |
|
||||
| `listTickets({ state, agent_id, limit, offset })` | List tickets with optional filters |
|
||||
| `transitionState(id, state, reason)` | Move ticket to a new state (e.g., `'RESEARCH'`) |
|
||||
| `setPRCreated(id, prUrl)` | Mark ticket as having a PR created |
|
||||
| `queueSync(id, action, payload)` | Queue a sync action (`update_status`, `update_pr_url`, `mark_done`) |
|
||||
| `registerBranch(id, branch, repo)` | Register working branch for automatic PR detection |
|
||||
|
||||
### Error Handling
|
||||
|
||||
```typescript
|
||||
import { PipelineClient, PipelineAPIError } from '@pipeline/client';
|
||||
|
||||
try {
|
||||
await client.createTicket({ ... });
|
||||
} catch (error) {
|
||||
if (error instanceof PipelineAPIError) {
|
||||
console.error(`API Error ${error.status}: ${error.message}`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
```
|
||||
194
.claude/skills/ticket-intake/providers/github.md
Normal file
194
.claude/skills/ticket-intake/providers/github.md
Normal file
@@ -0,0 +1,194 @@
|
||||
# GitHub Provider - Ticket Intake
|
||||
|
||||
Provider-specific logic for ingesting tickets from GitHub Issues.
|
||||
|
||||
## URL Pattern
|
||||
|
||||
```
|
||||
https://github.com/{owner}/{repo}/issues/{number}
|
||||
https://www.github.com/{owner}/{repo}/issues/{number}
|
||||
```
|
||||
|
||||
Extract: `owner`, `repo`, `issue_number` from URL.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- `gh` CLI authenticated (`gh auth status`)
|
||||
- Access to the repository
|
||||
|
||||
## Fetch Issue Content
|
||||
|
||||
Use `gh` CLI to fetch issue details:
|
||||
|
||||
```bash
|
||||
# Get issue details in JSON
|
||||
gh issue view {number} --repo {owner}/{repo} --json title,body,state,labels,assignees,milestone,author,createdAt,comments,linkedPRs
|
||||
|
||||
# Get comments separately if needed
|
||||
gh issue view {number} --repo {owner}/{repo} --comments
|
||||
```
|
||||
|
||||
## Extract Ticket Data
|
||||
|
||||
Map GitHub issue fields to normalized ticket data (stored via API):
|
||||
|
||||
| GitHub Field | ticket.json Field | Notes |
|
||||
| ------------ | ----------------- | -------------------------- |
|
||||
| title | title | Direct mapping |
|
||||
| body | description | Issue body/description |
|
||||
| state | status | Map: open → "Not Started" |
|
||||
| labels | labels | Array of label names |
|
||||
| assignees | assignee | First assignee login |
|
||||
| author | author | Issue author login |
|
||||
| milestone | milestone | Milestone title if present |
|
||||
| comments | comments | Array of comment objects |
|
||||
| linkedPRs | linkedPRs | PRs linked to this issue |
|
||||
|
||||
### Priority Mapping
|
||||
|
||||
Infer priority from labels:
|
||||
|
||||
- `priority:critical`, `P0` → "Critical"
|
||||
- `priority:high`, `P1` → "High"
|
||||
- `priority:medium`, `P2` → "Medium"
|
||||
- `priority:low`, `P3` → "Low"
|
||||
- No priority label → "Medium" (default)
|
||||
|
||||
### Area Mapping
|
||||
|
||||
Infer area from labels:
|
||||
|
||||
- `area:ui`, `frontend`, `component:*` → "UI"
|
||||
- `area:api`, `backend` → "API"
|
||||
- `area:docs`, `documentation` → "Docs"
|
||||
- `bug`, `fix` → "Bug"
|
||||
- `enhancement`, `feature` → "Feature"
|
||||
|
||||
## Update Source
|
||||
|
||||
**For GitHub issues, update is optional but recommended.**
|
||||
|
||||
Add a comment to indicate work has started:
|
||||
|
||||
```bash
|
||||
gh issue comment {number} --repo {owner}/{repo} --body "🤖 Pipeline started processing this issue."
|
||||
```
|
||||
|
||||
Optionally assign to self:
|
||||
|
||||
```bash
|
||||
gh issue edit {number} --repo {owner}/{repo} --add-assignee @me
|
||||
```
|
||||
|
||||
Log any updates via the Pipeline API:
|
||||
|
||||
```typescript
|
||||
await client.updateTicket(ticketId, {
|
||||
metadata: {
|
||||
...ticket.metadata,
|
||||
githubWrites: [
|
||||
...(ticket.metadata?.githubWrites || []),
|
||||
{
|
||||
action: 'comment',
|
||||
issueNumber: 123,
|
||||
at: new Date().toISOString(),
|
||||
skill: 'ticket-intake',
|
||||
success: true
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## GitHub-Specific Ticket Fields
|
||||
|
||||
Store via API using `client.createTicket()`:
|
||||
|
||||
```json
|
||||
{
|
||||
"source": "github",
|
||||
"githubOwner": "Comfy-Org",
|
||||
"githubRepo": "ComfyUI_frontend",
|
||||
"githubIssueNumber": 123,
|
||||
"githubIssueUrl": "https://github.com/Comfy-Org/ComfyUI_frontend/issues/123",
|
||||
"labels": ["bug", "area:ui", "priority:high"],
|
||||
"linkedPRs": [456, 789],
|
||||
"dosuComment": "..." // Extracted Dosu bot analysis if present
|
||||
}
|
||||
```
|
||||
|
||||
## Dosu Bot Detection
|
||||
|
||||
Many repositories use Dosu bot for automated issue analysis. Check comments for Dosu:
|
||||
|
||||
```bash
|
||||
gh issue view {number} --repo {owner}/{repo} --comments | grep -A 100 "dosu"
|
||||
```
|
||||
|
||||
Look for comments from:
|
||||
|
||||
- `dosu[bot]`
|
||||
- `dosu-bot`
|
||||
|
||||
Extract Dosu analysis which typically includes:
|
||||
|
||||
- Root cause analysis
|
||||
- Suggested files to modify
|
||||
- Related issues/PRs
|
||||
- Potential solutions
|
||||
|
||||
Store in ticket data via API:
|
||||
|
||||
```json
|
||||
{
|
||||
"dosuComment": {
|
||||
"found": true,
|
||||
"analysis": "...",
|
||||
"suggestedFiles": ["src/file1.ts", "src/file2.ts"],
|
||||
"relatedIssues": [100, 101]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Extract Linked Issues/PRs
|
||||
|
||||
Parse issue body and comments for references:
|
||||
|
||||
- `#123` → Issue or PR reference
|
||||
- `fixes #123`, `closes #123` → Linked issue
|
||||
- `https://github.com/.../issues/123` → Full URL reference
|
||||
|
||||
Store in ticket data via API for research phase:
|
||||
|
||||
```json
|
||||
{
|
||||
"referencedIssues": [100, 101, 102],
|
||||
"referencedPRs": [200, 201]
|
||||
}
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Authentication Error
|
||||
|
||||
```
|
||||
⚠️ GitHub CLI not authenticated.
|
||||
Run: gh auth login
|
||||
```
|
||||
|
||||
### Issue Not Found
|
||||
|
||||
```
|
||||
❌ GitHub issue not found or inaccessible.
|
||||
- Check the URL is correct
|
||||
- Ensure you have access to this repository
|
||||
- Run: gh auth status
|
||||
```
|
||||
|
||||
### Rate Limiting
|
||||
|
||||
```
|
||||
⚠️ GitHub API rate limited.
|
||||
Wait a few minutes and try again.
|
||||
Check status: gh api rate_limit
|
||||
```
|
||||
202
.claude/skills/ticket-intake/providers/notion.md
Normal file
202
.claude/skills/ticket-intake/providers/notion.md
Normal file
@@ -0,0 +1,202 @@
|
||||
# Notion Provider - Ticket Intake
|
||||
|
||||
Provider-specific logic for ingesting tickets from Notion.
|
||||
|
||||
## URL Pattern
|
||||
|
||||
```
|
||||
https://www.notion.so/workspace/Page-Title-abc123def456...
|
||||
https://notion.so/Page-Title-abc123def456...
|
||||
https://www.notion.so/abc123def456...
|
||||
```
|
||||
|
||||
Page ID is the 32-character hex string (with or without hyphens).
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Notion MCP connected and authenticated
|
||||
- If not setup: `claude mcp add --transport http notion https://mcp.notion.com/mcp`
|
||||
- Authenticate via `/mcp` command if prompted
|
||||
|
||||
## Fetch Ticket Content
|
||||
|
||||
Use `Notion:notion-fetch` with the page URL or ID:
|
||||
|
||||
```
|
||||
Fetch the full page content including all properties
|
||||
```
|
||||
|
||||
## Extract Ticket Data
|
||||
|
||||
Extract these properties (names may vary):
|
||||
|
||||
| Property | Expected Name | Type |
|
||||
| ------------- | ------------------------- | ------------ |
|
||||
| Title | Name / Title | Title |
|
||||
| Status | Status | Select |
|
||||
| Assignee | Assignee / Assigned To | Person |
|
||||
| Description | - | Page content |
|
||||
| Slack Link | Slack Link / Slack Thread | URL |
|
||||
| GitHub PR | GitHub PR / PR Link | URL |
|
||||
| Priority | Priority | Select |
|
||||
| Area | Area / Category | Select |
|
||||
| Related Tasks | Related Tasks | Relation |
|
||||
|
||||
**If properties are missing**: Note what's unavailable and continue with available data.
|
||||
|
||||
## Update Source (REQUIRED)
|
||||
|
||||
**⚠️ DO NOT SKIP THIS STEP. This is a required action, not optional.**
|
||||
|
||||
**⚠️ Notion Write Safety rules apply (see `$PIPELINE_DIR/docs/notion-write-safety.md` for full reference):**
|
||||
|
||||
- **Whitelist**: Only `Status`, `GitHub PR`, and `Assignee` fields may be written
|
||||
- **Valid transitions**: Not Started → In Progress, In Progress → In Review, In Review → Done
|
||||
- **Logging**: Every write attempt MUST be logged with timestamp, field, value, previous value, skill name, and success status
|
||||
|
||||
Use `Notion:notion-update-page` to update the ticket:
|
||||
|
||||
1. **Status**: Set to "In Progress" (only valid from "Not Started")
|
||||
2. **Assignee**: Assign to pipeline owner (Notion ID: `175d872b-594c-81d4-ba5a-0002911c5966`)
|
||||
|
||||
```json
|
||||
{
|
||||
"page_id": "{page_id_from_ticket}",
|
||||
"command": "update_properties",
|
||||
"properties": {
|
||||
"Status": "In Progress",
|
||||
"Assignee": "175d872b-594c-81d4-ba5a-0002911c5966"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**After the update succeeds**, log the write via the Pipeline API:
|
||||
|
||||
```typescript
|
||||
await client.updateTicket(ticketId, {
|
||||
metadata: {
|
||||
...ticket.metadata,
|
||||
notionWrites: [
|
||||
...(ticket.metadata?.notionWrites || []),
|
||||
{
|
||||
field: 'Status',
|
||||
value: 'In Progress',
|
||||
previousValue: 'Not Started',
|
||||
at: new Date().toISOString(),
|
||||
skill: 'ticket-intake',
|
||||
success: true
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
If update fails, log with `success: false` and continue.
|
||||
|
||||
## Notion-Specific Ticket Fields
|
||||
|
||||
Store via API using `client.createTicket()`:
|
||||
|
||||
```json
|
||||
{
|
||||
"source": "notion",
|
||||
"notionPageId": "abc123def456...",
|
||||
"slackLink": "https://slack.com/...",
|
||||
"relatedTasks": ["page-id-1", "page-id-2"]
|
||||
}
|
||||
```
|
||||
|
||||
## Slack Thread Handling
|
||||
|
||||
If a Slack link exists, use the `slackdump` skill to fetch the thread content programmatically.
|
||||
|
||||
### Slack URL Conversion
|
||||
|
||||
Notion stores Slack links in `slackMessage://` format:
|
||||
|
||||
```
|
||||
slackMessage://comfy-organization.slack.com/CHANNEL_ID/THREAD_TS/MESSAGE_TS
|
||||
```
|
||||
|
||||
Convert to browser-clickable format:
|
||||
|
||||
```
|
||||
https://comfy-organization.slack.com/archives/CHANNEL_ID/pMESSAGE_TS_NO_DOT
|
||||
```
|
||||
|
||||
**Example:**
|
||||
|
||||
- Input: `slackMessage://comfy-organization.slack.com/C075ANWQ8KS/1766022478.450909/1764772881.854829`
|
||||
- Output: `https://comfy-organization.slack.com/archives/C075ANWQ8KS/p1764772881854829`
|
||||
|
||||
(Remove the dot from the last timestamp and prefix with `p`)
|
||||
|
||||
### Fetching Thread Content
|
||||
|
||||
Load the `slackdump` skill and use the **export-thread** workflow:
|
||||
|
||||
```bash
|
||||
# Export thread by URL
|
||||
slackdump dump "https://comfy-organization.slack.com/archives/CHANNEL_ID/pMESSAGE_TS"
|
||||
|
||||
# Or by colon notation (channel_id:thread_ts)
|
||||
slackdump dump CHANNEL_ID:THREAD_TS
|
||||
```
|
||||
|
||||
Save the thread content to `$RUN_DIR/slack-context.md` and include it in the ticket metadata.
|
||||
|
||||
> **No manual action required.** The slackdump CLI handles authentication via stored credentials at `~/.cache/slackdump/comfy-organization.bin`.
|
||||
|
||||
## Database Reference: Comfy Tasks
|
||||
|
||||
The "Comfy Tasks" database has these properties (verify via `notion-search`):
|
||||
|
||||
- **Status values**: Not Started, In Progress, In Review, Done
|
||||
- **Team assignment**: "Frontend Team" for unassigned tickets
|
||||
- **Filtering note**: Team filtering in Notion may have quirks - handle gracefully
|
||||
|
||||
### Pipeline Owner Details
|
||||
|
||||
When assigning tickets, use these identifiers:
|
||||
|
||||
| Platform | Identifier |
|
||||
| --------------- | -------------------------------------- |
|
||||
| Notion User ID | `175d872b-594c-81d4-ba5a-0002911c5966` |
|
||||
| Notion Name | Christian Byrne |
|
||||
| Notion Email | cbyrne@comfy.org |
|
||||
| Slack User ID | U087MJCDHHC |
|
||||
| GitHub Username | christian-byrne |
|
||||
|
||||
**To update Assignee**, use the Notion User ID (not name):
|
||||
|
||||
```
|
||||
properties: {"Assignee": "175d872b-594c-81d4-ba5a-0002911c5966"}
|
||||
```
|
||||
|
||||
### Finding Active Tickets
|
||||
|
||||
To list your active tickets:
|
||||
|
||||
```
|
||||
Use Notion:notion-search for "Comfy Tasks"
|
||||
Filter by Assignee = current user OR Team = "Frontend Team"
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Authentication Error
|
||||
|
||||
```
|
||||
⚠️ Notion authentication required.
|
||||
Run: claude mcp add --transport http notion https://mcp.notion.com/mcp
|
||||
Then authenticate via /mcp command.
|
||||
```
|
||||
|
||||
### Page Not Found
|
||||
|
||||
```
|
||||
❌ Notion page not found or inaccessible.
|
||||
- Check the URL is correct
|
||||
- Ensure you have access to this page
|
||||
- Try re-authenticating via /mcp
|
||||
```
|
||||
81
.claude/skills/ticket-intake/schema.md
Normal file
81
.claude/skills/ticket-intake/schema.md
Normal file
@@ -0,0 +1,81 @@
|
||||
# Ticket Schema
|
||||
|
||||
Common schema for normalized ticket data across all sources. This data is stored and retrieved via the Pipeline API, not local files.
|
||||
|
||||
## Ticket Data Schema
|
||||
|
||||
```json
|
||||
{
|
||||
// Required fields (all sources)
|
||||
"id": "string", // Unique identifier (short form)
|
||||
"url": "string", // Original URL
|
||||
"source": "notion | github", // Source type
|
||||
"title": "string", // Ticket title
|
||||
"description": "string", // Full description/body
|
||||
"fetchedAt": "ISO8601", // When ticket was fetched
|
||||
|
||||
// Common optional fields
|
||||
"status": "string", // Current status
|
||||
"assignee": "string", // Assigned user
|
||||
"priority": "string", // Priority level
|
||||
"area": "string", // Category/area
|
||||
"labels": ["string"], // Tags/labels
|
||||
"acceptanceCriteria": ["string"] // List of AC items
|
||||
|
||||
// Source-specific fields (see providers)
|
||||
// Notion: notionPageId, slackLink, relatedTasks, notionWrites
|
||||
// GitHub: githubOwner, githubRepo, githubIssueNumber, linkedPRs, dosuComment, referencedIssues
|
||||
}
|
||||
```
|
||||
|
||||
## Ticket State Schema (via API)
|
||||
|
||||
State is managed via the Pipeline API using `client.transitionState()`:
|
||||
|
||||
```json
|
||||
{
|
||||
"ticketId": "string",
|
||||
"state": "intake | research | planning | implementation | pr_created | done | failed",
|
||||
"stateChangedAt": "ISO8601",
|
||||
|
||||
// Timestamps tracked by API
|
||||
"createdAt": "ISO8601",
|
||||
"updatedAt": "ISO8601"
|
||||
}
|
||||
```
|
||||
|
||||
## Priority Normalization
|
||||
|
||||
All sources should normalize to these values:
|
||||
|
||||
| Normalized | Description |
|
||||
| ---------- | ------------------------- |
|
||||
| Critical | Production down, security |
|
||||
| High | Blocking work, urgent |
|
||||
| Medium | Normal priority (default) |
|
||||
| Low | Nice to have, backlog |
|
||||
|
||||
## Status Normalization
|
||||
|
||||
Pipeline tracks these statuses internally:
|
||||
|
||||
| Status | Description |
|
||||
| -------------- | ---------------------------- |
|
||||
| research | Gathering context |
|
||||
| planning | Creating implementation plan |
|
||||
| implementation | Writing code |
|
||||
| review | Code review in progress |
|
||||
| qa | Quality assurance |
|
||||
| done | PR merged or completed |
|
||||
|
||||
## ID Generation
|
||||
|
||||
IDs are generated by the API when creating tickets. For reference:
|
||||
|
||||
- **Notion**: First 8 characters of page ID
|
||||
- **GitHub**: `gh-{owner}-{repo}-{issue_number}` (sanitized)
|
||||
|
||||
Examples:
|
||||
|
||||
- Notion: `abc12345`
|
||||
- GitHub: `gh-comfy-org-frontend-123`
|
||||
@@ -44,18 +44,15 @@ await expect(node).toHaveClass(BYPASS_CLASS)
|
||||
|
||||
These are frequent causes of flaky tests - check them first, but investigate if they don't apply:
|
||||
|
||||
| Symptom | Common Cause | Typical Fix |
|
||||
| ----------------------------------- | ------------------------- | -------------------------------------------------------------------------------------- |
|
||||
| Test passes locally, fails in CI | Missing nextFrame() | Add `await comfyPage.nextFrame()` after canvas ops (not needed after `loadWorkflow()`) |
|
||||
| Keyboard shortcuts don't work | Missing focus | Add `await comfyPage.canvas.click()` first |
|
||||
| Double-click doesn't trigger | Timing too fast | Add `{ delay: 5 }` option |
|
||||
| Elements end up in wrong position | Drag animation incomplete | Use `{ steps: 10 }` not `{ steps: 1 }` |
|
||||
| Widget value wrong after drag-drop | Upload incomplete | Add `{ waitForUpload: true }` |
|
||||
| Test fails when run with others | Test pollution | Add `afterEach` with `resetView()` |
|
||||
| Local screenshots don't match CI | Platform differences | Screenshots are Linux-only, use PR label |
|
||||
| `subtree intercepts pointer events` | Canvas overlay (z-999) | Use `dispatchEvent` on the DOM element to bypass overlay |
|
||||
| Context menu empty / wrong items | Node not selected | Select node first: `vueNodes.selectNode()` or `nodeRef.click('title')` |
|
||||
| `navigateIntoSubgraph` timeout | Node too small in asset | Use node size `[400, 200]` minimum in test asset JSON |
|
||||
| Symptom | Common Cause | Typical Fix |
|
||||
| ---------------------------------- | ------------------------- | -------------------------------------------------------------------------------------- |
|
||||
| Test passes locally, fails in CI | Missing nextFrame() | Add `await comfyPage.nextFrame()` after canvas ops (not needed after `loadWorkflow()`) |
|
||||
| Keyboard shortcuts don't work | Missing focus | Add `await comfyPage.canvas.click()` first |
|
||||
| Double-click doesn't trigger | Timing too fast | Add `{ delay: 5 }` option |
|
||||
| Elements end up in wrong position | Drag animation incomplete | Use `{ steps: 10 }` not `{ steps: 1 }` |
|
||||
| Widget value wrong after drag-drop | Upload incomplete | Add `{ waitForUpload: true }` |
|
||||
| Test fails when run with others | Test pollution | Add `afterEach` with `resetView()` |
|
||||
| Local screenshots don't match CI | Platform differences | Screenshots are Linux-only, use PR label |
|
||||
|
||||
## Test Tags
|
||||
|
||||
|
||||
@@ -28,21 +28,3 @@ reviews:
|
||||
3. The PR description includes a concrete, non-placeholder explanation of why an end-to-end regression test was not added.
|
||||
|
||||
Fail otherwise. When failing, mention which bug-fix signal you found and ask the author to either add or update a Playwright regression test under `browser_tests/` or add a concrete explanation in the PR description of why an end-to-end regression test is not practical.
|
||||
- name: ADR compliance for entity/litegraph changes
|
||||
mode: warning
|
||||
instructions: |
|
||||
Use only PR metadata already available in the review context: the changed-file list relative to the PR base, the PR description, and the diff content. Do not rely on shell commands.
|
||||
|
||||
This check applies ONLY when the PR modifies files under `src/lib/litegraph/`, `src/ecs/`, or files related to graph entities (nodes, links, widgets, slots, reroutes, groups, subgraphs).
|
||||
|
||||
If none of those paths appear in the changed files, pass immediately.
|
||||
|
||||
When applicable, check for:
|
||||
1. **Command pattern (ADR 0003)**: Entity state mutations must be serializable, idempotent, deterministic commands — not imperative fire-and-forget side effects. Flag direct spatial mutation (`node.pos =`, `node.size =`, `group.pos =`) outside of a store or command, and any new void-returning mutation API that should produce a command object.
|
||||
2. **God-object growth (ADR 0008)**: New methods/properties added to `LGraphNode`, `LGraphCanvas`, `LGraph`, or `Subgraph` that add responsibilities rather than extracting/migrating existing ones.
|
||||
3. **ECS data/behavior separation (ADR 0008)**: Component-like data structures that contain methods or back-references to parent entities. ECS components must be plain data. New OOP instance patterns (`node.someProperty`, `node.someMethod()`) for data that should be a World component.
|
||||
4. **Extension ecosystem (ADR 0008)**: Changes to extension-facing callbacks (`onConnectionsChange`, `onRemoved`, `onAdded`, `onConfigure`, `onConnectInput/Output`, `onWidgetChanged`), `node.widgets` access, `node.serialize` overrides, or `graph._version++` without migration guidance. These affect 40+ custom node repos.
|
||||
|
||||
Pass if none of these patterns are found in the diff.
|
||||
|
||||
When warning, reference the specific ADR by number and link to `docs/adr/` for context. Frame findings as directional guidance since ADR 0003 and 0008 are in Proposed status.
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
end_of_line = lf
|
||||
charset = utf-8
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
@@ -45,4 +45,3 @@ ALGOLIA_API_KEY=684d998c36b67a9a9fce8fc2d8860579
|
||||
# SENTRY_AUTH_TOKEN=private-token # get from sentry
|
||||
# SENTRY_ORG=comfy-org
|
||||
# SENTRY_PROJECT=cloud-frontend-staging
|
||||
# SENTRY_PROJECT_PROD= # prod project slug for sourcemap uploads
|
||||
|
||||
65
.github/actions/find-workflow-run/action.yaml
vendored
65
.github/actions/find-workflow-run/action.yaml
vendored
@@ -1,65 +0,0 @@
|
||||
name: Find Workflow Run
|
||||
description: Finds a workflow run for a given commit SHA and outputs its status and run ID.
|
||||
|
||||
inputs:
|
||||
workflow-id:
|
||||
description: The workflow filename (e.g., 'ci-size-data.yaml')
|
||||
required: true
|
||||
head-sha:
|
||||
description: The commit SHA to find runs for
|
||||
required: true
|
||||
not-found-status:
|
||||
description: Status to output when no run exists
|
||||
required: false
|
||||
default: pending
|
||||
token:
|
||||
description: GitHub token for API access
|
||||
required: true
|
||||
|
||||
outputs:
|
||||
status:
|
||||
description: One of 'ready', 'pending', 'failed', or the not-found-status value
|
||||
value: ${{ steps.find.outputs.status }}
|
||||
run-id:
|
||||
description: The workflow run ID (only set when status is 'ready')
|
||||
value: ${{ steps.find.outputs.run-id }}
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Find workflow run
|
||||
id: find
|
||||
uses: actions/github-script@v8
|
||||
env:
|
||||
WORKFLOW_ID: ${{ inputs.workflow-id }}
|
||||
HEAD_SHA: ${{ inputs.head-sha }}
|
||||
NOT_FOUND_STATUS: ${{ inputs.not-found-status }}
|
||||
with:
|
||||
github-token: ${{ inputs.token }}
|
||||
script: |
|
||||
const { data: runs } = await github.rest.actions.listWorkflowRuns({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
workflow_id: process.env.WORKFLOW_ID,
|
||||
head_sha: process.env.HEAD_SHA,
|
||||
per_page: 1,
|
||||
});
|
||||
|
||||
const run = runs.workflow_runs[0];
|
||||
if (!run) {
|
||||
core.setOutput('status', process.env.NOT_FOUND_STATUS);
|
||||
return;
|
||||
}
|
||||
|
||||
if (run.status !== 'completed') {
|
||||
core.setOutput('status', 'pending');
|
||||
return;
|
||||
}
|
||||
|
||||
if (run.conclusion !== 'success') {
|
||||
core.setOutput('status', 'failed');
|
||||
return;
|
||||
}
|
||||
|
||||
core.setOutput('status', 'ready');
|
||||
core.setOutput('run-id', String(run.id));
|
||||
31
.github/actions/lint-format-verify/action.yml
vendored
31
.github/actions/lint-format-verify/action.yml
vendored
@@ -1,31 +0,0 @@
|
||||
name: 'Lint and format verify'
|
||||
description: >
|
||||
Runs the lint/format/knip verification suite plus a conditional
|
||||
browser-tests typecheck. Shared by ci-lint-format.yaml (PR) and
|
||||
ci-lint-format-queue.yaml (merge queue) so both paths run the exact
|
||||
same checks. The caller is responsible for checkout and frontend setup
|
||||
before invoking this action.
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Detect browser_tests changes
|
||||
id: changed-paths
|
||||
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
|
||||
with:
|
||||
filters: |
|
||||
browser_tests:
|
||||
- 'browser_tests/**'
|
||||
|
||||
- name: Verify lint and format
|
||||
shell: bash
|
||||
run: |
|
||||
pnpm lint
|
||||
pnpm stylelint
|
||||
pnpm format:check
|
||||
pnpm knip
|
||||
|
||||
- name: Typecheck browser tests
|
||||
if: steps.changed-paths.outputs.browser_tests == 'true'
|
||||
shell: bash
|
||||
run: pnpm typecheck:browser
|
||||
@@ -1,88 +0,0 @@
|
||||
name: Resolve PR from workflow_run
|
||||
description: >
|
||||
Resolves the PR number from a workflow_run event using pull_requests[0]
|
||||
with a listPullRequestsAssociatedWithCommit fallback.
|
||||
Skips closed/merged PRs and stale runs (head SHA mismatch).
|
||||
|
||||
inputs:
|
||||
token:
|
||||
description: GitHub token for API calls
|
||||
required: false
|
||||
default: ${{ github.token }}
|
||||
|
||||
outputs:
|
||||
skip:
|
||||
description: "'true' when no open PR was found or the run is stale"
|
||||
value: ${{ steps.resolve.outputs.skip }}
|
||||
number:
|
||||
description: The PR number (empty when skip is true)
|
||||
value: ${{ steps.resolve.outputs.number }}
|
||||
base:
|
||||
description: The PR base branch (empty when skip is true)
|
||||
value: ${{ steps.resolve.outputs.base }}
|
||||
head-sha:
|
||||
description: The PR head SHA (empty when skip is true)
|
||||
value: ${{ steps.resolve.outputs.head-sha }}
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Resolve PR
|
||||
id: resolve
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
github-token: ${{ inputs.token }}
|
||||
script: |
|
||||
let pr = context.payload.workflow_run.pull_requests?.[0];
|
||||
if (!pr) {
|
||||
const { data: prs } = await github.rest.repos.listPullRequestsAssociatedWithCommit({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
commit_sha: context.payload.workflow_run.head_sha,
|
||||
});
|
||||
pr = prs.find(p => p.state === 'open');
|
||||
}
|
||||
|
||||
// Fork PRs: pull_requests is empty and commit SHA may not be in
|
||||
// the base repo graph. Fall back to pulls.list with head filter.
|
||||
if (!pr && context.payload.workflow_run.head_repository?.owner?.login) {
|
||||
const { data: prs } = await github.rest.pulls.list({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
state: 'open',
|
||||
head: `${context.payload.workflow_run.head_repository.owner.login}:${context.payload.workflow_run.head_branch}`,
|
||||
per_page: 1,
|
||||
});
|
||||
pr = prs.find(p => p.head.sha === context.payload.workflow_run.head_sha);
|
||||
}
|
||||
|
||||
if (!pr) {
|
||||
core.info('No open PR found for this workflow run — skipping.');
|
||||
core.setOutput('skip', 'true');
|
||||
return;
|
||||
}
|
||||
|
||||
const { data: livePr } = await github.rest.pulls.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: pr.number,
|
||||
});
|
||||
|
||||
if (livePr.state !== 'open') {
|
||||
core.info(`PR #${pr.number} is ${livePr.state} — skipping.`);
|
||||
core.setOutput('skip', 'true');
|
||||
return;
|
||||
}
|
||||
|
||||
if (livePr.head.sha !== context.payload.workflow_run.head_sha) {
|
||||
core.info(
|
||||
`Stale run: workflow SHA ${context.payload.workflow_run.head_sha} != PR head ${livePr.head.sha}`
|
||||
);
|
||||
core.setOutput('skip', 'true');
|
||||
return;
|
||||
}
|
||||
|
||||
core.setOutput('base', livePr.base.ref);
|
||||
core.setOutput('head-sha', livePr.head.sha);
|
||||
core.setOutput('skip', 'false');
|
||||
core.setOutput('number', String(pr.number));
|
||||
4
.github/actions/setup-frontend/action.yaml
vendored
4
.github/actions/setup-frontend/action.yaml
vendored
@@ -12,7 +12,9 @@ runs:
|
||||
|
||||
# Install pnpm, Node.js, build frontend
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
|
||||
@@ -16,7 +16,9 @@ jobs:
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
|
||||
@@ -21,7 +21,9 @@ jobs:
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
|
||||
109
.github/workflows/api-update-registry-api-types.yaml
vendored
Normal file
109
.github/workflows/api-update-registry-api-types.yaml
vendored
Normal file
@@ -0,0 +1,109 @@
|
||||
# Description: When upstream comfy-api is updated, click dispatch to update the TypeScript type definitions in this repo
|
||||
name: 'Api: Update Registry API Types'
|
||||
|
||||
on:
|
||||
# Manual trigger
|
||||
workflow_dispatch:
|
||||
|
||||
# Triggered from comfy-api repo
|
||||
repository_dispatch:
|
||||
types: [comfy-api-updated]
|
||||
|
||||
jobs:
|
||||
update-registry-types:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Checkout comfy-api repository
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
repository: Comfy-Org/comfy-api
|
||||
path: comfy-api
|
||||
token: ${{ secrets.COMFY_API_PAT }}
|
||||
clean: true
|
||||
|
||||
- name: Get API commit information
|
||||
id: api-info
|
||||
run: |
|
||||
cd comfy-api
|
||||
API_COMMIT=$(git rev-parse --short HEAD)
|
||||
echo "commit=${API_COMMIT}" >> $GITHUB_OUTPUT
|
||||
cd ..
|
||||
|
||||
- name: Generate API types
|
||||
run: |
|
||||
echo "Generating TypeScript types from comfy-api@${{ steps.api-info.outputs.commit }}..."
|
||||
mkdir -p ./packages/registry-types/src
|
||||
pnpm dlx openapi-typescript ./comfy-api/openapi.yml --output ./packages/registry-types/src/comfyRegistryTypes.ts
|
||||
|
||||
- name: Validate generated types
|
||||
run: |
|
||||
if [ ! -f ./packages/registry-types/src/comfyRegistryTypes.ts ]; then
|
||||
echo "Error: Types file was not generated."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if file is not empty
|
||||
if [ ! -s ./packages/registry-types/src/comfyRegistryTypes.ts ]; then
|
||||
echo "Error: Generated types file is empty."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Lint generated types
|
||||
run: |
|
||||
echo "Linting generated Comfy Registry API types..."
|
||||
pnpm lint:fix:no-cache -- ./packages/registry-types/src/comfyRegistryTypes.ts
|
||||
|
||||
- name: Check for changes
|
||||
id: check-changes
|
||||
run: |
|
||||
if [[ -z $(git status --porcelain ./packages/registry-types/src/comfyRegistryTypes.ts) ]]; then
|
||||
echo "No changes to Comfy Registry API types detected."
|
||||
echo "changed=false" >> $GITHUB_OUTPUT
|
||||
exit 0
|
||||
else
|
||||
echo "Changes detected in Comfy Registry API types."
|
||||
echo "changed=true" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Create Pull Request
|
||||
if: steps.check-changes.outputs.changed == 'true'
|
||||
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
|
||||
with:
|
||||
token: ${{ secrets.PR_GH_TOKEN }}
|
||||
commit-message: '[chore] Update Comfy Registry API types from comfy-api@${{ steps.api-info.outputs.commit }}'
|
||||
title: '[chore] Update Comfy Registry API types from comfy-api@${{ steps.api-info.outputs.commit }}'
|
||||
body: |
|
||||
## Automated API Type Update
|
||||
|
||||
This PR updates the Comfy Registry API types from the latest comfy-api OpenAPI specification.
|
||||
|
||||
- API commit: ${{ steps.api-info.outputs.commit }}
|
||||
- Generated on: ${{ github.event.repository.updated_at }}
|
||||
|
||||
These types are automatically generated using openapi-typescript.
|
||||
branch: update-registry-types-${{ steps.api-info.outputs.commit }}
|
||||
base: main
|
||||
labels: CNR
|
||||
delete-branch: true
|
||||
add-paths: |
|
||||
packages/registry-types/src/comfyRegistryTypes.ts
|
||||
@@ -19,7 +19,9 @@ jobs:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
|
||||
29
.github/workflows/ci-lint-format-queue.yaml
vendored
29
.github/workflows/ci-lint-format-queue.yaml
vendored
@@ -1,29 +0,0 @@
|
||||
# Description: Lint and format verification for GitHub merge queue runs.
|
||||
# Paired with ci-lint-format.yaml — workflow name and job name must match
|
||||
# so branch protection resolves a single required check in both the
|
||||
# pull_request and merge_group contexts. This file runs verify-only steps
|
||||
# with a read-only token; auto-fix and PR comments live in the PR workflow.
|
||||
name: 'CI: Lint Format'
|
||||
|
||||
on:
|
||||
merge_group:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
lint-and-format:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout merge group ref
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup frontend
|
||||
uses: ./.github/actions/setup-frontend
|
||||
|
||||
- name: Verify lint and format
|
||||
uses: ./.github/actions/lint-format-verify
|
||||
25
.github/workflows/ci-lint-format.yaml
vendored
25
.github/workflows/ci-lint-format.yaml
vendored
@@ -1,7 +1,4 @@
|
||||
# Description: Linting and code formatting validation for pull requests.
|
||||
# Paired with ci-lint-format-queue.yaml - workflow name and job name must
|
||||
# match so branch protection resolves a single required check in both the
|
||||
# pull_request and merge_group contexts.
|
||||
# Description: Linting and code formatting validation for pull requests
|
||||
name: 'CI: Lint Format'
|
||||
|
||||
on:
|
||||
@@ -29,6 +26,14 @@ jobs:
|
||||
- name: Setup frontend
|
||||
uses: ./.github/actions/setup-frontend
|
||||
|
||||
- name: Detect browser_tests changes
|
||||
id: changed-paths
|
||||
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
|
||||
with:
|
||||
filters: |
|
||||
browser_tests:
|
||||
- 'browser_tests/**'
|
||||
|
||||
- name: Run ESLint with auto-fix
|
||||
run: pnpm lint:fix
|
||||
|
||||
@@ -72,8 +77,16 @@ jobs:
|
||||
echo "See CONTRIBUTING.md for more details."
|
||||
exit 1
|
||||
|
||||
- name: Verify lint and format
|
||||
uses: ./.github/actions/lint-format-verify
|
||||
- name: Final validation
|
||||
run: |
|
||||
pnpm lint
|
||||
pnpm stylelint
|
||||
pnpm format:check
|
||||
pnpm knip
|
||||
|
||||
- name: Typecheck browser tests
|
||||
if: steps.changed-paths.outputs.browser_tests == 'true'
|
||||
run: pnpm typecheck:browser
|
||||
|
||||
- name: Comment on PR about auto-fix
|
||||
if: steps.verify-changed-files.outputs.changed == 'true' && github.event.pull_request.head.repo.full_name == github.repository
|
||||
|
||||
10
.github/workflows/ci-oss-assets-validation.yaml
vendored
10
.github/workflows/ci-oss-assets-validation.yaml
vendored
@@ -20,7 +20,9 @@ jobs:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
uses: pnpm/action-setup@9fd676a19091d4595eefd76e4bd31c97133911f1 # v4.2.0
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
@@ -73,7 +75,9 @@ jobs:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
uses: pnpm/action-setup@9fd676a19091d4595eefd76e4bd31c97133911f1 # v4.2.0
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
@@ -95,7 +99,7 @@ jobs:
|
||||
if npx license-checker-rseidelsohn@4 \
|
||||
--production \
|
||||
--summary \
|
||||
--excludePackages '@comfyorg/comfyui-frontend;@comfyorg/design-system;@comfyorg/ingest-types;@comfyorg/registry-types;@comfyorg/shared-frontend-utils;@comfyorg/tailwind-utils;@comfyorg/comfyui-electron-types' \
|
||||
--excludePackages '@comfyorg/comfyui-frontend;@comfyorg/design-system;@comfyorg/registry-types;@comfyorg/shared-frontend-utils;@comfyorg/tailwind-utils;@comfyorg/comfyui-electron-types' \
|
||||
--clarificationsFile .github/license-clarifications.json \
|
||||
--onlyAllow 'MIT;MIT*;Apache-2.0;BSD-2-Clause;BSD-3-Clause;ISC;0BSD;BlueOak-1.0.0;Python-2.0;CC0-1.0;Unlicense;(MIT OR Apache-2.0);(MIT OR GPL-3.0);(Apache-2.0 OR MIT);(MPL-2.0 OR Apache-2.0);CC-BY-4.0;CC-BY-3.0;GPL-3.0-only'; then
|
||||
echo ''
|
||||
|
||||
14
.github/workflows/ci-perf-report.yaml
vendored
14
.github/workflows/ci-perf-report.yaml
vendored
@@ -58,6 +58,20 @@ jobs:
|
||||
retention-days: 30
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: Save PR metadata
|
||||
if: github.event_name == 'pull_request'
|
||||
run: |
|
||||
mkdir -p temp/perf-meta
|
||||
echo "${{ github.event.number }}" > temp/perf-meta/number.txt
|
||||
echo "${{ github.event.pull_request.base.ref }}" > temp/perf-meta/base.txt
|
||||
|
||||
- name: Upload PR metadata
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: perf-meta
|
||||
path: temp/perf-meta/
|
||||
|
||||
- name: Save perf baseline to perf-data branch
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main' && steps.perf.outcome == 'success'
|
||||
continue-on-error: true
|
||||
|
||||
10
.github/workflows/ci-size-data.yaml
vendored
10
.github/workflows/ci-size-data.yaml
vendored
@@ -8,10 +8,6 @@ on:
|
||||
branches:
|
||||
- main
|
||||
|
||||
concurrency:
|
||||
group: size-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
@@ -32,6 +28,12 @@ jobs:
|
||||
- name: Collect size data
|
||||
run: node scripts/size-collect.js
|
||||
|
||||
- name: Save PR number & base branch
|
||||
if: ${{ github.event_name == 'pull_request' }}
|
||||
run: |
|
||||
echo ${{ github.event.number }} > ./temp/size/number.txt
|
||||
echo ${{ github.base_ref }} > ./temp/size/base.txt
|
||||
|
||||
- name: Upload size data
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
|
||||
147
.github/workflows/ci-tests-e2e-coverage.yaml
vendored
147
.github/workflows/ci-tests-e2e-coverage.yaml
vendored
@@ -1,147 +0,0 @@
|
||||
name: 'CI: E2E Coverage'
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ['CI: Tests E2E']
|
||||
types:
|
||||
- completed
|
||||
|
||||
concurrency:
|
||||
group: e2e-coverage-${{ github.event.workflow_run.head_sha }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
merge:
|
||||
if: >
|
||||
github.repository == 'Comfy-Org/ComfyUI_frontend' &&
|
||||
github.event.workflow_run.conclusion == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup frontend
|
||||
uses: ./.github/actions/setup-frontend
|
||||
|
||||
- name: Download all shard coverage data
|
||||
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
|
||||
with:
|
||||
run_id: ${{ github.event.workflow_run.id }}
|
||||
name: e2e-coverage-shard-.*
|
||||
name_is_regexp: true
|
||||
path: temp/coverage-shards
|
||||
if_no_artifact_found: warn
|
||||
|
||||
- name: Install lcov
|
||||
run: sudo apt-get install -y -qq lcov
|
||||
|
||||
- name: Merge shard coverage into single LCOV
|
||||
run: |
|
||||
mkdir -p coverage/playwright
|
||||
LCOV_FILES=$(find temp/coverage-shards -name 'coverage.lcov' -type f)
|
||||
if [ -z "$LCOV_FILES" ]; then
|
||||
echo "No coverage.lcov files found"
|
||||
touch coverage/playwright/coverage.lcov
|
||||
exit 0
|
||||
fi
|
||||
ADD_ARGS=""
|
||||
for f in $LCOV_FILES; do ADD_ARGS="$ADD_ARGS -a $f"; done
|
||||
lcov $ADD_ARGS -o coverage/playwright/coverage.lcov
|
||||
wc -l coverage/playwright/coverage.lcov
|
||||
|
||||
- name: Validate merged coverage
|
||||
run: |
|
||||
SHARD_COUNT=$(find temp/coverage-shards -name 'coverage.lcov' -type f | wc -l | tr -d ' ')
|
||||
if [ "$SHARD_COUNT" -eq 0 ]; then
|
||||
echo "::error::No shard coverage.lcov files found under temp/coverage-shards"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
MERGED_SF=$(grep -c '^SF:' coverage/playwright/coverage.lcov || echo 0)
|
||||
MERGED_LH=$(awk -F: '/^LH:/{s+=$2}END{print s+0}' coverage/playwright/coverage.lcov)
|
||||
MERGED_LF=$(awk -F: '/^LF:/{s+=$2}END{print s+0}' coverage/playwright/coverage.lcov)
|
||||
echo "### Merged coverage" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "- **$MERGED_SF** source files" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "- **$MERGED_LH / $MERGED_LF** lines hit" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "| Shard | Files | Lines Hit |" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "|-------|-------|-----------|" >> "$GITHUB_STEP_SUMMARY"
|
||||
for f in $(find temp/coverage-shards -name 'coverage.lcov' -type f | sort); do
|
||||
SHARD=$(basename "$(dirname "$f")")
|
||||
SHARD_SF=$(grep -c '^SF:' "$f" || echo 0)
|
||||
SHARD_LH=$(awk -F: '/^LH:/{s+=$2}END{print s+0}' "$f")
|
||||
echo "| $SHARD | $SHARD_SF | $SHARD_LH |" >> "$GITHUB_STEP_SUMMARY"
|
||||
if [ "$MERGED_LH" -lt "$SHARD_LH" ]; then
|
||||
echo "::error::Merged LH ($MERGED_LH) < shard LH ($SHARD_LH) in $SHARD — possible data loss"
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Upload merged coverage data
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: e2e-coverage
|
||||
path: coverage/playwright/
|
||||
retention-days: 30
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: Upload E2E coverage to Codecov
|
||||
if: always()
|
||||
uses: codecov/codecov-action@1af58845a975a7985b0beb0cbe6fbbb71a41dbad # v5.5.3
|
||||
with:
|
||||
files: coverage/playwright/coverage.lcov
|
||||
flags: e2e
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
fail_ci_if_error: false
|
||||
|
||||
- name: Generate HTML coverage report
|
||||
run: |
|
||||
if [ ! -s coverage/playwright/coverage.lcov ]; then
|
||||
echo "No coverage data; generating placeholder report."
|
||||
mkdir -p coverage/html
|
||||
echo '<html><body><h1>No E2E coverage data available for this run.</h1></body></html>' > coverage/html/index.html
|
||||
exit 0
|
||||
fi
|
||||
genhtml coverage/playwright/coverage.lcov \
|
||||
-o coverage/html \
|
||||
--title "ComfyUI E2E Coverage" \
|
||||
--no-function-coverage \
|
||||
--precision 1
|
||||
|
||||
- name: Upload HTML report artifact
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: e2e-coverage-html
|
||||
path: coverage/html/
|
||||
retention-days: 30
|
||||
|
||||
deploy:
|
||||
needs: merge
|
||||
if: github.event.workflow_run.head_branch == 'main'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pages: write
|
||||
id-token: write
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
steps:
|
||||
- name: Download HTML report
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: e2e-coverage-html
|
||||
path: coverage/html
|
||||
|
||||
- name: Upload to GitHub Pages
|
||||
uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa # v3.0.1
|
||||
with:
|
||||
path: coverage/html
|
||||
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4.0.5
|
||||
35
.github/workflows/ci-tests-e2e-forks.yaml
vendored
35
.github/workflows/ci-tests-e2e-forks.yaml
vendored
@@ -6,10 +6,6 @@ on:
|
||||
workflows: ['CI: Tests E2E']
|
||||
types: [requested, completed]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.workflow_run.head_repository.full_name }}-${{ github.event.workflow_run.head_branch }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
deploy-and-comment-forked-pr:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -34,23 +30,40 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Resolve PR from workflow_run context
|
||||
- name: Get PR Number
|
||||
id: pr
|
||||
uses: ./.github/actions/resolve-pr-from-workflow-run
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
const { data: prs } = await github.rest.pulls.list({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
state: 'open',
|
||||
});
|
||||
|
||||
const pr = prs.find(p => p.head.sha === context.payload.workflow_run.head_sha);
|
||||
|
||||
if (!pr) {
|
||||
console.log('No PR found for SHA:', context.payload.workflow_run.head_sha);
|
||||
return null;
|
||||
}
|
||||
|
||||
console.log(`Found PR #${pr.number} from fork: ${context.payload.workflow_run.head_repository.full_name}`);
|
||||
return pr.number;
|
||||
|
||||
- name: Handle Test Start
|
||||
if: steps.pr.outputs.skip != 'true' && github.event.action == 'requested'
|
||||
if: steps.pr.outputs.result != 'null' && github.event.action == 'requested'
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
chmod +x scripts/cicd/pr-playwright-deploy-and-comment.sh
|
||||
./scripts/cicd/pr-playwright-deploy-and-comment.sh \
|
||||
"${{ steps.pr.outputs.number }}" \
|
||||
"${{ steps.pr.outputs.result }}" \
|
||||
"${{ github.event.workflow_run.head_branch }}" \
|
||||
"starting"
|
||||
|
||||
- name: Download and Deploy Reports
|
||||
if: steps.pr.outputs.skip != 'true' && github.event.action == 'completed'
|
||||
if: steps.pr.outputs.result != 'null' && github.event.action == 'completed'
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -59,7 +72,7 @@ jobs:
|
||||
path: reports
|
||||
|
||||
- name: Handle Test Completion
|
||||
if: steps.pr.outputs.skip != 'true' && github.event.action == 'completed'
|
||||
if: steps.pr.outputs.result != 'null' && github.event.action == 'completed'
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
@@ -72,6 +85,6 @@ jobs:
|
||||
|
||||
chmod +x scripts/cicd/pr-playwright-deploy-and-comment.sh
|
||||
./scripts/cicd/pr-playwright-deploy-and-comment.sh \
|
||||
"${{ steps.pr.outputs.number }}" \
|
||||
"${{ steps.pr.outputs.result }}" \
|
||||
"${{ github.event.workflow_run.head_branch }}" \
|
||||
"completed"
|
||||
|
||||
35
.github/workflows/ci-tests-e2e.yaml
vendored
35
.github/workflows/ci-tests-e2e.yaml
vendored
@@ -8,7 +8,6 @@ on:
|
||||
pull_request:
|
||||
branches-ignore: [wip/*, draft/*, temp/*]
|
||||
paths-ignore: ['**/*.md']
|
||||
merge_group:
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
@@ -34,27 +33,13 @@ jobs:
|
||||
path: dist/
|
||||
retention-days: 1
|
||||
|
||||
# Build cloud distribution for @cloud tagged tests
|
||||
# NX_SKIP_NX_CACHE=true is required because `nx build` was already run
|
||||
# for the OSS distribution above. Without skipping cache, Nx returns
|
||||
# the cached OSS build since env vars aren't part of the cache key.
|
||||
- name: Build cloud frontend
|
||||
run: NX_SKIP_NX_CACHE=true pnpm build:cloud
|
||||
|
||||
- name: Upload cloud frontend
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: frontend-dist-cloud
|
||||
path: dist/
|
||||
retention-days: 1
|
||||
|
||||
# Sharded chromium tests
|
||||
playwright-tests-chromium-sharded:
|
||||
needs: setup
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
container:
|
||||
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.16
|
||||
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.13
|
||||
credentials:
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -87,7 +72,6 @@ jobs:
|
||||
run: pnpm exec playwright test --project=chromium --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} --reporter=blob
|
||||
env:
|
||||
PLAYWRIGHT_BLOB_OUTPUT_DIR: ./blob-report
|
||||
COLLECT_COVERAGE: 'true'
|
||||
|
||||
- name: Upload blob report
|
||||
uses: actions/upload-artifact@v6
|
||||
@@ -97,22 +81,13 @@ jobs:
|
||||
path: blob-report/
|
||||
retention-days: 1
|
||||
|
||||
- name: Upload shard coverage data
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: e2e-coverage-shard-${{ matrix.shardIndex }}
|
||||
path: coverage/playwright/
|
||||
retention-days: 1
|
||||
if-no-files-found: warn
|
||||
|
||||
playwright-tests:
|
||||
# Ideally, each shard runs test in 6 minutes, but allow up to 15 minutes
|
||||
timeout-minutes: 15
|
||||
needs: setup
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.16
|
||||
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.13
|
||||
credentials:
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -122,14 +97,14 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
browser: [chromium-2x, chromium-0.5x, mobile-chrome, cloud]
|
||||
browser: [chromium-2x, chromium-0.5x, mobile-chrome]
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
- name: Download built frontend
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: ${{ matrix.browser == 'cloud' && 'frontend-dist-cloud' || 'frontend-dist' }}
|
||||
name: frontend-dist
|
||||
path: dist/
|
||||
|
||||
- name: Start ComfyUI server
|
||||
@@ -169,7 +144,7 @@ jobs:
|
||||
if: ${{ !cancelled() }}
|
||||
steps:
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
with:
|
||||
version: 10
|
||||
|
||||
|
||||
35
.github/workflows/ci-tests-storybook-forks.yaml
vendored
35
.github/workflows/ci-tests-storybook-forks.yaml
vendored
@@ -6,10 +6,6 @@ on:
|
||||
workflows: ['CI: Tests Storybook']
|
||||
types: [requested, completed]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.workflow_run.head_repository.full_name }}-${{ github.event.workflow_run.head_branch }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
deploy-and-comment-forked-pr:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -34,23 +30,40 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Resolve PR from workflow_run context
|
||||
- name: Get PR Number
|
||||
id: pr
|
||||
uses: ./.github/actions/resolve-pr-from-workflow-run
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
const { data: prs } = await github.rest.pulls.list({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
state: 'open',
|
||||
});
|
||||
|
||||
const pr = prs.find(p => p.head.sha === context.payload.workflow_run.head_sha);
|
||||
|
||||
if (!pr) {
|
||||
console.log('No PR found for SHA:', context.payload.workflow_run.head_sha);
|
||||
return null;
|
||||
}
|
||||
|
||||
console.log(`Found PR #${pr.number} from fork: ${context.payload.workflow_run.head_repository.full_name}`);
|
||||
return pr.number;
|
||||
|
||||
- name: Handle Storybook Start
|
||||
if: steps.pr.outputs.skip != 'true' && github.event.action == 'requested'
|
||||
if: steps.pr.outputs.result != 'null' && github.event.action == 'requested'
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
chmod +x scripts/cicd/pr-storybook-deploy-and-comment.sh
|
||||
./scripts/cicd/pr-storybook-deploy-and-comment.sh \
|
||||
"${{ steps.pr.outputs.number }}" \
|
||||
"${{ steps.pr.outputs.result }}" \
|
||||
"${{ github.event.workflow_run.head_branch }}" \
|
||||
"starting"
|
||||
|
||||
- name: Download and Deploy Storybook
|
||||
if: steps.pr.outputs.skip != 'true' && github.event.action == 'completed' && github.event.workflow_run.conclusion == 'success'
|
||||
if: steps.pr.outputs.result != 'null' && github.event.action == 'completed' && github.event.workflow_run.conclusion == 'success'
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -59,7 +72,7 @@ jobs:
|
||||
path: storybook-static
|
||||
|
||||
- name: Handle Storybook Completion
|
||||
if: steps.pr.outputs.skip != 'true' && github.event.action == 'completed'
|
||||
if: steps.pr.outputs.result != 'null' && github.event.action == 'completed'
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
@@ -69,6 +82,6 @@ jobs:
|
||||
run: |
|
||||
chmod +x scripts/cicd/pr-storybook-deploy-and-comment.sh
|
||||
./scripts/cicd/pr-storybook-deploy-and-comment.sh \
|
||||
"${{ steps.pr.outputs.number }}" \
|
||||
"${{ steps.pr.outputs.result }}" \
|
||||
"${{ github.event.workflow_run.head_branch }}" \
|
||||
"completed"
|
||||
|
||||
25
.github/workflows/ci-tests-unit.yaml
vendored
25
.github/workflows/ci-tests-unit.yaml
vendored
@@ -1,4 +1,4 @@
|
||||
# Description: Unit and component testing with Vitest + coverage reporting
|
||||
# Description: Unit and component testing with Vitest
|
||||
name: 'CI: Tests Unit'
|
||||
|
||||
on:
|
||||
@@ -8,7 +8,6 @@ on:
|
||||
pull_request:
|
||||
branches-ignore: [wip/*, draft/*, temp/*]
|
||||
paths-ignore: ['**/*.md']
|
||||
merge_group:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
@@ -24,23 +23,5 @@ jobs:
|
||||
- name: Setup frontend
|
||||
uses: ./.github/actions/setup-frontend
|
||||
|
||||
- name: Run Vitest tests with coverage
|
||||
run: pnpm test:coverage
|
||||
|
||||
- name: Upload unit coverage artifact
|
||||
if: always() && github.event_name == 'push'
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: unit-coverage
|
||||
path: coverage/lcov.info
|
||||
retention-days: 30
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: Upload coverage to Codecov
|
||||
if: always()
|
||||
uses: codecov/codecov-action@1af58845a975a7985b0beb0cbe6fbbb71a41dbad # v5.5.3
|
||||
with:
|
||||
files: coverage/lcov.info
|
||||
flags: unit
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
fail_ci_if_error: false
|
||||
- name: Run Vitest tests
|
||||
run: pnpm test:unit
|
||||
|
||||
158
.github/workflows/ci-vercel-website-preview.yaml
vendored
158
.github/workflows/ci-vercel-website-preview.yaml
vendored
@@ -1,158 +0,0 @@
|
||||
---
|
||||
name: 'CI: Vercel Website Preview'
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
paths:
|
||||
- 'apps/website/**'
|
||||
- 'packages/design-system/**'
|
||||
- 'packages/tailwind-utils/**'
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'apps/website/**'
|
||||
- 'packages/design-system/**'
|
||||
- 'packages/tailwind-utils/**'
|
||||
|
||||
env:
|
||||
VERCEL_ORG_ID: ${{ secrets.VERCEL_WEBSITE_ORG_ID }}
|
||||
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_WEBSITE_PROJECT_ID }}
|
||||
VERCEL_TOKEN: ${{ secrets.VERCEL_WEBSITE_TOKEN }}
|
||||
VERCEL_SCOPE: comfyui
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
deploy-preview:
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
env:
|
||||
ALIAS_HOST: comfy-website-preview-pr-${{ github.event.pull_request.number }}.vercel.app
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
|
||||
- name: Install Vercel CLI
|
||||
run: npm install --global vercel@latest
|
||||
|
||||
- name: Pull Vercel environment information
|
||||
run: vercel pull --yes --environment=preview
|
||||
|
||||
- name: Build project artifacts
|
||||
run: vercel build
|
||||
|
||||
- name: Fetch head commit metadata
|
||||
id: head-commit
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
const { data } = await github.rest.repos.getCommit({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
ref: context.payload.pull_request.head.sha,
|
||||
})
|
||||
const author = data.author?.login || data.commit.author?.name || ''
|
||||
const message = (data.commit.message || '').split('\n', 1)[0]
|
||||
core.setOutput('author', author)
|
||||
core.setOutput('message', message)
|
||||
|
||||
- name: Deploy project artifacts to Vercel
|
||||
id: deploy
|
||||
env:
|
||||
GIT_COMMIT_REF: ${{ github.event.pull_request.head.ref }}
|
||||
GIT_COMMIT_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
GIT_AUTHOR_LOGIN: ${{ steps.head-commit.outputs.author }}
|
||||
GIT_COMMIT_MESSAGE: ${{ steps.head-commit.outputs.message }}
|
||||
GIT_PR_ID: ${{ github.event.pull_request.number }}
|
||||
GIT_REPO: ${{ github.repository }}
|
||||
run: |
|
||||
URL=$(vercel deploy --prebuilt \
|
||||
--meta githubCommitRef="$GIT_COMMIT_REF" \
|
||||
--meta githubCommitSha="$GIT_COMMIT_SHA" \
|
||||
--meta githubCommitAuthorLogin="$GIT_AUTHOR_LOGIN" \
|
||||
--meta githubCommitMessage="$GIT_COMMIT_MESSAGE" \
|
||||
--meta githubPrId="$GIT_PR_ID" \
|
||||
--meta githubRepo="$GIT_REPO")
|
||||
echo "url=$URL" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Alias deployment to stable PR hostname
|
||||
id: alias-set
|
||||
continue-on-error: true
|
||||
env:
|
||||
DEPLOY_URL: ${{ steps.deploy.outputs.url }}
|
||||
run: |
|
||||
vercel alias set "$DEPLOY_URL" "$ALIAS_HOST" --scope="$VERCEL_SCOPE"
|
||||
|
||||
- name: Publish preview outputs
|
||||
env:
|
||||
DEPLOY_URL: ${{ steps.deploy.outputs.url }}
|
||||
ALIAS_OK: ${{ steps.alias-set.outcome == 'success' }}
|
||||
run: |
|
||||
if [[ "$ALIAS_OK" == "true" ]]; then
|
||||
STABLE_URL="https://$ALIAS_HOST"
|
||||
else
|
||||
STABLE_URL="$DEPLOY_URL"
|
||||
fi
|
||||
mkdir -p temp/vercel-preview
|
||||
echo "$DEPLOY_URL" > temp/vercel-preview/url.txt
|
||||
echo "$STABLE_URL" > temp/vercel-preview/stable-url.txt
|
||||
{
|
||||
echo "**Preview:** $STABLE_URL"
|
||||
if [[ "$ALIAS_OK" == "true" ]]; then
|
||||
echo "**This commit:** $DEPLOY_URL"
|
||||
else
|
||||
echo "_Stable alias update failed — URL reflects this commit only._"
|
||||
fi
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Upload preview metadata
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: vercel-preview
|
||||
path: temp/vercel-preview
|
||||
|
||||
deploy-production:
|
||||
if: github.event_name == 'push'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
|
||||
- name: Install Vercel CLI
|
||||
run: npm install --global vercel@latest
|
||||
|
||||
- name: Pull Vercel environment information
|
||||
run: vercel pull --yes --environment=production
|
||||
|
||||
- name: Build project artifacts
|
||||
run: vercel build --prod
|
||||
|
||||
- name: Deploy project artifacts to Vercel
|
||||
id: deploy
|
||||
run: |
|
||||
URL=$(vercel deploy --prebuilt --prod)
|
||||
echo "url=$URL" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Add deployment URL to summary
|
||||
run: echo "**Production:** ${{ steps.deploy.outputs.url }}" >> "$GITHUB_STEP_SUMMARY"
|
||||
33
.github/workflows/ci-website-build.yaml
vendored
33
.github/workflows/ci-website-build.yaml
vendored
@@ -1,33 +0,0 @@
|
||||
# Description: Build and validate the marketing website (apps/website)
|
||||
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:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Setup frontend
|
||||
uses: ./.github/actions/setup-frontend
|
||||
|
||||
- name: Build website
|
||||
run: pnpm --filter @comfyorg/website build
|
||||
149
.github/workflows/coverage-slack-notify.yaml
vendored
149
.github/workflows/coverage-slack-notify.yaml
vendored
@@ -1,149 +0,0 @@
|
||||
name: 'Coverage: Slack Notification'
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ['CI: Tests Unit']
|
||||
branches: [main]
|
||||
types:
|
||||
- completed
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
pull-requests: read
|
||||
|
||||
jobs:
|
||||
notify:
|
||||
if: >
|
||||
github.repository == 'Comfy-Org/ComfyUI_frontend' &&
|
||||
github.event.workflow_run.conclusion == 'success' &&
|
||||
github.event.workflow_run.event == 'push'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Setup frontend
|
||||
uses: ./.github/actions/setup-frontend
|
||||
|
||||
- name: Download current unit coverage
|
||||
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
|
||||
with:
|
||||
run_id: ${{ github.event.workflow_run.id }}
|
||||
name: unit-coverage
|
||||
path: coverage
|
||||
|
||||
- name: Download previous unit coverage baseline
|
||||
continue-on-error: true
|
||||
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
|
||||
with:
|
||||
branch: main
|
||||
workflow: coverage-slack-notify.yaml
|
||||
name: unit-coverage-baseline
|
||||
path: temp/coverage-baseline
|
||||
if_no_artifact_found: warn
|
||||
|
||||
- name: Download latest E2E coverage
|
||||
continue-on-error: true
|
||||
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
|
||||
with:
|
||||
branch: main
|
||||
workflow: ci-tests-e2e-coverage.yaml
|
||||
name: e2e-coverage
|
||||
path: temp/e2e-coverage
|
||||
if_no_artifact_found: warn
|
||||
|
||||
- name: Download previous E2E coverage baseline
|
||||
continue-on-error: true
|
||||
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
|
||||
with:
|
||||
branch: main
|
||||
workflow: coverage-slack-notify.yaml
|
||||
name: e2e-coverage-baseline
|
||||
path: temp/e2e-coverage-baseline
|
||||
if_no_artifact_found: warn
|
||||
|
||||
- name: Resolve merged PR metadata
|
||||
id: pr-meta
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
const sha = context.payload.workflow_run.head_sha;
|
||||
const { data: commit } = await github.rest.repos.getCommit({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
ref: sha,
|
||||
});
|
||||
const message = commit.commit.message ?? '';
|
||||
const firstLine = message.split('\n')[0];
|
||||
const match = firstLine.match(/\(#(\d+)\)\s*$/);
|
||||
if (!match) {
|
||||
core.setOutput('skip', 'true');
|
||||
core.info('No PR number found in commit message — skipping.');
|
||||
return;
|
||||
}
|
||||
const prNumber = match[1];
|
||||
const { data: pr } = await github.rest.pulls.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: Number(prNumber),
|
||||
});
|
||||
core.setOutput('skip', 'false');
|
||||
core.setOutput('number', prNumber);
|
||||
core.setOutput('url', pr.html_url);
|
||||
core.setOutput('author', pr.user.login);
|
||||
|
||||
- name: Generate Slack notification
|
||||
if: steps.pr-meta.outputs.skip != 'true'
|
||||
id: slack-payload
|
||||
env:
|
||||
PR_URL: ${{ steps.pr-meta.outputs.url }}
|
||||
PR_NUMBER: ${{ steps.pr-meta.outputs.number }}
|
||||
PR_AUTHOR: ${{ steps.pr-meta.outputs.author }}
|
||||
run: |
|
||||
PAYLOAD=$(pnpm exec tsx scripts/coverage-slack-notify.ts \
|
||||
--pr-url="$PR_URL" \
|
||||
--pr-number="$PR_NUMBER" \
|
||||
--author="$PR_AUTHOR")
|
||||
if [ -n "$PAYLOAD" ]; then
|
||||
echo "has_payload=true" >> "$GITHUB_OUTPUT"
|
||||
DELIM="SLACK_PAYLOAD_$(date +%s)"
|
||||
echo "payload<<$DELIM" >> "$GITHUB_OUTPUT"
|
||||
printf '%s\n' "$PAYLOAD" >> "$GITHUB_OUTPUT"
|
||||
echo "$DELIM" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "has_payload=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Post to Slack
|
||||
if: steps.slack-payload.outputs.has_payload == 'true'
|
||||
continue-on-error: true
|
||||
env:
|
||||
SLACK_PAYLOAD: ${{ steps.slack-payload.outputs.payload }}
|
||||
SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }}
|
||||
run: |
|
||||
# Channel: #p-deprecated-frontend-automated-testing
|
||||
BODY=$(echo "$SLACK_PAYLOAD" | jq --arg ch "C0AP09LKRDZ" '. + {channel: $ch}')
|
||||
curl -sf -X POST \
|
||||
-H "Authorization: Bearer $SLACK_BOT_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$BODY" \
|
||||
-o /dev/null \
|
||||
https://slack.com/api/chat.postMessage
|
||||
|
||||
- name: Save unit coverage baseline
|
||||
if: always() && hashFiles('coverage/lcov.info') != ''
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: unit-coverage-baseline
|
||||
path: coverage/lcov.info
|
||||
retention-days: 90
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: Save E2E coverage baseline
|
||||
if: always() && hashFiles('temp/e2e-coverage/coverage.lcov') != ''
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: e2e-coverage-baseline
|
||||
path: temp/e2e-coverage/coverage.lcov
|
||||
retention-days: 90
|
||||
if-no-files-found: warn
|
||||
2
.github/workflows/pr-backport.yaml
vendored
2
.github/workflows/pr-backport.yaml
vendored
@@ -348,8 +348,6 @@ jobs:
|
||||
PR_NUM=$(echo "${PR_URL}" | grep -o '[0-9]*$')
|
||||
|
||||
if [ -n "${PR_NUM}" ]; then
|
||||
gh pr merge "${PR_NUM}" --auto --squash --repo "${{ github.repository }}" \
|
||||
|| echo "::warning::Failed to enable auto-merge for PR #${PR_NUM}"
|
||||
gh pr comment "${PR_NUMBER}" --body "@${PR_AUTHOR} Successfully backported to #${PR_NUM}"
|
||||
fi
|
||||
else
|
||||
|
||||
4
.github/workflows/pr-claude-review.yaml
vendored
4
.github/workflows/pr-claude-review.yaml
vendored
@@ -29,7 +29,9 @@ jobs:
|
||||
ref: refs/pull/${{ github.event.pull_request.number }}/head
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
|
||||
148
.github/workflows/pr-perf-report.yaml
vendored
Normal file
148
.github/workflows/pr-perf-report.yaml
vendored
Normal file
@@ -0,0 +1,148 @@
|
||||
name: 'PR: Performance Report'
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ['CI: Performance Report']
|
||||
types:
|
||||
- completed
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
issues: write
|
||||
actions: read
|
||||
|
||||
jobs:
|
||||
comment:
|
||||
runs-on: ubuntu-latest
|
||||
if: >
|
||||
github.repository == 'Comfy-Org/ComfyUI_frontend' &&
|
||||
github.event.workflow_run.event == 'pull_request' &&
|
||||
github.event.workflow_run.conclusion == 'success'
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
|
||||
- name: Download PR metadata
|
||||
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
|
||||
with:
|
||||
name: perf-meta
|
||||
run_id: ${{ github.event.workflow_run.id }}
|
||||
path: temp/perf-meta/
|
||||
|
||||
- name: Resolve and validate PR metadata
|
||||
id: pr-meta
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
const artifactPr = Number(fs.readFileSync('temp/perf-meta/number.txt', 'utf8').trim());
|
||||
const artifactBase = fs.readFileSync('temp/perf-meta/base.txt', 'utf8').trim();
|
||||
|
||||
// Resolve PR from trusted workflow context
|
||||
let pr = context.payload.workflow_run.pull_requests?.[0];
|
||||
if (!pr) {
|
||||
const { data: prs } = await github.rest.repos.listPullRequestsAssociatedWithCommit({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
commit_sha: context.payload.workflow_run.head_sha,
|
||||
});
|
||||
pr = prs.find(p => p.state === 'open');
|
||||
}
|
||||
|
||||
if (!pr) {
|
||||
core.setFailed('Unable to resolve PR from workflow_run context.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (Number(pr.number) !== artifactPr) {
|
||||
core.setFailed(`Artifact PR number (${artifactPr}) does not match trusted context (${pr.number}).`);
|
||||
return;
|
||||
}
|
||||
|
||||
const trustedBase = pr.base?.ref;
|
||||
if (!trustedBase || artifactBase !== trustedBase) {
|
||||
core.setFailed(`Artifact base (${artifactBase}) does not match trusted context (${trustedBase}).`);
|
||||
return;
|
||||
}
|
||||
|
||||
core.setOutput('number', String(pr.number));
|
||||
core.setOutput('base', trustedBase);
|
||||
|
||||
- name: Check if results are still current
|
||||
id: sha-check
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
const prNumber = Number('${{ steps.pr-meta.outputs.number }}');
|
||||
const { data: pr } = await github.rest.pulls.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: prNumber,
|
||||
});
|
||||
const runSha = context.payload.workflow_run.head_sha;
|
||||
const currentSha = pr.head.sha;
|
||||
if (runSha !== currentSha) {
|
||||
core.info(`Skipping stale report: run SHA ${runSha} != current PR SHA ${currentSha}`);
|
||||
core.setOutput('stale', 'true');
|
||||
} else {
|
||||
core.setOutput('stale', 'false');
|
||||
}
|
||||
|
||||
- name: Download PR perf metrics
|
||||
if: steps.sha-check.outputs.stale != 'true'
|
||||
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
|
||||
with:
|
||||
name: perf-metrics
|
||||
run_id: ${{ github.event.workflow_run.id }}
|
||||
path: test-results/
|
||||
|
||||
- name: Download baseline perf metrics
|
||||
if: steps.sha-check.outputs.stale != 'true'
|
||||
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
|
||||
with:
|
||||
branch: ${{ steps.pr-meta.outputs.base }}
|
||||
workflow: ci-perf-report.yaml
|
||||
event: push
|
||||
name: perf-metrics
|
||||
path: temp/perf-baseline/
|
||||
if_no_artifact_found: warn
|
||||
|
||||
- name: Load historical baselines from perf-data branch
|
||||
if: steps.sha-check.outputs.stale != 'true'
|
||||
continue-on-error: true
|
||||
run: |
|
||||
mkdir -p temp/perf-history
|
||||
|
||||
git fetch origin perf-data 2>/dev/null || {
|
||||
echo "perf-data branch not found, skipping historical data"
|
||||
exit 0
|
||||
}
|
||||
|
||||
INDEX=0
|
||||
for file in $(git ls-tree --name-only origin/perf-data baselines/ 2>/dev/null | sort -r | head -5); do
|
||||
DIR="temp/perf-history/$INDEX"
|
||||
mkdir -p "$DIR"
|
||||
git show "origin/perf-data:${file}" > "$DIR/perf-metrics.json" 2>/dev/null || true
|
||||
INDEX=$((INDEX + 1))
|
||||
done
|
||||
|
||||
echo "Loaded $INDEX historical baselines"
|
||||
|
||||
- name: Generate perf report
|
||||
if: steps.sha-check.outputs.stale != 'true'
|
||||
run: npx --yes tsx scripts/perf-report.ts > perf-report.md
|
||||
|
||||
- name: Post PR comment
|
||||
if: steps.sha-check.outputs.stale != 'true'
|
||||
uses: ./.github/actions/post-pr-report-comment
|
||||
with:
|
||||
pr-number: ${{ steps.pr-meta.outputs.number }}
|
||||
report-file: ./perf-report.md
|
||||
comment-marker: '<!-- COMFYUI_FRONTEND_PERF -->'
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
168
.github/workflows/pr-report.yaml
vendored
168
.github/workflows/pr-report.yaml
vendored
@@ -1,168 +0,0 @@
|
||||
name: 'PR: Unified Report'
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ['CI: Size Data', 'CI: Performance Report', 'CI: E2E Coverage']
|
||||
types:
|
||||
- completed
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
issues: write
|
||||
actions: read
|
||||
|
||||
concurrency:
|
||||
group: pr-report-${{ github.event.workflow_run.head_sha }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
comment:
|
||||
runs-on: ubuntu-latest
|
||||
if: >
|
||||
github.repository == 'Comfy-Org/ComfyUI_frontend' &&
|
||||
github.event.workflow_run.event == 'pull_request'
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Setup frontend
|
||||
uses: ./.github/actions/setup-frontend
|
||||
|
||||
- name: Resolve PR from workflow_run context
|
||||
id: pr-meta
|
||||
uses: ./.github/actions/resolve-pr-from-workflow-run
|
||||
|
||||
- name: Find size workflow run
|
||||
if: steps.pr-meta.outputs.skip != 'true'
|
||||
id: find-size
|
||||
uses: ./.github/actions/find-workflow-run
|
||||
with:
|
||||
workflow-id: ci-size-data.yaml
|
||||
head-sha: ${{ steps.pr-meta.outputs.head-sha }}
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Find perf workflow run
|
||||
if: steps.pr-meta.outputs.skip != 'true'
|
||||
id: find-perf
|
||||
uses: ./.github/actions/find-workflow-run
|
||||
with:
|
||||
workflow-id: ci-perf-report.yaml
|
||||
head-sha: ${{ steps.pr-meta.outputs.head-sha }}
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Download size data (current)
|
||||
if: steps.pr-meta.outputs.skip != 'true' && steps.find-size.outputs.status == 'ready'
|
||||
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
|
||||
with:
|
||||
name: size-data
|
||||
run_id: ${{ steps.find-size.outputs.run-id }}
|
||||
path: temp/size
|
||||
|
||||
- name: Download size baseline
|
||||
if: steps.pr-meta.outputs.skip != 'true' && steps.find-size.outputs.status == 'ready'
|
||||
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
|
||||
with:
|
||||
branch: ${{ steps.pr-meta.outputs.base }}
|
||||
workflow: ci-size-data.yaml
|
||||
event: push
|
||||
name: size-data
|
||||
path: temp/size-prev
|
||||
if_no_artifact_found: warn
|
||||
|
||||
- name: Find coverage workflow run
|
||||
if: steps.pr-meta.outputs.skip != 'true'
|
||||
id: find-coverage
|
||||
uses: ./.github/actions/find-workflow-run
|
||||
with:
|
||||
workflow-id: ci-tests-e2e-coverage.yaml
|
||||
head-sha: ${{ steps.pr-meta.outputs.head-sha }}
|
||||
not-found-status: skip
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Download coverage data
|
||||
if: steps.pr-meta.outputs.skip != 'true' && steps.find-coverage.outputs.status == 'ready'
|
||||
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
|
||||
with:
|
||||
name: e2e-coverage
|
||||
run_id: ${{ steps.find-coverage.outputs.run-id }}
|
||||
path: temp/coverage
|
||||
if_no_artifact_found: warn
|
||||
|
||||
- name: Download perf metrics (current)
|
||||
if: steps.pr-meta.outputs.skip != 'true' && steps.find-perf.outputs.status == 'ready'
|
||||
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
|
||||
with:
|
||||
name: perf-metrics
|
||||
run_id: ${{ steps.find-perf.outputs.run-id }}
|
||||
path: test-results/
|
||||
|
||||
- name: Download perf baseline
|
||||
if: steps.pr-meta.outputs.skip != 'true' && steps.find-perf.outputs.status == 'ready'
|
||||
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
|
||||
with:
|
||||
branch: ${{ steps.pr-meta.outputs.base }}
|
||||
workflow: ci-perf-report.yaml
|
||||
event: push
|
||||
name: perf-metrics
|
||||
path: temp/perf-baseline/
|
||||
if_no_artifact_found: warn
|
||||
|
||||
- name: Download perf history from perf-data branch
|
||||
if: steps.pr-meta.outputs.skip != 'true' && steps.find-perf.outputs.status == 'ready'
|
||||
continue-on-error: true
|
||||
run: |
|
||||
if git ls-remote --exit-code origin perf-data >/dev/null 2>&1; then
|
||||
git fetch origin perf-data --depth=1
|
||||
mkdir -p temp/perf-history
|
||||
for file in $(git ls-tree --name-only origin/perf-data baselines/ 2>/dev/null | sort -r | head -15); do
|
||||
git show "origin/perf-data:${file}" > "temp/perf-history/$(basename "$file")" 2>/dev/null || true
|
||||
done
|
||||
echo "Loaded $(ls temp/perf-history/*.json 2>/dev/null | wc -l) historical baselines"
|
||||
fi
|
||||
|
||||
- name: Generate unified report
|
||||
if: steps.pr-meta.outputs.skip != 'true'
|
||||
run: >
|
||||
pnpm exec tsx scripts/unified-report.ts
|
||||
--size-status=${{ steps.find-size.outputs.status }}
|
||||
--perf-status=${{ steps.find-perf.outputs.status }}
|
||||
--coverage-status=${{ steps.find-coverage.outputs.status }}
|
||||
> pr-report.md
|
||||
|
||||
- name: Remove legacy separate comments
|
||||
if: steps.pr-meta.outputs.skip != 'true'
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
const prNumber = Number('${{ steps.pr-meta.outputs.number }}');
|
||||
const legacyMarkers = [
|
||||
'<!-- COMFYUI_FRONTEND_SIZE -->',
|
||||
'<!-- COMFYUI_FRONTEND_PERF -->',
|
||||
];
|
||||
|
||||
const comments = await github.paginate(github.rest.issues.listComments, {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: prNumber,
|
||||
per_page: 100,
|
||||
});
|
||||
|
||||
for (const comment of comments) {
|
||||
if (legacyMarkers.some(m => comment.body?.includes(m))) {
|
||||
await github.rest.issues.deleteComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: comment.id,
|
||||
});
|
||||
core.info(`Deleted legacy comment ${comment.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
- name: Post PR comment
|
||||
if: steps.pr-meta.outputs.skip != 'true'
|
||||
uses: ./.github/actions/post-pr-report-comment
|
||||
with:
|
||||
pr-number: ${{ steps.pr-meta.outputs.number }}
|
||||
report-file: ./pr-report.md
|
||||
comment-marker: '<!-- COMFYUI_FRONTEND_PR_REPORT -->'
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
24
.github/workflows/pr-request-team-review.yaml
vendored
24
.github/workflows/pr-request-team-review.yaml
vendored
@@ -1,24 +0,0 @@
|
||||
# Request team review for PRs from external contributors.
|
||||
name: PR:Request Team Review
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, reopened]
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
request-review:
|
||||
if: >-
|
||||
!contains(fromJson('["OWNER","MEMBER","COLLABORATOR"]'),
|
||||
github.event.pull_request.author_association)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Request team review
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
gh pr edit ${{ github.event.pull_request.number }} \
|
||||
--repo ${{ github.repository }} \
|
||||
--add-reviewer Comfy-org/comfy_frontend_devs
|
||||
133
.github/workflows/pr-size-report.yaml
vendored
Normal file
133
.github/workflows/pr-size-report.yaml
vendored
Normal file
@@ -0,0 +1,133 @@
|
||||
name: 'PR: Size Report'
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ['CI: Size Data']
|
||||
types:
|
||||
- completed
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
pr_number:
|
||||
description: 'PR number to report on'
|
||||
required: true
|
||||
type: number
|
||||
run_id:
|
||||
description: 'Size data workflow run ID'
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
comment:
|
||||
runs-on: ubuntu-latest
|
||||
if: >
|
||||
github.repository == 'Comfy-Org/ComfyUI_frontend' &&
|
||||
(
|
||||
(github.event_name == 'workflow_run' &&
|
||||
github.event.workflow_run.event == 'pull_request' &&
|
||||
github.event.workflow_run.conclusion == 'success') ||
|
||||
github.event_name == 'workflow_dispatch'
|
||||
)
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Setup frontend
|
||||
uses: ./.github/actions/setup-frontend
|
||||
|
||||
- name: Download size data
|
||||
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
|
||||
with:
|
||||
name: size-data
|
||||
run_id: ${{ github.event_name == 'workflow_dispatch' && inputs.run_id || github.event.workflow_run.id }}
|
||||
path: temp/size
|
||||
|
||||
- name: Resolve and validate PR metadata
|
||||
id: pr-meta
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
|
||||
// workflow_dispatch: validate artifact metadata against API-resolved PR
|
||||
if (context.eventName === 'workflow_dispatch') {
|
||||
const pullNumber = Number('${{ inputs.pr_number }}');
|
||||
const { data: dispatchPr } = await github.rest.pulls.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: pullNumber,
|
||||
});
|
||||
|
||||
const artifactPr = Number(fs.readFileSync('temp/size/number.txt', 'utf8').trim());
|
||||
const artifactBase = fs.readFileSync('temp/size/base.txt', 'utf8').trim();
|
||||
|
||||
if (artifactPr !== dispatchPr.number) {
|
||||
core.setFailed(`Artifact PR number (${artifactPr}) does not match dispatch PR (${dispatchPr.number}).`);
|
||||
return;
|
||||
}
|
||||
if (artifactBase !== dispatchPr.base.ref) {
|
||||
core.setFailed(`Artifact base (${artifactBase}) does not match dispatch PR base (${dispatchPr.base.ref}).`);
|
||||
return;
|
||||
}
|
||||
|
||||
core.setOutput('number', String(dispatchPr.number));
|
||||
core.setOutput('base', dispatchPr.base.ref);
|
||||
return;
|
||||
}
|
||||
|
||||
// workflow_run: validate artifact metadata against trusted context
|
||||
const artifactPr = Number(fs.readFileSync('temp/size/number.txt', 'utf8').trim());
|
||||
const artifactBase = fs.readFileSync('temp/size/base.txt', 'utf8').trim();
|
||||
|
||||
let pr = context.payload.workflow_run.pull_requests?.[0];
|
||||
if (!pr) {
|
||||
const { data: prs } = await github.rest.repos.listPullRequestsAssociatedWithCommit({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
commit_sha: context.payload.workflow_run.head_sha,
|
||||
});
|
||||
pr = prs.find(p => p.state === 'open');
|
||||
}
|
||||
|
||||
if (!pr) {
|
||||
core.setFailed('Unable to resolve PR from workflow_run context.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (Number(pr.number) !== artifactPr) {
|
||||
core.setFailed(`Artifact PR number (${artifactPr}) does not match trusted context (${pr.number}).`);
|
||||
return;
|
||||
}
|
||||
|
||||
const trustedBase = pr.base?.ref;
|
||||
if (!trustedBase || artifactBase !== trustedBase) {
|
||||
core.setFailed(`Artifact base (${artifactBase}) does not match trusted context (${trustedBase}).`);
|
||||
return;
|
||||
}
|
||||
|
||||
core.setOutput('number', String(pr.number));
|
||||
core.setOutput('base', trustedBase);
|
||||
|
||||
- name: Download previous size data
|
||||
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
|
||||
with:
|
||||
branch: ${{ steps.pr-meta.outputs.base }}
|
||||
workflow: ci-size-data.yaml
|
||||
event: push
|
||||
name: size-data
|
||||
path: temp/size-prev
|
||||
if_no_artifact_found: warn
|
||||
|
||||
- name: Generate size report
|
||||
run: node scripts/size-report.js > size-report.md
|
||||
|
||||
- name: Post PR comment
|
||||
uses: ./.github/actions/post-pr-report-comment
|
||||
with:
|
||||
pr-number: ${{ steps.pr-meta.outputs.number }}
|
||||
report-file: ./size-report.md
|
||||
comment-marker: '<!-- COMFYUI_FRONTEND_SIZE -->'
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -77,7 +77,7 @@ jobs:
|
||||
needs: setup
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.16
|
||||
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.13
|
||||
credentials:
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
64
.github/workflows/pr-vercel-website-preview.yaml
vendored
64
.github/workflows/pr-vercel-website-preview.yaml
vendored
@@ -1,64 +0,0 @@
|
||||
---
|
||||
name: 'PR: Vercel Website Preview'
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ['CI: Vercel Website Preview']
|
||||
types:
|
||||
- completed
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
actions: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.workflow_run.head_repository.full_name }}-${{ github.event.workflow_run.head_branch }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
comment:
|
||||
runs-on: ubuntu-latest
|
||||
if: >
|
||||
github.repository == 'Comfy-Org/ComfyUI_frontend' &&
|
||||
github.event.workflow_run.event == 'pull_request' &&
|
||||
github.event.workflow_run.conclusion == 'success'
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Download preview metadata
|
||||
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
|
||||
with:
|
||||
name: vercel-preview
|
||||
run_id: ${{ github.event.workflow_run.id }}
|
||||
path: temp/vercel-preview
|
||||
|
||||
- name: Resolve PR number from workflow_run context
|
||||
id: pr-meta
|
||||
uses: ./.github/actions/resolve-pr-from-workflow-run
|
||||
|
||||
- name: Write report
|
||||
if: steps.pr-meta.outputs.skip != 'true'
|
||||
env:
|
||||
DEPLOYED_AT: ${{ github.event.workflow_run.updated_at }}
|
||||
HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
|
||||
run: |
|
||||
STABLE_URL=$(cat temp/vercel-preview/stable-url.txt)
|
||||
UNIQUE_URL=$(cat temp/vercel-preview/url.txt)
|
||||
SHORT_SHA="${HEAD_SHA:0:7}"
|
||||
cat > preview-report.md <<EOF
|
||||
**Website Preview:** $STABLE_URL
|
||||
|
||||
<sub>This commit: $UNIQUE_URL</sub>
|
||||
|
||||
<sub>Last updated: $DEPLOYED_AT for \`$SHORT_SHA\`</sub>
|
||||
EOF
|
||||
|
||||
- name: Post PR comment
|
||||
if: steps.pr-meta.outputs.skip != 'true'
|
||||
uses: ./.github/actions/post-pr-report-comment
|
||||
with:
|
||||
pr-number: ${{ steps.pr-meta.outputs.number }}
|
||||
report-file: ./preview-report.md
|
||||
comment-marker: '<!-- VERCEL_WEBSITE_PREVIEW -->'
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
4
.github/workflows/publish-desktop-ui.yaml
vendored
4
.github/workflows/publish-desktop-ui.yaml
vendored
@@ -84,7 +84,9 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
|
||||
164
.github/workflows/release-biweekly-comfyui.yaml
vendored
164
.github/workflows/release-biweekly-comfyui.yaml
vendored
@@ -1,23 +1,14 @@
|
||||
# Release workflow for ComfyUI frontend: version bump → PyPI publish → ComfyUI PR.
|
||||
# Runs on a bi-weekly schedule for minor releases, or manually for patch/hotfix releases.
|
||||
name: 'Release: ComfyUI'
|
||||
# Automated bi-weekly workflow to bump ComfyUI frontend RC releases
|
||||
name: 'Release: Bi-weekly ComfyUI'
|
||||
|
||||
on:
|
||||
# Bi-weekly schedule: Monday at 20:00 UTC
|
||||
# Schedule for Monday at 12:00 PM PST (20:00 UTC)
|
||||
schedule:
|
||||
- cron: '0 20 * * 1'
|
||||
|
||||
# Manual trigger for both on-demand minor and patch/hotfix releases
|
||||
# Allow manual triggering (bypasses bi-weekly check)
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
release_type:
|
||||
description: 'minor = next minor version (bi-weekly cadence), patch = hotfix for current production version'
|
||||
required: true
|
||||
default: 'minor'
|
||||
type: choice
|
||||
options:
|
||||
- minor
|
||||
- patch
|
||||
comfyui_fork:
|
||||
description: 'ComfyUI fork to use for PR (e.g., Comfy-Org/ComfyUI)'
|
||||
required: false
|
||||
@@ -50,11 +41,10 @@ jobs:
|
||||
|
||||
- name: Summary
|
||||
run: |
|
||||
echo "## Release Check" >> $GITHUB_STEP_SUMMARY
|
||||
echo "## Bi-weekly Check" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- Is release week: ${{ steps.check.outputs.is_release_week }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- Manual trigger: ${{ github.event_name == 'workflow_dispatch' }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- Release type: ${{ inputs.release_type || 'minor (scheduled)' }}" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
resolve-version:
|
||||
needs: check-release-week
|
||||
@@ -85,9 +75,9 @@ jobs:
|
||||
path: comfyui
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
with:
|
||||
package_json_file: frontend/package.json
|
||||
version: 10
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
@@ -101,8 +91,6 @@ jobs:
|
||||
- name: Resolve release information
|
||||
id: resolve
|
||||
working-directory: frontend
|
||||
env:
|
||||
RELEASE_TYPE: ${{ inputs.release_type || 'minor' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
@@ -174,130 +162,9 @@ jobs:
|
||||
echo "- Target version: ${{ needs.resolve-version.outputs.target_version }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- [View workflow runs](https://github.com/Comfy-Org/ComfyUI_frontend/actions/workflows/release-version-bump.yaml)" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
publish-pypi:
|
||||
needs: [resolve-version, trigger-release-if-needed]
|
||||
if: >
|
||||
always() &&
|
||||
needs.resolve-version.result == 'success' &&
|
||||
(needs.trigger-release-if-needed.result == 'success' ||
|
||||
needs.trigger-release-if-needed.result == 'skipped')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Wait for release PR to be created and merged
|
||||
if: needs.trigger-release-if-needed.result == 'success'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.PR_GH_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
TARGET_VERSION="${{ needs.resolve-version.outputs.target_version }}"
|
||||
TARGET_BRANCH="${{ needs.resolve-version.outputs.target_branch }}"
|
||||
echo "Waiting for version bump PR for v${TARGET_VERSION} on ${TARGET_BRANCH} to be merged..."
|
||||
|
||||
# Poll for up to 30 minutes (a human or automation needs to merge the version bump PR)
|
||||
for i in $(seq 1 60); do
|
||||
# Check if the tag exists (release-draft-create creates a tag on merge)
|
||||
if gh api "repos/Comfy-Org/ComfyUI_frontend/git/ref/tags/v${TARGET_VERSION}" --silent 2>/dev/null; then
|
||||
echo "✅ Tag v${TARGET_VERSION} found — release PR has been merged"
|
||||
exit 0
|
||||
fi
|
||||
echo "Attempt $i/60: Tag v${TARGET_VERSION} not found yet, waiting 30s..."
|
||||
sleep 30
|
||||
done
|
||||
|
||||
echo "❌ Timed out waiting for tag v${TARGET_VERSION}"
|
||||
exit 1
|
||||
|
||||
- name: Checkout code at target version
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: v${{ needs.resolve-version.outputs.target_version }}
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Build project
|
||||
env:
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
ALGOLIA_APP_ID: ${{ secrets.ALGOLIA_APP_ID }}
|
||||
ALGOLIA_API_KEY: ${{ secrets.ALGOLIA_API_KEY }}
|
||||
ENABLE_MINIFY: 'true'
|
||||
USE_PROD_CONFIG: 'true'
|
||||
run: |
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm build
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.x'
|
||||
|
||||
- name: Install build dependencies
|
||||
run: python -m pip install build
|
||||
|
||||
- name: Build and publish PyPI package
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p comfyui_frontend_package/comfyui_frontend_package/static/
|
||||
cp -r dist/* comfyui_frontend_package/comfyui_frontend_package/static/
|
||||
|
||||
- name: Build pypi package
|
||||
run: python -m build
|
||||
working-directory: comfyui_frontend_package
|
||||
env:
|
||||
COMFYUI_FRONTEND_VERSION: ${{ needs.resolve-version.outputs.target_version }}
|
||||
|
||||
- name: Publish pypi package
|
||||
uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0
|
||||
with:
|
||||
skip-existing: true
|
||||
password: ${{ secrets.PYPI_TOKEN }}
|
||||
packages-dir: comfyui_frontend_package/dist
|
||||
|
||||
- name: Wait for PyPI propagation
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
TARGET_VERSION="${{ needs.resolve-version.outputs.target_version }}"
|
||||
PACKAGE="comfyui-frontend-package"
|
||||
echo "Waiting for ${PACKAGE}==${TARGET_VERSION} to be available on PyPI..."
|
||||
|
||||
# Wait up to 15 minutes (polling every 30 seconds)
|
||||
for i in $(seq 1 30); do
|
||||
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" "https://pypi.org/pypi/${PACKAGE}/${TARGET_VERSION}/json")
|
||||
if [ "$HTTP_CODE" = "200" ]; then
|
||||
echo "✅ ${PACKAGE}==${TARGET_VERSION} is available on PyPI"
|
||||
exit 0
|
||||
fi
|
||||
echo "Attempt $i/30: PyPI returned HTTP ${HTTP_CODE}, waiting 30s..."
|
||||
sleep 30
|
||||
done
|
||||
|
||||
echo "❌ Timed out waiting for ${PACKAGE}==${TARGET_VERSION} on PyPI"
|
||||
exit 1
|
||||
|
||||
- name: Summary
|
||||
run: |
|
||||
echo "## PyPI Publishing" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- Package: comfyui-frontend-package" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- Version: ${{ needs.resolve-version.outputs.target_version }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- Status: ✅ Published and confirmed available" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
create-comfyui-pr:
|
||||
needs:
|
||||
[
|
||||
check-release-week,
|
||||
resolve-version,
|
||||
trigger-release-if-needed,
|
||||
publish-pypi
|
||||
]
|
||||
if: always() && needs.resolve-version.result == 'success' && needs.publish-pypi.result == 'success' && (needs.check-release-week.outputs.is_release_week == 'true' || github.event_name == 'workflow_dispatch')
|
||||
needs: [check-release-week, resolve-version, trigger-release-if-needed]
|
||||
if: always() && needs.resolve-version.result == 'success' && (needs.check-release-week.outputs.is_release_week == 'true' || github.event_name == 'workflow_dispatch')
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
@@ -369,8 +236,11 @@ jobs:
|
||||
EOF
|
||||
)
|
||||
|
||||
PYPI_NOTE="✅ **PyPI package confirmed available** — \`comfyui-frontend-package==${{ needs.resolve-version.outputs.target_version }}\` has been published and verified."
|
||||
BODY=$''"${PYPI_NOTE}"$'\n\n'"${BODY}"
|
||||
# Add release PR note if release was triggered
|
||||
if [ "${{ needs.resolve-version.outputs.needs_release }}" = "true" ]; then
|
||||
RELEASE_NOTE="⚠️ **Release PR must be merged first** - check [release workflow runs](https://github.com/Comfy-Org/ComfyUI_frontend/actions/workflows/release-version-bump.yaml)"
|
||||
BODY=$''"${RELEASE_NOTE}"$'\n\n'"${BODY}"
|
||||
fi
|
||||
|
||||
# Save to file for later use
|
||||
printf '%s\n' "$BODY" > pr-body.txt
|
||||
@@ -437,11 +307,7 @@ jobs:
|
||||
fi
|
||||
|
||||
if [ -n "$EXISTING_PR" ] && [ "$EXISTING_PR" != "null" ]; then
|
||||
echo "PR already exists (#${EXISTING_PR}), refreshing title/body"
|
||||
gh pr edit "$EXISTING_PR" \
|
||||
--repo Comfy-Org/ComfyUI \
|
||||
--title "Bump comfyui-frontend-package to ${{ needs.resolve-version.outputs.target_version }}" \
|
||||
--body-file ../pr-body.txt
|
||||
echo "PR already exists (#${EXISTING_PR}), updating branch will update the PR"
|
||||
else
|
||||
echo "Failed to create PR and no existing PR found"
|
||||
exit 1
|
||||
|
||||
38
.github/workflows/release-draft-create.yaml
vendored
38
.github/workflows/release-draft-create.yaml
vendored
@@ -20,10 +20,10 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
with:
|
||||
version: 10
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
@@ -99,6 +99,37 @@ jobs:
|
||||
${{ needs.build.outputs.is_prerelease == 'true' }}
|
||||
generate_release_notes: true
|
||||
|
||||
publish_pypi:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
- name: Download dist artifact
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: dist-files
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.x'
|
||||
- name: Install build dependencies
|
||||
run: python -m pip install build
|
||||
- name: Setup pypi package
|
||||
run: |
|
||||
mkdir -p comfyui_frontend_package/comfyui_frontend_package/static/
|
||||
cp -r dist/* comfyui_frontend_package/comfyui_frontend_package/static/
|
||||
- name: Build pypi package
|
||||
run: python -m build
|
||||
working-directory: comfyui_frontend_package
|
||||
env:
|
||||
COMFYUI_FRONTEND_VERSION: ${{ needs.build.outputs.version }}
|
||||
- name: Publish pypi package
|
||||
uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0
|
||||
with:
|
||||
password: ${{ secrets.PYPI_TOKEN }}
|
||||
packages-dir: comfyui_frontend_package/dist
|
||||
|
||||
publish_types:
|
||||
needs: build
|
||||
uses: ./.github/workflows/release-npm-types.yaml
|
||||
@@ -111,6 +142,7 @@ jobs:
|
||||
name: Comment Release Summary
|
||||
needs:
|
||||
- draft_release
|
||||
- publish_pypi
|
||||
- publish_types
|
||||
if: success()
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
4
.github/workflows/release-npm-types.yaml
vendored
4
.github/workflows/release-npm-types.yaml
vendored
@@ -75,7 +75,9 @@ jobs:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
|
||||
6
.github/workflows/release-pypi-dev.yaml
vendored
6
.github/workflows/release-pypi-dev.yaml
vendored
@@ -16,10 +16,10 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
with:
|
||||
version: 10
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
|
||||
19
.github/workflows/release-version-bump.yaml
vendored
19
.github/workflows/release-version-bump.yaml
vendored
@@ -30,7 +30,6 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
bump-version:
|
||||
if: github.repository == 'Comfy-Org/ComfyUI_frontend'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -142,22 +141,10 @@ jobs:
|
||||
fi
|
||||
echo "✅ Branch '$BRANCH' exists"
|
||||
|
||||
- name: Ensure packageManager field exists
|
||||
run: |
|
||||
if ! grep -q '"packageManager"' package.json; then
|
||||
# Old branches (e.g. core/1.42) predate the packageManager field.
|
||||
# Inject it so pnpm/action-setup can resolve the version.
|
||||
node -e "
|
||||
const fs = require('fs');
|
||||
const pkg = JSON.parse(fs.readFileSync('package.json','utf8'));
|
||||
pkg.packageManager = 'pnpm@10.33.0';
|
||||
fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n');
|
||||
"
|
||||
echo "Injected packageManager into package.json for legacy branch"
|
||||
fi
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
|
||||
@@ -51,7 +51,9 @@ jobs:
|
||||
echo "✅ Branch '$BRANCH' exists"
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
|
||||
5
.github/workflows/weekly-docs-check.yaml
vendored
5
.github/workflows/weekly-docs-check.yaml
vendored
@@ -18,7 +18,6 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
docs-check:
|
||||
if: github.repository == 'Comfy-Org/ComfyUI_frontend'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
@@ -29,7 +28,9 @@ jobs:
|
||||
ref: main
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
|
||||
@@ -64,7 +64,6 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"no-unsafe-optional-chaining": "error",
|
||||
"no-self-assign": "allow",
|
||||
"no-unused-expressions": "off",
|
||||
"no-unused-private-class-members": "off",
|
||||
@@ -84,7 +83,6 @@
|
||||
"typescript/no-unsafe-declaration-merging": "off",
|
||||
"typescript/no-unused-vars": "off",
|
||||
"unicorn/no-empty-file": "off",
|
||||
"vitest/require-mock-type-parameters": "off",
|
||||
"unicorn/no-new-array": "off",
|
||||
"unicorn/no-single-promise-in-promise-methods": "off",
|
||||
"unicorn/no-useless-fallback-in-spread": "off",
|
||||
@@ -99,14 +97,8 @@
|
||||
"typescript/unbound-method": "off",
|
||||
"typescript/no-floating-promises": "error",
|
||||
"typescript/no-explicit-any": "error",
|
||||
"typescript/no-import-type-side-effects": "error",
|
||||
"typescript/no-empty-object-type": [
|
||||
"error",
|
||||
{
|
||||
"allowInterfaces": "always"
|
||||
}
|
||||
],
|
||||
"vue/no-import-compiler-macros": "error"
|
||||
"vue/no-import-compiler-macros": "error",
|
||||
"vue/no-dupe-keys": "error"
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
@@ -117,60 +109,13 @@
|
||||
},
|
||||
{
|
||||
"files": ["browser_tests/**/*.ts"],
|
||||
"jsPlugins": ["eslint-plugin-playwright"],
|
||||
"rules": {
|
||||
"typescript/no-explicit-any": "error",
|
||||
"no-async-promise-executor": "error",
|
||||
"no-control-regex": "error",
|
||||
"no-useless-rename": "error",
|
||||
"no-unused-private-class-members": "error",
|
||||
"unicorn/no-empty-file": "error",
|
||||
"playwright/consistent-spacing-between-blocks": "error",
|
||||
"playwright/expect-expect": [
|
||||
"error",
|
||||
{
|
||||
"assertFunctionNames": [
|
||||
"recordMeasurement",
|
||||
"logMeasurement",
|
||||
"builderSaveAs"
|
||||
],
|
||||
"assertFunctionPatterns": [
|
||||
"^expect",
|
||||
"^assert",
|
||||
"^verify",
|
||||
"^searchAndExpect",
|
||||
"waitForOpen",
|
||||
"waitForClosed",
|
||||
"waitForRequest"
|
||||
]
|
||||
}
|
||||
],
|
||||
"playwright/max-nested-describe": "error",
|
||||
"playwright/no-duplicate-hooks": "error",
|
||||
"playwright/no-element-handle": "error",
|
||||
"playwright/no-eval": "error",
|
||||
"playwright/no-focused-test": "error",
|
||||
"playwright/no-force-option": "error",
|
||||
"playwright/no-networkidle": "error",
|
||||
"playwright/no-page-pause": "error",
|
||||
"playwright/no-skipped-test": "error",
|
||||
"playwright/no-unsafe-references": "error",
|
||||
"playwright/no-unused-locators": "error",
|
||||
"playwright/no-useless-await": "error",
|
||||
"playwright/no-useless-not": "error",
|
||||
"playwright/no-wait-for-navigation": "error",
|
||||
"playwright/no-wait-for-selector": "error",
|
||||
"playwright/no-wait-for-timeout": "error",
|
||||
"playwright/prefer-hooks-on-top": "error",
|
||||
"playwright/prefer-locator": "error",
|
||||
"playwright/prefer-to-have-count": "error",
|
||||
"playwright/prefer-to-have-length": "error",
|
||||
"playwright/prefer-web-first-assertions": "error",
|
||||
"playwright/prefer-native-locators": "error",
|
||||
"playwright/require-to-pass-timeout": "error",
|
||||
"playwright/valid-expect": "error",
|
||||
"playwright/valid-expect-in-promise": "error",
|
||||
"playwright/valid-title": "error"
|
||||
"unicorn/no-empty-file": "error"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
23
AGENTS.md
23
AGENTS.md
@@ -44,7 +44,6 @@ This project uses **pnpm**. Always prefer scripts defined in `package.json` (e.g
|
||||
## Build, Test, and Development Commands
|
||||
|
||||
- `pnpm dev`: Start Vite dev server.
|
||||
- `pnpm dev:cloud`: Dev server connected to cloud backend (testcloud.comfy.org)
|
||||
- `pnpm dev:electron`: Dev server with Electron API mocks
|
||||
- `pnpm build`: Type-check then production build to `dist/`
|
||||
- `pnpm preview`: Preview the production build locally
|
||||
@@ -180,12 +179,6 @@ This project uses **pnpm**. Always prefer scripts defined in `package.json` (e.g
|
||||
24. Do not use function expressions if it's possible to use function declarations instead
|
||||
25. Watch out for [Code Smells](https://wiki.c2.com/?CodeSmell) and refactor to avoid them
|
||||
|
||||
## Design Standards
|
||||
|
||||
Before implementing any user-facing feature, consult the [Comfy Design Standards](https://www.figma.com/design/QreIv5htUaSICNuO2VBHw0/Comfy-Design-Standards) Figma file. Use the Figma MCP to fetch it live — the file is the single source of truth and may be updated by designers at any time.
|
||||
|
||||
See `docs/guidance/design-standards.md` for Figma file keys, section node IDs, and component references.
|
||||
|
||||
## Testing Guidelines
|
||||
|
||||
See @docs/testing/\*.md for detailed patterns.
|
||||
@@ -215,7 +208,7 @@ See @docs/testing/\*.md for detailed patterns.
|
||||
3. Keep your module mocks contained
|
||||
Do not use global mutable state within the test file
|
||||
Use `vi.hoisted()` if necessary to allow for per-test Arrange phase manipulation of deeper mock state
|
||||
4. For Component testing, prefer [@testing-library/vue](https://testing-library.com/docs/vue-testing-library/intro/) with `@testing-library/user-event` for user-centric, behavioral tests. [Vue Test Utils](https://test-utils.vuejs.org/) is also accepted, especially for tests that need direct access to the component wrapper (e.g., `findComponent`, `emitted()`). Follow the advice [about making components easy to test](https://test-utils.vuejs.org/guide/essentials/easy-to-test.html)
|
||||
4. For Component testing, use [Vue Test Utils](https://test-utils.vuejs.org/) and especially follow the advice [about making components easy to test](https://test-utils.vuejs.org/guide/essentials/easy-to-test.html)
|
||||
5. Aim for behavioral coverage of critical and new features
|
||||
|
||||
### Playwright / Browser / E2E Tests
|
||||
@@ -223,7 +216,6 @@ See @docs/testing/\*.md for detailed patterns.
|
||||
1. Follow the Best Practices described [in the Playwright documentation](https://playwright.dev/docs/best-practices)
|
||||
2. Do not use waitForTimeout, use Locator actions and [retrying assertions](https://playwright.dev/docs/test-assertions#auto-retrying-assertions)
|
||||
3. Tags like `@mobile`, `@2x` are respected by config and should be used for relevant tests
|
||||
4. Type all API mock responses in `route.fulfill()` using generated types or schemas from `packages/ingest-types`, `packages/registry-types`, `src/workbench/extensions/manager/types/generatedManagerTypes.ts`, or `src/schemas/` — see `docs/guidance/playwright.md` for the full source-of-truth table
|
||||
|
||||
## External Resources
|
||||
|
||||
@@ -233,25 +225,12 @@ See @docs/testing/\*.md for detailed patterns.
|
||||
- shadcn/vue: <https://www.shadcn-vue.com/>
|
||||
- Reka UI: <https://reka-ui.com/>
|
||||
- PrimeVue: <https://primevue.org>
|
||||
- Comfy Design Standards: <https://www.figma.com/design/QreIv5htUaSICNuO2VBHw0/Comfy-Design-Standards>
|
||||
- ComfyUI: <https://docs.comfy.org>
|
||||
- Electron: <https://www.electronjs.org/docs/latest/>
|
||||
- Wiki: <https://deepwiki.com/Comfy-Org/ComfyUI_frontend/1-overview>
|
||||
- Nx: <https://nx.dev/docs/reference/nx-commands>
|
||||
- [Practical Test Pyramid](https://martinfowler.com/articles/practical-test-pyramid.html)
|
||||
|
||||
## Architecture Decision Records
|
||||
|
||||
All architectural decisions are documented in `docs/adr/`. Code changes must be consistent with accepted ADRs. Proposed ADRs indicate design direction and should be treated as guidance. See `.agents/checks/adr-compliance.md` for automated validation rules.
|
||||
|
||||
### Entity Architecture Constraints (ADR 0003 + ADR 0008)
|
||||
|
||||
1. **Command pattern for all mutations**: Every entity state change must be a serializable, idempotent, deterministic command — replayable, undoable, and transmittable over CRDT. No imperative fire-and-forget mutation APIs. Systems produce command batches, not direct side effects.
|
||||
2. **Centralized registries and ECS-style access**: Entity data lives in the World (centralized registry), queried via `world.getComponent(entityId, ComponentType)`. Do not add new instance properties/methods to entity classes. Do not use OOP inheritance for entity modeling.
|
||||
3. **No god-object growth**: Do not add methods to `LGraphNode`, `LGraphCanvas`, `LGraph`, or `Subgraph`. Extract to systems, stores, or composables.
|
||||
4. **Plain data components**: ECS components are plain data objects — no methods, no back-references to parent entities. Behavior belongs in systems (pure functions).
|
||||
5. **Extension ecosystem impact**: Changes to entity callbacks (`onConnectionsChange`, `onRemoved`, `onAdded`, `onConnectInput/Output`, `onConfigure`, `onWidgetChanged`), `node.widgets` access, `node.serialize`, or `graph._version++` affect 40+ custom node repos and require migration guidance.
|
||||
|
||||
## Project Philosophy
|
||||
|
||||
- Follow good software engineering principles
|
||||
|
||||
94
CODEOWNERS
94
CODEOWNERS
@@ -1,95 +1,61 @@
|
||||
# Global Ownership
|
||||
* @Comfy-org/comfy_frontend_devs
|
||||
|
||||
# Desktop/Electron
|
||||
/apps/desktop-ui/ @benceruleanlu
|
||||
/src/stores/electronDownloadStore.ts @benceruleanlu
|
||||
/src/extensions/core/electronAdapter.ts @benceruleanlu
|
||||
/vite.electron.config.mts @benceruleanlu
|
||||
/apps/desktop-ui/ @benceruleanlu @Comfy-org/comfy_frontend_devs
|
||||
/src/stores/electronDownloadStore.ts @benceruleanlu @Comfy-org/comfy_frontend_devs
|
||||
/src/extensions/core/electronAdapter.ts @benceruleanlu @Comfy-org/comfy_frontend_devs
|
||||
/vite.electron.config.mts @benceruleanlu @Comfy-org/comfy_frontend_devs
|
||||
|
||||
# Common UI Components
|
||||
/src/components/chip/ @viva-jinyi
|
||||
/src/components/card/ @viva-jinyi
|
||||
/src/components/button/ @viva-jinyi
|
||||
/src/components/input/ @viva-jinyi
|
||||
/src/components/chip/ @viva-jinyi @Comfy-org/comfy_frontend_devs
|
||||
/src/components/card/ @viva-jinyi @Comfy-org/comfy_frontend_devs
|
||||
/src/components/button/ @viva-jinyi @Comfy-org/comfy_frontend_devs
|
||||
/src/components/input/ @viva-jinyi @Comfy-org/comfy_frontend_devs
|
||||
|
||||
# Topbar
|
||||
/src/components/topbar/ @pythongosssss
|
||||
/src/components/topbar/ @pythongosssss @Comfy-org/comfy_frontend_devs
|
||||
|
||||
# Thumbnail
|
||||
/src/renderer/core/thumbnail/ @pythongosssss
|
||||
/src/renderer/core/thumbnail/ @pythongosssss @Comfy-org/comfy_frontend_devs
|
||||
|
||||
# Legacy UI
|
||||
/scripts/ui/ @pythongosssss
|
||||
/scripts/ui/ @pythongosssss @Comfy-org/comfy_frontend_devs
|
||||
|
||||
# Link rendering
|
||||
/src/renderer/core/canvas/links/ @benceruleanlu
|
||||
/src/renderer/core/canvas/links/ @benceruleanlu @Comfy-org/comfy_frontend_devs
|
||||
|
||||
# Partner Nodes
|
||||
/src/composables/node/useNodePricing.ts @jojodecayz @bigcat88
|
||||
/src/composables/node/useNodePricing.ts @jojodecayz @bigcat88 @Comfy-org/comfy_frontend_devs
|
||||
|
||||
# Node help system
|
||||
/src/utils/nodeHelpUtil.ts @benceruleanlu
|
||||
/src/stores/workspace/nodeHelpStore.ts @benceruleanlu
|
||||
/src/services/nodeHelpService.ts @benceruleanlu
|
||||
/src/utils/nodeHelpUtil.ts @benceruleanlu @Comfy-org/comfy_frontend_devs
|
||||
/src/stores/workspace/nodeHelpStore.ts @benceruleanlu @Comfy-org/comfy_frontend_devs
|
||||
/src/services/nodeHelpService.ts @benceruleanlu @Comfy-org/comfy_frontend_devs
|
||||
|
||||
# Selection toolbox
|
||||
/src/components/graph/selectionToolbox/ @Myestery
|
||||
/src/components/graph/selectionToolbox/ @Myestery @Comfy-org/comfy_frontend_devs
|
||||
|
||||
# Minimap
|
||||
/src/renderer/extensions/minimap/ @jtydhr88 @Myestery
|
||||
/src/renderer/extensions/minimap/ @jtydhr88 @Myestery @Comfy-org/comfy_frontend_devs
|
||||
|
||||
# Workflow Templates
|
||||
/src/platform/workflow/templates/ @Myestery @christian-byrne @comfyui-wiki
|
||||
/src/components/templates/ @Myestery @christian-byrne @comfyui-wiki
|
||||
/src/platform/workflow/templates/ @Myestery @christian-byrne @comfyui-wiki @Comfy-org/comfy_frontend_devs
|
||||
/src/components/templates/ @Myestery @christian-byrne @comfyui-wiki @Comfy-org/comfy_frontend_devs
|
||||
|
||||
# Mask Editor
|
||||
/src/extensions/core/maskeditor.ts @trsommer @brucew4yn3rp @jtydhr88
|
||||
/src/extensions/core/maskEditorLayerFilenames.ts @trsommer @brucew4yn3rp @jtydhr88
|
||||
/src/components/maskeditor/ @trsommer @brucew4yn3rp @jtydhr88
|
||||
/src/composables/maskeditor/ @trsommer @brucew4yn3rp @jtydhr88
|
||||
/src/stores/maskEditorStore.ts @trsommer @brucew4yn3rp @jtydhr88
|
||||
/src/stores/maskEditorDataStore.ts @trsommer @brucew4yn3rp @jtydhr88
|
||||
|
||||
# Image Crop
|
||||
/src/extensions/core/imageCrop.ts @jtydhr88
|
||||
/src/components/imagecrop/ @jtydhr88
|
||||
/src/composables/useImageCrop.ts @jtydhr88
|
||||
/src/lib/litegraph/src/widgets/ImageCropWidget.ts @jtydhr88
|
||||
|
||||
# Image Compare
|
||||
/src/extensions/core/imageCompare.ts @jtydhr88
|
||||
/src/renderer/extensions/vueNodes/widgets/components/WidgetImageCompare.vue @jtydhr88
|
||||
/src/renderer/extensions/vueNodes/widgets/components/WidgetImageCompare.test.ts @jtydhr88
|
||||
/src/renderer/extensions/vueNodes/widgets/components/WidgetImageCompare.stories.ts @jtydhr88
|
||||
/src/renderer/extensions/vueNodes/widgets/composables/useImageCompareWidget.ts @jtydhr88
|
||||
/src/lib/litegraph/src/widgets/ImageCompareWidget.ts @jtydhr88
|
||||
|
||||
# Painter
|
||||
/src/extensions/core/painter.ts @jtydhr88
|
||||
/src/components/painter/ @jtydhr88
|
||||
/src/composables/painter/ @jtydhr88
|
||||
/src/renderer/extensions/vueNodes/widgets/composables/usePainterWidget.ts @jtydhr88
|
||||
/src/lib/litegraph/src/widgets/PainterWidget.ts @jtydhr88
|
||||
|
||||
# GLSL
|
||||
/src/renderer/glsl/ @jtydhr88 @pythongosssss @christian-byrne
|
||||
/src/extensions/core/maskeditor.ts @trsommer @brucew4yn3rp @Comfy-org/comfy_frontend_devs
|
||||
/src/extensions/core/maskEditorLayerFilenames.ts @trsommer @brucew4yn3rp @Comfy-org/comfy_frontend_devs
|
||||
|
||||
# 3D
|
||||
/src/extensions/core/load3d.ts @jtydhr88
|
||||
/src/extensions/core/load3dLazy.ts @jtydhr88
|
||||
/src/extensions/core/load3d/ @jtydhr88
|
||||
/src/components/load3d/ @jtydhr88
|
||||
/src/composables/useLoad3d.ts @jtydhr88
|
||||
/src/composables/useLoad3d.test.ts @jtydhr88
|
||||
/src/composables/useLoad3dDrag.ts @jtydhr88
|
||||
/src/composables/useLoad3dDrag.test.ts @jtydhr88
|
||||
/src/composables/useLoad3dViewer.ts @jtydhr88
|
||||
/src/composables/useLoad3dViewer.test.ts @jtydhr88
|
||||
/src/services/load3dService.ts @jtydhr88
|
||||
/src/extensions/core/load3d.ts @jtydhr88 @Comfy-org/comfy_frontend_devs
|
||||
/src/components/load3d/ @jtydhr88 @Comfy-org/comfy_frontend_devs
|
||||
|
||||
# Manager
|
||||
/src/workbench/extensions/manager/ @viva-jinyi @christian-byrne @ltdrdata
|
||||
/src/workbench/extensions/manager/ @viva-jinyi @christian-byrne @ltdrdata @Comfy-org/comfy_frontend_devs
|
||||
|
||||
# Model-to-node mappings (cloud team)
|
||||
/src/platform/assets/mappings/ @deepme987
|
||||
# Translations
|
||||
/src/locales/ @Comfy-Org/comfy_maintainer @Comfy-org/comfy_frontend_devs
|
||||
|
||||
# LLM Instructions (blank on purpose)
|
||||
.claude/
|
||||
|
||||
@@ -62,37 +62,6 @@ python main.py --port 8188 --cpu
|
||||
- Run `pnpm dev:electron` to start the dev server with electron API mocked
|
||||
- Run `pnpm dev:cloud` to start the dev server against the cloud backend (instead of local ComfyUI server)
|
||||
|
||||
#### Testing with Cloud & Staging Environments
|
||||
|
||||
Some features — particularly **partner/API nodes** (e.g. BFL, OpenAI, Stability AI) — require a cloud backend for authentication and billing. Running these against a local ComfyUI instance will result in permission errors or logged-out states. There are two ways to connect to a cloud/staging backend:
|
||||
|
||||
**Option 1: Frontend — `pnpm dev:cloud`**
|
||||
|
||||
The simplest approach. This proxies all API requests to the test cloud environment:
|
||||
|
||||
```bash
|
||||
pnpm dev:cloud
|
||||
```
|
||||
|
||||
This sets `DEV_SERVER_COMFYUI_URL` to `https://testcloud.comfy.org/` automatically. You can also set this variable manually in your `.env` file to target a different environment:
|
||||
|
||||
```bash
|
||||
# .env
|
||||
DEV_SERVER_COMFYUI_URL=https://stagingcloud.comfy.org/
|
||||
```
|
||||
|
||||
Any `*.comfy.org` URL automatically enables cloud mode, which includes the GCS media proxy needed for viewing generated images and videos. See [.env_example](.env_example) for all available cloud URLs.
|
||||
|
||||
**Option 2: Backend — `--comfy-api-base`**
|
||||
|
||||
Alternatively, launch the ComfyUI backend pointed at the staging API:
|
||||
|
||||
```bash
|
||||
python main.py --comfy-api-base https://stagingapi.comfy.org --verbose
|
||||
```
|
||||
|
||||
Then run `pnpm dev` as usual. This keeps the frontend in local mode but routes backend API calls through staging.
|
||||
|
||||
#### Access dev server on touch devices
|
||||
|
||||
Enable remote access to the dev server by setting `VITE_REMOTE_DEV` in `.env` to `true`.
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
"scripts": {
|
||||
"lint": "nx run @comfyorg/desktop-ui:lint",
|
||||
"typecheck": "nx run @comfyorg/desktop-ui:typecheck",
|
||||
"test:unit": "vitest run --config vitest.config.mts",
|
||||
"storybook": "storybook dev -p 6007",
|
||||
"build-storybook": "storybook build -o dist/storybook"
|
||||
},
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import StartupDisplay from '@/components/common/StartupDisplay.vue'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: { g: { logoAlt: 'ComfyUI' } } }
|
||||
})
|
||||
|
||||
const ProgressBarStub = {
|
||||
props: ['mode', 'value', 'showValue'],
|
||||
template:
|
||||
'<div data-testid="progress-bar" :data-mode="mode" :data-value="value" />'
|
||||
}
|
||||
|
||||
function renderDisplay(
|
||||
props: {
|
||||
progressPercentage?: number
|
||||
title?: string
|
||||
statusText?: string
|
||||
hideProgress?: boolean
|
||||
fullScreen?: boolean
|
||||
} = {}
|
||||
) {
|
||||
return render(StartupDisplay, {
|
||||
props,
|
||||
global: {
|
||||
plugins: [[PrimeVue, { unstyled: true }], i18n],
|
||||
stubs: { ProgressBar: ProgressBarStub }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('StartupDisplay', () => {
|
||||
describe('progressMode', () => {
|
||||
it('renders indeterminate mode when progressPercentage is undefined', () => {
|
||||
renderDisplay()
|
||||
expect(screen.getByTestId('progress-bar').dataset.mode).toBe(
|
||||
'indeterminate'
|
||||
)
|
||||
})
|
||||
|
||||
it('renders determinate mode when progressPercentage is provided', () => {
|
||||
renderDisplay({ progressPercentage: 50 })
|
||||
expect(screen.getByTestId('progress-bar').dataset.mode).toBe(
|
||||
'determinate'
|
||||
)
|
||||
})
|
||||
|
||||
it('passes progressPercentage as value to the progress bar', () => {
|
||||
renderDisplay({ progressPercentage: 75 })
|
||||
expect(screen.getByTestId('progress-bar').dataset.value).toBe('75')
|
||||
})
|
||||
})
|
||||
|
||||
describe('hideProgress', () => {
|
||||
it('hides the progress bar when hideProgress is true', () => {
|
||||
renderDisplay({ hideProgress: true })
|
||||
expect(screen.queryByTestId('progress-bar')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows the progress bar by default', () => {
|
||||
renderDisplay()
|
||||
expect(screen.getByTestId('progress-bar')).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('title', () => {
|
||||
it('renders the title text when provided', () => {
|
||||
renderDisplay({ title: 'Loading...' })
|
||||
expect(screen.getByText('Loading...')).toBeDefined()
|
||||
})
|
||||
|
||||
it('does not render h1 when title is not provided', () => {
|
||||
renderDisplay()
|
||||
expect(screen.queryByRole('heading', { level: 1 })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('statusText', () => {
|
||||
it('renders statusText with data-testid attribute', () => {
|
||||
renderDisplay({ statusText: 'Starting server' })
|
||||
expect(screen.getByTestId('startup-status-text').textContent).toContain(
|
||||
'Starting server'
|
||||
)
|
||||
})
|
||||
|
||||
it('does not render statusText element when not provided', () => {
|
||||
renderDisplay()
|
||||
expect(screen.queryByTestId('startup-status-text')).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,208 +0,0 @@
|
||||
import { render, screen, waitFor } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest'
|
||||
|
||||
vi.mock('@comfyorg/shared-frontend-utils/networkUtil', () => ({
|
||||
checkUrlReachable: vi.fn()
|
||||
}))
|
||||
|
||||
import { checkUrlReachable } from '@comfyorg/shared-frontend-utils/networkUtil'
|
||||
import UrlInput from '@/components/common/UrlInput.vue'
|
||||
import { ValidationState } from '@/utils/validationUtil'
|
||||
|
||||
const InputTextStub = {
|
||||
props: ['modelValue', 'invalid'],
|
||||
emits: ['update:modelValue', 'blur'],
|
||||
template: `<input
|
||||
data-testid="url-input"
|
||||
:value="modelValue"
|
||||
:data-invalid="invalid"
|
||||
@input="$emit('update:modelValue', $event.target.value)"
|
||||
@blur="$emit('blur')"
|
||||
/>`
|
||||
}
|
||||
|
||||
const InputIconStub = {
|
||||
template: '<span data-testid="input-icon" />'
|
||||
}
|
||||
|
||||
const IconFieldStub = {
|
||||
template: '<div><slot /></div>'
|
||||
}
|
||||
|
||||
function renderUrlInput(
|
||||
modelValue = '',
|
||||
validateUrlFn?: (url: string) => Promise<boolean>
|
||||
) {
|
||||
return render(UrlInput, {
|
||||
props: { modelValue, ...(validateUrlFn ? { validateUrlFn } : {}) },
|
||||
global: {
|
||||
plugins: [[PrimeVue, { unstyled: true }]],
|
||||
stubs: {
|
||||
InputText: InputTextStub,
|
||||
InputIcon: InputIconStub,
|
||||
IconField: IconFieldStub
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('UrlInput', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
})
|
||||
|
||||
describe('initial validation on mount', () => {
|
||||
it('stays IDLE when modelValue is empty on mount', async () => {
|
||||
renderUrlInput('')
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('url-input').dataset.invalid).toBe('false')
|
||||
})
|
||||
})
|
||||
|
||||
it('sets VALID state when modelValue is a reachable URL on mount', async () => {
|
||||
vi.mocked(checkUrlReachable).mockResolvedValue(true)
|
||||
renderUrlInput('https://example.com')
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('url-input').dataset.invalid).toBe('false')
|
||||
})
|
||||
})
|
||||
|
||||
it('sets INVALID state when URL is not reachable on mount', async () => {
|
||||
vi.mocked(checkUrlReachable).mockResolvedValue(false)
|
||||
renderUrlInput('https://unreachable.example')
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('url-input').dataset.invalid).toBe('true')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('input handling', () => {
|
||||
it('resets validation state to IDLE on user input', async () => {
|
||||
vi.mocked(checkUrlReachable).mockResolvedValue(false)
|
||||
renderUrlInput('https://bad.example')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('url-input').dataset.invalid).toBe('true')
|
||||
})
|
||||
|
||||
const user = userEvent.setup()
|
||||
await user.type(screen.getByTestId('url-input'), 'x')
|
||||
expect(screen.getByTestId('url-input').dataset.invalid).toBe('false')
|
||||
})
|
||||
|
||||
it('strips whitespace from typed input', async () => {
|
||||
const onUpdate = vi.fn()
|
||||
render(UrlInput, {
|
||||
props: {
|
||||
modelValue: '',
|
||||
'onUpdate:modelValue': onUpdate
|
||||
},
|
||||
global: {
|
||||
plugins: [[PrimeVue, { unstyled: true }]],
|
||||
stubs: {
|
||||
InputText: InputTextStub,
|
||||
InputIcon: InputIconStub,
|
||||
IconField: IconFieldStub
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const user = userEvent.setup()
|
||||
const input = screen.getByTestId('url-input')
|
||||
await user.type(input, 'htt ps')
|
||||
expect((input as HTMLInputElement).value).not.toContain(' ')
|
||||
})
|
||||
})
|
||||
|
||||
describe('blur handling', () => {
|
||||
it('emits update:modelValue on blur', async () => {
|
||||
const onUpdate = vi.fn()
|
||||
render(UrlInput, {
|
||||
props: {
|
||||
modelValue: 'https://example.com',
|
||||
'onUpdate:modelValue': onUpdate
|
||||
},
|
||||
global: {
|
||||
plugins: [[PrimeVue, { unstyled: true }]],
|
||||
stubs: {
|
||||
InputText: InputTextStub,
|
||||
InputIcon: InputIconStub,
|
||||
IconField: IconFieldStub
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const user = userEvent.setup()
|
||||
await user.click(screen.getByTestId('url-input'))
|
||||
await user.tab()
|
||||
|
||||
expect(onUpdate).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('normalizes URL on blur', async () => {
|
||||
const onUpdate = vi.fn()
|
||||
render(UrlInput, {
|
||||
props: {
|
||||
modelValue: 'https://example.com',
|
||||
'onUpdate:modelValue': onUpdate
|
||||
},
|
||||
global: {
|
||||
plugins: [[PrimeVue, { unstyled: true }]],
|
||||
stubs: {
|
||||
InputText: InputTextStub,
|
||||
InputIcon: InputIconStub,
|
||||
IconField: IconFieldStub
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const user = userEvent.setup()
|
||||
await user.click(screen.getByTestId('url-input'))
|
||||
await user.tab()
|
||||
|
||||
const emittedUrl = onUpdate.mock.calls[0]?.[0]
|
||||
expect(emittedUrl).toBe('https://example.com/')
|
||||
})
|
||||
})
|
||||
|
||||
describe('custom validateUrlFn', () => {
|
||||
it('uses custom validateUrlFn when provided', async () => {
|
||||
const customValidator = vi.fn().mockResolvedValue(true)
|
||||
renderUrlInput('https://custom.example', customValidator)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(customValidator).toHaveBeenCalledWith('https://custom.example')
|
||||
})
|
||||
|
||||
expect(checkUrlReachable).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('state-change emission', () => {
|
||||
it('emits state-change when validation state changes', async () => {
|
||||
const onStateChange = vi.fn()
|
||||
vi.mocked(checkUrlReachable).mockResolvedValue(true)
|
||||
|
||||
render(UrlInput, {
|
||||
props: {
|
||||
modelValue: 'https://example.com',
|
||||
'onState-change': onStateChange
|
||||
},
|
||||
global: {
|
||||
plugins: [[PrimeVue, { unstyled: true }]],
|
||||
stubs: {
|
||||
InputText: InputTextStub,
|
||||
InputIcon: InputIconStub,
|
||||
IconField: IconFieldStub
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onStateChange).toHaveBeenCalledWith(ValidationState.VALID)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,112 +0,0 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
vi.mock('@/utils/envUtil', () => ({
|
||||
electronAPI: vi.fn(() => ({
|
||||
getPlatform: vi.fn().mockReturnValue('win32')
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n', () => ({
|
||||
t: (key: string) => key,
|
||||
te: () => false,
|
||||
st: (_key: string, fallback: string) => fallback
|
||||
}))
|
||||
|
||||
import type { TorchDeviceType } from '@comfyorg/comfyui-electron-types'
|
||||
import GpuPicker from '@/components/install/GpuPicker.vue'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
missingWarn: false,
|
||||
fallbackWarn: false,
|
||||
messages: { en: {} }
|
||||
})
|
||||
|
||||
const HardwareOptionStub = {
|
||||
props: ['imagePath', 'placeholderText', 'subtitle', 'selected'],
|
||||
emits: ['click'],
|
||||
template:
|
||||
'<button :data-testid="placeholderText" :data-selected="selected" @click="$emit(\'click\')" >{{ placeholderText }}</button>'
|
||||
}
|
||||
|
||||
function renderPicker(device: TorchDeviceType | null = null) {
|
||||
return render(GpuPicker, {
|
||||
props: { device },
|
||||
global: {
|
||||
plugins: [[PrimeVue, { unstyled: true }], i18n],
|
||||
stubs: {
|
||||
HardwareOption: HardwareOptionStub,
|
||||
Tag: {
|
||||
props: ['value'],
|
||||
template: '<span data-testid="recommended-tag">{{ value }}</span>'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('GpuPicker', () => {
|
||||
describe('recommended badge', () => {
|
||||
it('shows recommended badge for nvidia', () => {
|
||||
renderPicker('nvidia')
|
||||
expect(screen.getByTestId('recommended-tag')).toBeVisible()
|
||||
})
|
||||
|
||||
it('shows recommended badge for amd', () => {
|
||||
renderPicker('amd')
|
||||
expect(screen.getByTestId('recommended-tag')).toBeVisible()
|
||||
})
|
||||
|
||||
it('does not show recommended badge for cpu', () => {
|
||||
renderPicker('cpu')
|
||||
expect(screen.getByTestId('recommended-tag')).not.toBeVisible()
|
||||
})
|
||||
|
||||
it('does not show recommended badge for unsupported', () => {
|
||||
renderPicker('unsupported')
|
||||
expect(screen.getByTestId('recommended-tag')).not.toBeVisible()
|
||||
})
|
||||
|
||||
it('does not show recommended badge when no device is selected', () => {
|
||||
renderPicker(null)
|
||||
expect(screen.getByTestId('recommended-tag')).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
describe('selection state', () => {
|
||||
it('marks nvidia as selected when device is nvidia', () => {
|
||||
renderPicker('nvidia')
|
||||
expect(screen.getByTestId('NVIDIA').dataset.selected).toBe('true')
|
||||
})
|
||||
|
||||
it('marks cpu as selected when device is cpu', () => {
|
||||
renderPicker('cpu')
|
||||
expect(screen.getByTestId('CPU').dataset.selected).toBe('true')
|
||||
})
|
||||
|
||||
it('marks unsupported as selected when device is unsupported', () => {
|
||||
renderPicker('unsupported')
|
||||
expect(screen.getByTestId('Manual Install').dataset.selected).toBe('true')
|
||||
})
|
||||
|
||||
it('no option is selected when device is null', () => {
|
||||
renderPicker(null)
|
||||
expect(screen.getByTestId('CPU').dataset.selected).toBe('false')
|
||||
expect(screen.getByTestId('NVIDIA').dataset.selected).toBe('false')
|
||||
})
|
||||
})
|
||||
|
||||
describe('gpu options on non-darwin platform', () => {
|
||||
it('shows NVIDIA, AMD, CPU, and Manual Install options', () => {
|
||||
renderPicker(null)
|
||||
expect(screen.getByTestId('NVIDIA')).toBeDefined()
|
||||
expect(screen.getByTestId('AMD')).toBeDefined()
|
||||
expect(screen.getByTestId('CPU')).toBeDefined()
|
||||
expect(screen.getByTestId('Manual Install')).toBeDefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,223 +0,0 @@
|
||||
import { render, screen, waitFor } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
const mockValidateComfyUISource = vi.fn()
|
||||
const mockShowDirectoryPicker = vi.fn()
|
||||
|
||||
vi.mock('@/utils/envUtil', () => ({
|
||||
electronAPI: vi.fn(() => ({
|
||||
validateComfyUISource: mockValidateComfyUISource,
|
||||
showDirectoryPicker: mockShowDirectoryPicker
|
||||
}))
|
||||
}))
|
||||
|
||||
import MigrationPicker from '@/components/install/MigrationPicker.vue'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
install: {
|
||||
migrationSourcePathDescription: 'Source path description',
|
||||
migrationOptional: 'Migration is optional',
|
||||
selectItemsToMigrate: 'Select items to migrate',
|
||||
pathValidationFailed: 'Validation failed',
|
||||
failedToSelectDirectory: 'Failed to select directory',
|
||||
locationPicker: {
|
||||
migrationPathPlaceholder: 'Enter path'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const InputTextStub = {
|
||||
props: ['modelValue', 'invalid'],
|
||||
emits: ['update:modelValue'],
|
||||
template: `<input
|
||||
data-testid="source-input"
|
||||
:value="modelValue"
|
||||
@input="$emit('update:modelValue', $event.target.value)"
|
||||
/>`
|
||||
}
|
||||
|
||||
const CheckboxStub = {
|
||||
props: ['modelValue', 'inputId', 'binary'],
|
||||
emits: ['update:modelValue', 'click'],
|
||||
template: `<input
|
||||
type="checkbox"
|
||||
:data-testid="'checkbox-' + inputId"
|
||||
:checked="modelValue"
|
||||
@change="$emit('update:modelValue', $event.target.checked)"
|
||||
@click.stop="$emit('click')"
|
||||
/>`
|
||||
}
|
||||
|
||||
function renderPicker(sourcePath = '', migrationItemIds: string[] = []) {
|
||||
return render(MigrationPicker, {
|
||||
props: { sourcePath, migrationItemIds },
|
||||
global: {
|
||||
plugins: [[PrimeVue, { unstyled: true }], i18n],
|
||||
stubs: {
|
||||
InputText: InputTextStub,
|
||||
Checkbox: CheckboxStub,
|
||||
Button: { template: '<button data-testid="browse-btn" />' },
|
||||
Message: {
|
||||
props: ['severity'],
|
||||
template: '<div data-testid="error-msg"><slot /></div>'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('MigrationPicker', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
})
|
||||
|
||||
describe('isValidSource', () => {
|
||||
it('hides migration options when source path is empty', () => {
|
||||
renderPicker('')
|
||||
expect(screen.queryByText('Select items to migrate')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows migration options when source path is valid', async () => {
|
||||
mockValidateComfyUISource.mockResolvedValue({ isValid: true })
|
||||
const { rerender } = renderPicker('')
|
||||
|
||||
await rerender({ sourcePath: '/valid/path' })
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Select items to migrate')).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows optional message when no valid source', () => {
|
||||
renderPicker('')
|
||||
expect(screen.getByText('Migration is optional')).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateSource', () => {
|
||||
it('clears error when source path becomes empty', async () => {
|
||||
mockValidateComfyUISource.mockResolvedValue({
|
||||
isValid: false,
|
||||
error: 'Not found'
|
||||
})
|
||||
|
||||
const user = userEvent.setup()
|
||||
renderPicker()
|
||||
|
||||
await user.type(screen.getByTestId('source-input'), '/bad/path')
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('error-msg')).toBeDefined()
|
||||
})
|
||||
|
||||
await user.clear(screen.getByTestId('source-input'))
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('error-msg')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows error message when validation fails', async () => {
|
||||
mockValidateComfyUISource.mockResolvedValue({
|
||||
isValid: false,
|
||||
error: 'Path not found'
|
||||
})
|
||||
|
||||
const user = userEvent.setup()
|
||||
renderPicker()
|
||||
|
||||
await user.type(screen.getByTestId('source-input'), '/bad/path')
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('error-msg')).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows no error when validation passes', async () => {
|
||||
mockValidateComfyUISource.mockResolvedValue({ isValid: true })
|
||||
|
||||
const user = userEvent.setup()
|
||||
renderPicker()
|
||||
|
||||
await user.type(screen.getByTestId('source-input'), '/valid/path')
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('error-msg')).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('migrationItemIds watchEffect', () => {
|
||||
it('emits all item IDs by default (all items start selected)', async () => {
|
||||
const onUpdate = vi.fn()
|
||||
render(MigrationPicker, {
|
||||
props: {
|
||||
sourcePath: '',
|
||||
migrationItemIds: [],
|
||||
'onUpdate:migrationItemIds': onUpdate
|
||||
},
|
||||
global: {
|
||||
plugins: [[PrimeVue, { unstyled: true }], i18n],
|
||||
stubs: {
|
||||
InputText: InputTextStub,
|
||||
Checkbox: CheckboxStub,
|
||||
Button: { template: '<button />' },
|
||||
Message: { template: '<div />' }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onUpdate).toHaveBeenCalled()
|
||||
const emittedIds = onUpdate.mock.calls[0][0]
|
||||
expect(Array.isArray(emittedIds)).toBe(true)
|
||||
expect(emittedIds.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('browse path', () => {
|
||||
it('opens directory picker on browse click', async () => {
|
||||
mockShowDirectoryPicker.mockResolvedValue(null)
|
||||
renderPicker()
|
||||
|
||||
const user = userEvent.setup()
|
||||
await user.click(screen.getByTestId('browse-btn'))
|
||||
|
||||
expect(mockShowDirectoryPicker).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('updates source path when directory is selected', async () => {
|
||||
mockShowDirectoryPicker.mockResolvedValue('/selected/path')
|
||||
mockValidateComfyUISource.mockResolvedValue({ isValid: true })
|
||||
|
||||
const onUpdate = vi.fn()
|
||||
render(MigrationPicker, {
|
||||
props: {
|
||||
sourcePath: '',
|
||||
'onUpdate:sourcePath': onUpdate
|
||||
},
|
||||
global: {
|
||||
plugins: [[PrimeVue, { unstyled: true }], i18n],
|
||||
stubs: {
|
||||
InputText: InputTextStub,
|
||||
Checkbox: CheckboxStub,
|
||||
Button: { template: '<button data-testid="browse-btn" />' },
|
||||
Message: { template: '<div />' }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const user = userEvent.setup()
|
||||
await user.click(screen.getByTestId('browse-btn'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onUpdate).toHaveBeenCalledWith('/selected/path')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,85 +0,0 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent } from 'vue'
|
||||
|
||||
vi.mock('@/i18n', () => ({
|
||||
t: (key: string) => key
|
||||
}))
|
||||
|
||||
import StatusTag from '@/components/maintenance/StatusTag.vue'
|
||||
|
||||
const TagStub = defineComponent({
|
||||
name: 'Tag',
|
||||
props: {
|
||||
icon: String,
|
||||
severity: String,
|
||||
value: String
|
||||
},
|
||||
template: `<span data-testid="tag" :data-icon="icon" :data-severity="severity" :data-value="value">{{ value }}</span>`
|
||||
})
|
||||
|
||||
function renderStatusTag(props: { error: boolean; refreshing?: boolean }) {
|
||||
return render(StatusTag, {
|
||||
props,
|
||||
global: {
|
||||
plugins: [[PrimeVue, { unstyled: true }]],
|
||||
stubs: { Tag: TagStub }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('StatusTag', () => {
|
||||
describe('refreshing state', () => {
|
||||
it('shows info severity when refreshing', () => {
|
||||
renderStatusTag({ error: false, refreshing: true })
|
||||
expect(screen.getByTestId('tag').dataset.severity).toBe('info')
|
||||
})
|
||||
|
||||
it('shows refreshing translation key when refreshing', () => {
|
||||
renderStatusTag({ error: false, refreshing: true })
|
||||
expect(screen.getByTestId('tag').dataset.value).toBe(
|
||||
'maintenance.refreshing'
|
||||
)
|
||||
})
|
||||
|
||||
it('shows question icon when refreshing', () => {
|
||||
renderStatusTag({ error: false, refreshing: true })
|
||||
expect(screen.getByTestId('tag').dataset.icon).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('error state', () => {
|
||||
it('shows danger severity when error is true', () => {
|
||||
renderStatusTag({ error: true })
|
||||
expect(screen.getByTestId('tag').dataset.severity).toBe('danger')
|
||||
})
|
||||
|
||||
it('shows error translation key when error is true', () => {
|
||||
renderStatusTag({ error: true })
|
||||
expect(screen.getByTestId('tag').dataset.value).toBe('g.error')
|
||||
})
|
||||
})
|
||||
|
||||
describe('OK state', () => {
|
||||
it('shows success severity when not refreshing and not error', () => {
|
||||
renderStatusTag({ error: false })
|
||||
expect(screen.getByTestId('tag').dataset.severity).toBe('success')
|
||||
})
|
||||
|
||||
it('shows OK translation key when not refreshing and not error', () => {
|
||||
renderStatusTag({ error: false })
|
||||
expect(screen.getByTestId('tag').dataset.value).toBe('maintenance.OK')
|
||||
})
|
||||
})
|
||||
|
||||
describe('precedence', () => {
|
||||
it('shows refreshing state when both refreshing and error are true', () => {
|
||||
renderStatusTag({ error: true, refreshing: true })
|
||||
expect(screen.getByTestId('tag').dataset.severity).toBe('info')
|
||||
expect(screen.getByTestId('tag').dataset.value).toBe(
|
||||
'maintenance.refreshing'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,89 +0,0 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
vi.mock('@/utils/envUtil', () => ({
|
||||
electronAPI: vi.fn(() => ({
|
||||
Validation: { validateInstallation: vi.fn() }
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('@/constants/desktopMaintenanceTasks', () => ({
|
||||
DESKTOP_MAINTENANCE_TASKS: []
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/refUtil', () => ({
|
||||
useMinLoadingDurationRef: (source: { value: boolean }) => source
|
||||
}))
|
||||
|
||||
const mockGetRunner = vi.fn()
|
||||
vi.mock('@/stores/maintenanceTaskStore', () => ({
|
||||
useMaintenanceTaskStore: vi.fn(() => ({
|
||||
getRunner: mockGetRunner
|
||||
}))
|
||||
}))
|
||||
|
||||
import type { MaintenanceTask } from '@/types/desktop/maintenanceTypes'
|
||||
import TaskCard from '@/components/maintenance/TaskCard.vue'
|
||||
|
||||
const baseTask: MaintenanceTask = {
|
||||
id: 'testTask',
|
||||
name: 'Test Task',
|
||||
shortDescription: 'Short description',
|
||||
errorDescription: 'Error occurred',
|
||||
execute: vi.fn().mockResolvedValue(true)
|
||||
}
|
||||
|
||||
const cardStubs = {
|
||||
Card: {
|
||||
template: '<div data-testid="card"><slot name="content"></slot></div>'
|
||||
},
|
||||
Button: { template: '<button />' }
|
||||
}
|
||||
|
||||
function renderCard(
|
||||
state: 'OK' | 'error' | 'warning' | 'skipped',
|
||||
task: MaintenanceTask = baseTask
|
||||
) {
|
||||
mockGetRunner.mockReturnValue({
|
||||
state,
|
||||
executing: false,
|
||||
refreshing: false,
|
||||
resolved: false
|
||||
})
|
||||
|
||||
return render(TaskCard, {
|
||||
props: { task },
|
||||
global: {
|
||||
plugins: [[PrimeVue, { unstyled: true }]],
|
||||
stubs: cardStubs
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('TaskCard', () => {
|
||||
describe('description computed', () => {
|
||||
it('shows errorDescription when task state is error', () => {
|
||||
renderCard('error')
|
||||
expect(screen.getByText('Error occurred')).toBeDefined()
|
||||
})
|
||||
|
||||
it('shows shortDescription when task state is OK', () => {
|
||||
renderCard('OK')
|
||||
expect(screen.getByText('Short description')).toBeDefined()
|
||||
})
|
||||
|
||||
it('shows shortDescription when task state is warning', () => {
|
||||
renderCard('warning')
|
||||
expect(screen.getByText('Short description')).toBeDefined()
|
||||
})
|
||||
|
||||
it('falls back to shortDescription when errorDescription is absent and state is error', () => {
|
||||
const taskWithoutErrorDesc: MaintenanceTask = {
|
||||
...baseTask,
|
||||
errorDescription: undefined
|
||||
}
|
||||
renderCard('error', taskWithoutErrorDesc)
|
||||
expect(screen.getByText('Short description')).toBeDefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,97 +0,0 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@/utils/envUtil', () => ({
|
||||
electronAPI: vi.fn(() => ({
|
||||
Validation: { validateInstallation: vi.fn() }
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('@/constants/desktopMaintenanceTasks', () => ({
|
||||
DESKTOP_MAINTENANCE_TASKS: []
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/refUtil', () => ({
|
||||
useMinLoadingDurationRef: (source: { value: boolean }) => source
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n', () => ({
|
||||
t: (key: string) => key
|
||||
}))
|
||||
|
||||
const mockGetRunner = vi.fn()
|
||||
vi.mock('@/stores/maintenanceTaskStore', () => ({
|
||||
useMaintenanceTaskStore: vi.fn(() => ({
|
||||
getRunner: mockGetRunner
|
||||
}))
|
||||
}))
|
||||
|
||||
import type { MaintenanceTask } from '@/types/desktop/maintenanceTypes'
|
||||
import TaskListItem from '@/components/maintenance/TaskListItem.vue'
|
||||
|
||||
const baseTask: MaintenanceTask = {
|
||||
id: 'testTask',
|
||||
name: 'Test Task',
|
||||
button: { text: 'Fix', icon: 'pi pi-check' },
|
||||
execute: vi.fn().mockResolvedValue(true)
|
||||
}
|
||||
|
||||
const ButtonStub = {
|
||||
props: ['severity', 'label', 'icon', 'loading'],
|
||||
template:
|
||||
'<button :data-severity="severity" :data-label="label" :data-testid="label ? \'action-button\' : \'icon-button\'" />'
|
||||
}
|
||||
|
||||
function renderItem(state: 'OK' | 'error' | 'warning' | 'skipped') {
|
||||
mockGetRunner.mockReturnValue({
|
||||
state,
|
||||
executing: false,
|
||||
refreshing: false,
|
||||
resolved: false
|
||||
})
|
||||
|
||||
return render(TaskListItem, {
|
||||
props: { task: baseTask },
|
||||
global: {
|
||||
plugins: [[PrimeVue, { unstyled: true }]],
|
||||
stubs: {
|
||||
Button: ButtonStub,
|
||||
Popover: { template: '<div />' },
|
||||
TaskListStatusIcon: { template: '<span />' }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('TaskListItem', () => {
|
||||
describe('severity computed', () => {
|
||||
it('uses primary severity for error state', () => {
|
||||
renderItem('error')
|
||||
expect(screen.getByTestId('action-button').dataset.severity).toBe(
|
||||
'primary'
|
||||
)
|
||||
})
|
||||
|
||||
it('uses primary severity for warning state', () => {
|
||||
renderItem('warning')
|
||||
expect(screen.getByTestId('action-button').dataset.severity).toBe(
|
||||
'primary'
|
||||
)
|
||||
})
|
||||
|
||||
it('uses secondary severity for OK state', () => {
|
||||
renderItem('OK')
|
||||
expect(screen.getByTestId('action-button').dataset.severity).toBe(
|
||||
'secondary'
|
||||
)
|
||||
})
|
||||
|
||||
it('uses secondary severity for skipped state', () => {
|
||||
renderItem('skipped')
|
||||
expect(screen.getByTestId('action-button').dataset.severity).toBe(
|
||||
'secondary'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,44 +0,0 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@/i18n', () => ({
|
||||
t: (key: string) => key
|
||||
}))
|
||||
|
||||
import TaskListStatusIcon from '@/components/maintenance/TaskListStatusIcon.vue'
|
||||
|
||||
type TaskState = 'warning' | 'error' | 'resolved' | 'OK' | 'skipped' | undefined
|
||||
|
||||
function renderIcon(state: TaskState, loading?: boolean) {
|
||||
return render(TaskListStatusIcon, {
|
||||
props: { state, loading },
|
||||
global: {
|
||||
plugins: [[PrimeVue, { unstyled: true }]],
|
||||
stubs: {
|
||||
ProgressSpinner: {
|
||||
template: '<div data-testid="spinner" />'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('TaskListStatusIcon', () => {
|
||||
describe('loading / no state', () => {
|
||||
it('renders spinner when state is undefined', () => {
|
||||
renderIcon(undefined)
|
||||
expect(screen.getByTestId('spinner')).toBeDefined()
|
||||
})
|
||||
|
||||
it('renders spinner when loading is true', () => {
|
||||
renderIcon('OK', true)
|
||||
expect(screen.getByTestId('spinner')).toBeDefined()
|
||||
})
|
||||
|
||||
it('hides spinner when state is defined and not loading', () => {
|
||||
renderIcon('OK', false)
|
||||
expect(screen.queryByTestId('spinner')).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,124 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
|
||||
const { mockTerminal, MockTerminal, mockFitAddon, MockFitAddon } = vi.hoisted(
|
||||
() => {
|
||||
const mockTerminal = {
|
||||
loadAddon: vi.fn(),
|
||||
attachCustomKeyEventHandler: vi.fn(),
|
||||
open: vi.fn(),
|
||||
dispose: vi.fn(),
|
||||
hasSelection: vi.fn<[], boolean>(),
|
||||
resize: vi.fn(),
|
||||
cols: 80,
|
||||
rows: 24
|
||||
}
|
||||
const MockTerminal = vi.fn(function () {
|
||||
return mockTerminal
|
||||
})
|
||||
|
||||
const mockFitAddon = {
|
||||
proposeDimensions: vi.fn().mockReturnValue({ cols: 80, rows: 24 })
|
||||
}
|
||||
const MockFitAddon = vi.fn(function () {
|
||||
return mockFitAddon
|
||||
})
|
||||
|
||||
return { mockTerminal, MockTerminal, mockFitAddon, MockFitAddon }
|
||||
}
|
||||
)
|
||||
|
||||
vi.mock('@xterm/xterm', () => ({ Terminal: MockTerminal }))
|
||||
vi.mock('@xterm/addon-fit', () => ({ FitAddon: MockFitAddon }))
|
||||
vi.mock('@xterm/xterm/css/xterm.css', () => ({}))
|
||||
|
||||
import { withSetup } from '@/test/withSetup'
|
||||
import { useTerminal } from '@/composables/bottomPanelTabs/useTerminal'
|
||||
|
||||
function getKeyHandler(): (event: KeyboardEvent) => boolean {
|
||||
return mockTerminal.attachCustomKeyEventHandler.mock.calls[0][0]
|
||||
}
|
||||
|
||||
describe('useTerminal key event handler', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockTerminal.hasSelection.mockReturnValue(false)
|
||||
|
||||
const element = ref<HTMLElement | undefined>(undefined)
|
||||
withSetup(() => useTerminal(element))
|
||||
})
|
||||
|
||||
it('allows browser to handle copy when text is selected (Ctrl+C)', () => {
|
||||
mockTerminal.hasSelection.mockReturnValue(true)
|
||||
const event = {
|
||||
type: 'keydown',
|
||||
ctrlKey: true,
|
||||
metaKey: false,
|
||||
key: 'c'
|
||||
} as KeyboardEvent
|
||||
expect(getKeyHandler()(event)).toBe(false)
|
||||
})
|
||||
|
||||
it('allows browser to handle copy when text is selected (Meta+C)', () => {
|
||||
mockTerminal.hasSelection.mockReturnValue(true)
|
||||
const event = {
|
||||
type: 'keydown',
|
||||
ctrlKey: false,
|
||||
metaKey: true,
|
||||
key: 'c'
|
||||
} as KeyboardEvent
|
||||
expect(getKeyHandler()(event)).toBe(false)
|
||||
})
|
||||
|
||||
it('does not pass copy to browser when no text is selected', () => {
|
||||
mockTerminal.hasSelection.mockReturnValue(false)
|
||||
const event = {
|
||||
type: 'keydown',
|
||||
ctrlKey: true,
|
||||
metaKey: false,
|
||||
key: 'c'
|
||||
} as KeyboardEvent
|
||||
expect(getKeyHandler()(event)).toBe(true)
|
||||
})
|
||||
|
||||
it('allows browser to handle paste (Ctrl+V)', () => {
|
||||
const event = {
|
||||
type: 'keydown',
|
||||
ctrlKey: true,
|
||||
metaKey: false,
|
||||
key: 'v'
|
||||
} as KeyboardEvent
|
||||
expect(getKeyHandler()(event)).toBe(false)
|
||||
})
|
||||
|
||||
it('allows browser to handle paste (Meta+V)', () => {
|
||||
const event = {
|
||||
type: 'keydown',
|
||||
ctrlKey: false,
|
||||
metaKey: true,
|
||||
key: 'v'
|
||||
} as KeyboardEvent
|
||||
expect(getKeyHandler()(event)).toBe(false)
|
||||
})
|
||||
|
||||
it('does not intercept non-keydown events', () => {
|
||||
mockTerminal.hasSelection.mockReturnValue(true)
|
||||
const event = {
|
||||
type: 'keyup',
|
||||
ctrlKey: true,
|
||||
metaKey: false,
|
||||
key: 'c'
|
||||
} as KeyboardEvent
|
||||
expect(getKeyHandler()(event)).toBe(true)
|
||||
})
|
||||
|
||||
it('passes through unrelated key combinations', () => {
|
||||
const event = {
|
||||
type: 'keydown',
|
||||
ctrlKey: false,
|
||||
metaKey: false,
|
||||
key: 'Enter'
|
||||
} as KeyboardEvent
|
||||
expect(getKeyHandler()(event)).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -1,48 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { mockSerialize, MockSerializeAddon } = vi.hoisted(() => {
|
||||
const mockSerialize = vi.fn<[], string>()
|
||||
const MockSerializeAddon = vi.fn(function () {
|
||||
return { serialize: mockSerialize }
|
||||
})
|
||||
return { mockSerialize, MockSerializeAddon }
|
||||
})
|
||||
|
||||
vi.mock('@xterm/xterm', () => ({
|
||||
Terminal: vi.fn(function () {
|
||||
return { loadAddon: vi.fn(), dispose: vi.fn(), write: vi.fn() }
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@xterm/addon-serialize', () => ({
|
||||
SerializeAddon: MockSerializeAddon
|
||||
}))
|
||||
|
||||
import type { Terminal } from '@xterm/xterm'
|
||||
import { withSetup } from '@/test/withSetup'
|
||||
import { useTerminalBuffer } from '@/composables/bottomPanelTabs/useTerminalBuffer'
|
||||
|
||||
describe('useTerminalBuffer', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockSerialize.mockReturnValue('')
|
||||
})
|
||||
|
||||
describe('copyTo', () => {
|
||||
it('writes serialized buffer content to the destination terminal', () => {
|
||||
mockSerialize.mockReturnValue('hello world')
|
||||
const { copyTo } = withSetup(() => useTerminalBuffer())
|
||||
const mockWrite = vi.fn()
|
||||
copyTo({ write: mockWrite } as Pick<Terminal, 'write'>)
|
||||
expect(mockWrite).toHaveBeenCalledWith('hello world')
|
||||
})
|
||||
|
||||
it('writes empty string when buffer is empty', () => {
|
||||
mockSerialize.mockReturnValue('')
|
||||
const { copyTo } = withSetup(() => useTerminalBuffer())
|
||||
const mockWrite = vi.fn()
|
||||
copyTo({ write: mockWrite } as Pick<Terminal, 'write'>)
|
||||
expect(mockWrite).toHaveBeenCalledWith('')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,50 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { DESKTOP_DIALOGS, getDialog } from '@/constants/desktopDialogs'
|
||||
|
||||
describe('getDialog', () => {
|
||||
it('returns the matching dialog for a valid ID', () => {
|
||||
const result = getDialog('reinstallVenv')
|
||||
expect(result.id).toBe('reinstallVenv')
|
||||
expect(result.title).toBe(DESKTOP_DIALOGS.reinstallVenv.title)
|
||||
expect(result.message).toBe(DESKTOP_DIALOGS.reinstallVenv.message)
|
||||
})
|
||||
|
||||
it('returns invalidDialog for an unknown string ID', () => {
|
||||
const result = getDialog('unknownDialog')
|
||||
expect(result.id).toBe('invalidDialog')
|
||||
})
|
||||
|
||||
it('returns invalidDialog when given an array of strings', () => {
|
||||
const result = getDialog(['reinstallVenv', 'other'])
|
||||
expect(result.id).toBe('invalidDialog')
|
||||
})
|
||||
|
||||
it('returns invalidDialog for empty string', () => {
|
||||
const result = getDialog('')
|
||||
expect(result.id).toBe('invalidDialog')
|
||||
})
|
||||
|
||||
it('returns a deep clone — mutations do not affect the original', () => {
|
||||
const result = getDialog('reinstallVenv')
|
||||
const originalFirstLabel = DESKTOP_DIALOGS.reinstallVenv.buttons[0].label
|
||||
result.buttons[0].label = 'Mutated'
|
||||
expect(DESKTOP_DIALOGS.reinstallVenv.buttons[0].label).toBe(
|
||||
originalFirstLabel
|
||||
)
|
||||
})
|
||||
|
||||
it('every button has a returnValue', () => {
|
||||
for (const id of Object.keys(DESKTOP_DIALOGS)) {
|
||||
const result = getDialog(id)
|
||||
for (const button of result.buttons) {
|
||||
expect(button.returnValue).toBeDefined()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('invalidDialog has a close/cancel button', () => {
|
||||
const result = getDialog('invalidDialog')
|
||||
expect(result.buttons.some((b) => b.action === 'cancel')).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -1,75 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { mockElectron } = vi.hoisted(() => ({
|
||||
mockElectron: {
|
||||
setBasePath: vi.fn(),
|
||||
reinstall: vi.fn<[], Promise<void>>().mockResolvedValue(undefined),
|
||||
uv: {
|
||||
installRequirements: vi.fn<[], Promise<void>>(),
|
||||
clearCache: vi.fn<[], Promise<void>>().mockResolvedValue(undefined),
|
||||
resetVenv: vi.fn<[], Promise<void>>().mockResolvedValue(undefined)
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/envUtil', () => ({
|
||||
electronAPI: vi.fn(() => mockElectron)
|
||||
}))
|
||||
|
||||
import { DESKTOP_MAINTENANCE_TASKS } from '@/constants/desktopMaintenanceTasks'
|
||||
|
||||
function findTask(id: string) {
|
||||
const task = DESKTOP_MAINTENANCE_TASKS.find((t) => t.id === id)
|
||||
if (!task) throw new Error(`Task not found: ${id}`)
|
||||
return task
|
||||
}
|
||||
|
||||
describe('desktopMaintenanceTasks', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
vi.spyOn(window, 'open').mockReturnValue(null)
|
||||
mockElectron.reinstall.mockResolvedValue(undefined)
|
||||
mockElectron.uv.clearCache.mockResolvedValue(undefined)
|
||||
mockElectron.uv.resetVenv.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
describe('pythonPackages', () => {
|
||||
it('returns true when installation succeeds', async () => {
|
||||
mockElectron.uv.installRequirements.mockResolvedValue(undefined)
|
||||
expect(await findTask('pythonPackages').execute()).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false when installation throws', async () => {
|
||||
mockElectron.uv.installRequirements.mockRejectedValue(
|
||||
new Error('install failed')
|
||||
)
|
||||
expect(await findTask('pythonPackages').execute()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('URL-opening tasks', () => {
|
||||
it('git execute opens the git download page', () => {
|
||||
findTask('git').execute()
|
||||
expect(window.open).toHaveBeenCalledWith(
|
||||
'https://git-scm.com/downloads/',
|
||||
'_blank'
|
||||
)
|
||||
})
|
||||
|
||||
it('uv execute opens the uv installation page', () => {
|
||||
findTask('uv').execute()
|
||||
expect(window.open).toHaveBeenCalledWith(
|
||||
'https://docs.astral.sh/uv/getting-started/installation/',
|
||||
'_blank'
|
||||
)
|
||||
})
|
||||
|
||||
it('vcRedist execute opens the VC++ redistributable download', () => {
|
||||
findTask('vcRedist').execute()
|
||||
expect(window.open).toHaveBeenCalledWith(
|
||||
'https://aka.ms/vs/17/release/vc_redist.x64.exe',
|
||||
'_blank'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,288 +0,0 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import type { InstallValidation } from '@comfyorg/comfyui-electron-types'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { mockElectron, testTasks } = vi.hoisted(() => {
|
||||
const terminalTaskExecute = vi.fn().mockResolvedValue(true)
|
||||
const basicTaskExecute = vi.fn().mockResolvedValue(true)
|
||||
|
||||
return {
|
||||
mockElectron: {
|
||||
Validation: {
|
||||
validateInstallation: vi.fn()
|
||||
}
|
||||
},
|
||||
testTasks: [
|
||||
{
|
||||
id: 'basicTask',
|
||||
name: 'Basic Task',
|
||||
execute: basicTaskExecute
|
||||
},
|
||||
{
|
||||
id: 'terminalTask',
|
||||
name: 'Terminal Task',
|
||||
execute: terminalTaskExecute,
|
||||
usesTerminal: true,
|
||||
isInstallationFix: true
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/utils/envUtil', () => ({
|
||||
electronAPI: vi.fn(() => mockElectron)
|
||||
}))
|
||||
|
||||
vi.mock('@/constants/desktopMaintenanceTasks', () => ({
|
||||
DESKTOP_MAINTENANCE_TASKS: testTasks
|
||||
}))
|
||||
|
||||
import { useMaintenanceTaskStore } from '@/stores/maintenanceTaskStore'
|
||||
import type { MaintenanceTask } from '@/types/desktop/maintenanceTypes'
|
||||
|
||||
type PartialInstallValidation = Partial<InstallValidation> &
|
||||
Record<string, unknown>
|
||||
|
||||
function makeUpdate(
|
||||
overrides: PartialInstallValidation = {}
|
||||
): InstallValidation {
|
||||
return {
|
||||
inProgress: false,
|
||||
installState: 'installed',
|
||||
...overrides
|
||||
} as InstallValidation
|
||||
}
|
||||
|
||||
function createStore() {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
return useMaintenanceTaskStore()
|
||||
}
|
||||
|
||||
describe('useMaintenanceTaskStore', () => {
|
||||
let store: ReturnType<typeof useMaintenanceTaskStore>
|
||||
const [basicTask, terminalTask] = testTasks as MaintenanceTask[]
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
store = createStore()
|
||||
})
|
||||
|
||||
describe('processUpdate', () => {
|
||||
it('sets isRefreshing to true during in-progress update', () => {
|
||||
store.processUpdate(makeUpdate({ inProgress: true }))
|
||||
expect(store.isRefreshing).toBe(true)
|
||||
})
|
||||
|
||||
it('sets isRefreshing to false when update is complete', () => {
|
||||
store.processUpdate(makeUpdate({ inProgress: false, basicTask: 'OK' }))
|
||||
expect(store.isRefreshing).toBe(false)
|
||||
})
|
||||
|
||||
it('updates runner state for tasks present in the final update', () => {
|
||||
store.processUpdate(makeUpdate({ basicTask: 'error' }))
|
||||
expect(store.getRunner(basicTask).state).toBe('error')
|
||||
})
|
||||
|
||||
it('sets task state to warning from update', () => {
|
||||
store.processUpdate(makeUpdate({ basicTask: 'warning' }))
|
||||
expect(store.getRunner(basicTask).state).toBe('warning')
|
||||
})
|
||||
|
||||
it('marks runners as refreshing when task id is absent from in-progress update', () => {
|
||||
store.processUpdate(makeUpdate({ inProgress: true }))
|
||||
expect(store.getRunner(basicTask).refreshing).toBe(true)
|
||||
})
|
||||
|
||||
it('marks task as skipped when absent from final update', () => {
|
||||
store.processUpdate(makeUpdate({ inProgress: false }))
|
||||
expect(store.getRunner(basicTask).state).toBe('skipped')
|
||||
})
|
||||
|
||||
it('clears refreshing flag after final update', () => {
|
||||
store.processUpdate(makeUpdate({ inProgress: true }))
|
||||
store.processUpdate(makeUpdate({ inProgress: false }))
|
||||
expect(store.getRunner(basicTask).refreshing).toBe(false)
|
||||
})
|
||||
|
||||
it('stores lastUpdate and exposes unsafeBasePath', () => {
|
||||
store.processUpdate(makeUpdate({ unsafeBasePath: true }))
|
||||
expect(store.unsafeBasePath).toBe(true)
|
||||
})
|
||||
|
||||
it('exposes unsafeBasePathReason from the update', () => {
|
||||
store.processUpdate(
|
||||
makeUpdate({ unsafeBasePath: true, unsafeBasePathReason: 'oneDrive' })
|
||||
)
|
||||
expect(store.unsafeBasePathReason).toBe('oneDrive')
|
||||
})
|
||||
})
|
||||
|
||||
describe('anyErrors', () => {
|
||||
it('returns true when any task has error state', () => {
|
||||
store.processUpdate(makeUpdate({ basicTask: 'error' }))
|
||||
expect(store.anyErrors).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false when all tasks are OK', () => {
|
||||
store.processUpdate(makeUpdate({ basicTask: 'OK', terminalTask: 'OK' }))
|
||||
expect(store.anyErrors).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false when all tasks are warning', () => {
|
||||
store.processUpdate(
|
||||
makeUpdate({ basicTask: 'warning', terminalTask: 'warning' })
|
||||
)
|
||||
expect(store.anyErrors).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('runner state transitions', () => {
|
||||
it('marks runner as resolved when transitioning from error to OK', () => {
|
||||
store.processUpdate(makeUpdate({ basicTask: 'error' }))
|
||||
store.processUpdate(makeUpdate({ basicTask: 'OK' }))
|
||||
expect(store.getRunner(basicTask).resolved).toBe(true)
|
||||
})
|
||||
|
||||
it('does not mark resolved for warning to OK transition', () => {
|
||||
store.processUpdate(makeUpdate({ basicTask: 'warning' }))
|
||||
store.processUpdate(makeUpdate({ basicTask: 'OK' }))
|
||||
expect(store.getRunner(basicTask).resolved).toBeFalsy()
|
||||
})
|
||||
|
||||
it('clears resolved flag when task returns to error', () => {
|
||||
store.processUpdate(makeUpdate({ basicTask: 'error' }))
|
||||
store.processUpdate(makeUpdate({ basicTask: 'OK' }))
|
||||
store.processUpdate(makeUpdate({ basicTask: 'error' }))
|
||||
expect(store.getRunner(basicTask).resolved).toBeFalsy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('clearResolved', () => {
|
||||
it('clears resolved flags on all runners', () => {
|
||||
store.processUpdate(makeUpdate({ basicTask: 'error' }))
|
||||
store.processUpdate(makeUpdate({ basicTask: 'OK' }))
|
||||
expect(store.getRunner(basicTask).resolved).toBe(true)
|
||||
|
||||
store.clearResolved()
|
||||
expect(store.getRunner(basicTask).resolved).toBeFalsy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('execute', () => {
|
||||
it('returns true when task execution succeeds', async () => {
|
||||
vi.mocked(basicTask.execute).mockResolvedValue(true)
|
||||
const result = await store.execute(basicTask)
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false when task execution fails', async () => {
|
||||
vi.mocked(basicTask.execute).mockResolvedValue(false)
|
||||
const result = await store.execute(basicTask)
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('calls refreshDesktopTasks after successful installation-fix task', async () => {
|
||||
vi.mocked(terminalTask.execute).mockResolvedValue(true)
|
||||
await store.execute(terminalTask)
|
||||
expect(
|
||||
mockElectron.Validation.validateInstallation
|
||||
).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('does not call refreshDesktopTasks when task is not an installation fix', async () => {
|
||||
vi.mocked(basicTask.execute).mockResolvedValue(true)
|
||||
await store.execute(basicTask)
|
||||
expect(
|
||||
mockElectron.Validation.validateInstallation
|
||||
).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not call refreshDesktopTasks when installation-fix task fails', async () => {
|
||||
vi.mocked(terminalTask.execute).mockResolvedValue(false)
|
||||
await store.execute(terminalTask)
|
||||
expect(
|
||||
mockElectron.Validation.validateInstallation
|
||||
).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('sets runner executing to true during task execution', async () => {
|
||||
let resolveTask!: (value: boolean) => void
|
||||
vi.mocked(basicTask.execute).mockReturnValue(
|
||||
new Promise<boolean>((resolve) => {
|
||||
resolveTask = resolve
|
||||
})
|
||||
)
|
||||
|
||||
const executePromise = store.execute(basicTask)
|
||||
expect(store.getRunner(basicTask).executing).toBe(true)
|
||||
|
||||
resolveTask(true)
|
||||
await executePromise
|
||||
expect(store.getRunner(basicTask).executing).toBe(false)
|
||||
})
|
||||
|
||||
it('clears executing flag when task throws', async () => {
|
||||
vi.mocked(basicTask.execute).mockRejectedValue(new Error('fail'))
|
||||
await expect(store.execute(basicTask)).rejects.toThrow('fail')
|
||||
expect(store.getRunner(basicTask).executing).toBe(false)
|
||||
})
|
||||
|
||||
it('sets runner error message when task throws', async () => {
|
||||
vi.mocked(basicTask.execute).mockRejectedValue(
|
||||
new Error('something broke')
|
||||
)
|
||||
await expect(store.execute(basicTask)).rejects.toThrow()
|
||||
expect(store.getRunner(basicTask).error).toBe('something broke')
|
||||
})
|
||||
|
||||
it('clears runner error on successful execution', async () => {
|
||||
vi.mocked(basicTask.execute).mockRejectedValue(new Error('fail'))
|
||||
await expect(store.execute(basicTask)).rejects.toThrow()
|
||||
|
||||
vi.mocked(basicTask.execute).mockResolvedValue(true)
|
||||
await store.execute(basicTask)
|
||||
expect(store.getRunner(basicTask).error).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('isRunningTerminalCommand', () => {
|
||||
it('returns true while a terminal task is executing', async () => {
|
||||
let resolveTask!: (value: boolean) => void
|
||||
vi.mocked(terminalTask.execute).mockReturnValue(
|
||||
new Promise<boolean>((resolve) => {
|
||||
resolveTask = resolve
|
||||
})
|
||||
)
|
||||
|
||||
const executePromise = store.execute(terminalTask)
|
||||
expect(store.isRunningTerminalCommand).toBe(true)
|
||||
|
||||
resolveTask(true)
|
||||
await executePromise
|
||||
expect(store.isRunningTerminalCommand).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false when no terminal tasks are executing', () => {
|
||||
expect(store.isRunningTerminalCommand).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isRunningInstallationFix', () => {
|
||||
it('returns true while an installation-fix task is executing', async () => {
|
||||
let resolveTask!: (value: boolean) => void
|
||||
vi.mocked(terminalTask.execute).mockReturnValue(
|
||||
new Promise<boolean>((resolve) => {
|
||||
resolveTask = resolve
|
||||
})
|
||||
)
|
||||
|
||||
const executePromise = store.execute(terminalTask)
|
||||
expect(store.isRunningInstallationFix).toBe(true)
|
||||
|
||||
resolveTask(true)
|
||||
await executePromise
|
||||
expect(store.isRunningInstallationFix).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -15,7 +15,7 @@ type ValidationState = InstallValidation['basePath']
|
||||
type IndexedUpdate = InstallValidation & Record<string, ValidationState>
|
||||
|
||||
/** State of a maintenance task, managed by the maintenance task store. */
|
||||
class MaintenanceTaskRunner {
|
||||
export class MaintenanceTaskRunner {
|
||||
constructor(readonly task: MaintenanceTask) {}
|
||||
|
||||
private _state?: MaintenanceTaskState
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
@@ -1,16 +0,0 @@
|
||||
import { render } from '@testing-library/vue'
|
||||
import { defineComponent } from 'vue'
|
||||
|
||||
export function withSetup<T>(composable: () => T): T {
|
||||
let result!: T
|
||||
render(
|
||||
defineComponent({
|
||||
setup() {
|
||||
result = composable()
|
||||
return {}
|
||||
},
|
||||
template: '<div />'
|
||||
})
|
||||
)
|
||||
return result
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { mockElectron } = vi.hoisted(() => ({
|
||||
mockElectron: {
|
||||
NetWork: {
|
||||
canAccessUrl: vi.fn<[url: string], Promise<boolean>>()
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/envUtil', () => ({
|
||||
electronAPI: vi.fn(() => mockElectron)
|
||||
}))
|
||||
|
||||
import { checkMirrorReachable } from '@/utils/electronMirrorCheck'
|
||||
|
||||
describe('checkMirrorReachable', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('returns false for an invalid URL without calling canAccessUrl', async () => {
|
||||
const result = await checkMirrorReachable('not-a-url')
|
||||
expect(result).toBe(false)
|
||||
expect(mockElectron.NetWork.canAccessUrl).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns false when canAccessUrl returns false', async () => {
|
||||
mockElectron.NetWork.canAccessUrl.mockResolvedValue(false)
|
||||
const result = await checkMirrorReachable('https://example.com')
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('returns true when URL is valid and canAccessUrl returns true', async () => {
|
||||
mockElectron.NetWork.canAccessUrl.mockResolvedValue(true)
|
||||
const result = await checkMirrorReachable('https://example.com')
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it('passes the mirror URL to canAccessUrl', async () => {
|
||||
const url = 'https://pypi.org/simple/'
|
||||
mockElectron.NetWork.canAccessUrl.mockResolvedValue(true)
|
||||
await checkMirrorReachable(url)
|
||||
expect(mockElectron.NetWork.canAccessUrl).toHaveBeenCalledWith(url)
|
||||
})
|
||||
|
||||
it('returns false for empty string', async () => {
|
||||
const result = await checkMirrorReachable('')
|
||||
expect(result).toBe(false)
|
||||
expect(mockElectron.NetWork.canAccessUrl).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,72 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { isElectron, isNativeWindow } from '@/utils/envUtil'
|
||||
|
||||
describe('isElectron', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('returns true when window.electronAPI is an object', () => {
|
||||
vi.stubGlobal('window', { ...window, electronAPI: {} })
|
||||
expect(isElectron()).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false when window.electronAPI is undefined', () => {
|
||||
vi.stubGlobal('window', { ...window, electronAPI: undefined })
|
||||
expect(isElectron()).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false when window.electronAPI is absent', () => {
|
||||
const copy = { ...window } as Record<string, unknown>
|
||||
delete copy['electronAPI']
|
||||
vi.stubGlobal('window', copy)
|
||||
expect(isElectron()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isNativeWindow', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('returns true when Electron and windowControlsOverlay.visible is true', () => {
|
||||
vi.stubGlobal('window', {
|
||||
...window,
|
||||
electronAPI: {},
|
||||
navigator: {
|
||||
...window.navigator,
|
||||
windowControlsOverlay: { visible: true }
|
||||
}
|
||||
})
|
||||
expect(isNativeWindow()).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false when not in Electron', () => {
|
||||
const copy = { ...window } as Record<string, unknown>
|
||||
delete copy['electronAPI']
|
||||
vi.stubGlobal('window', copy)
|
||||
expect(isNativeWindow()).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false when windowControlsOverlay.visible is false', () => {
|
||||
vi.stubGlobal('window', {
|
||||
...window,
|
||||
electronAPI: {},
|
||||
navigator: {
|
||||
...window.navigator,
|
||||
windowControlsOverlay: { visible: false }
|
||||
}
|
||||
})
|
||||
expect(isNativeWindow()).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false when windowControlsOverlay is absent', () => {
|
||||
vi.stubGlobal('window', {
|
||||
...window,
|
||||
electronAPI: {},
|
||||
navigator: { ...window.navigator, windowControlsOverlay: undefined }
|
||||
})
|
||||
expect(isNativeWindow()).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,102 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
|
||||
import { withSetup } from '@/test/withSetup'
|
||||
import { useMinLoadingDurationRef } from '@/utils/refUtil'
|
||||
|
||||
describe('useMinLoadingDurationRef', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('reflects false when source is initially false', () => {
|
||||
const source = ref(false)
|
||||
const result = withSetup(() => useMinLoadingDurationRef(source))
|
||||
expect(result.value).toBe(false)
|
||||
})
|
||||
|
||||
it('reflects true when source is initially true', () => {
|
||||
const source = ref(true)
|
||||
const result = withSetup(() => useMinLoadingDurationRef(source))
|
||||
expect(result.value).toBe(true)
|
||||
})
|
||||
|
||||
it('becomes true immediately when source transitions to true', async () => {
|
||||
const source = ref(false)
|
||||
const result = withSetup(() => useMinLoadingDurationRef(source))
|
||||
source.value = true
|
||||
await nextTick()
|
||||
expect(result.value).toBe(true)
|
||||
})
|
||||
|
||||
it('stays true within minDuration after source returns to false', async () => {
|
||||
const source = ref(false)
|
||||
const result = withSetup(() => useMinLoadingDurationRef(source, 250))
|
||||
|
||||
source.value = true
|
||||
await nextTick()
|
||||
source.value = false
|
||||
await nextTick()
|
||||
|
||||
vi.advanceTimersByTime(100)
|
||||
await nextTick()
|
||||
expect(result.value).toBe(true)
|
||||
})
|
||||
|
||||
it('becomes false after minDuration has elapsed', async () => {
|
||||
const source = ref(false)
|
||||
const result = withSetup(() => useMinLoadingDurationRef(source, 250))
|
||||
|
||||
source.value = true
|
||||
await nextTick()
|
||||
source.value = false
|
||||
await nextTick()
|
||||
|
||||
vi.advanceTimersByTime(250)
|
||||
await nextTick()
|
||||
expect(result.value).toBe(false)
|
||||
})
|
||||
|
||||
it('remains true while source is true even after minDuration elapses', async () => {
|
||||
const source = ref(false)
|
||||
const result = withSetup(() => useMinLoadingDurationRef(source, 250))
|
||||
|
||||
source.value = true
|
||||
await nextTick()
|
||||
vi.advanceTimersByTime(500)
|
||||
await nextTick()
|
||||
expect(result.value).toBe(true)
|
||||
})
|
||||
|
||||
it('works with a computed ref as input', async () => {
|
||||
const raw = ref(false)
|
||||
const source = computed(() => raw.value)
|
||||
const result = withSetup(() => useMinLoadingDurationRef(source))
|
||||
|
||||
raw.value = true
|
||||
await nextTick()
|
||||
expect(result.value).toBe(true)
|
||||
})
|
||||
|
||||
it('uses 250ms as default minDuration', async () => {
|
||||
const source = ref(false)
|
||||
const result = withSetup(() => useMinLoadingDurationRef(source))
|
||||
|
||||
source.value = true
|
||||
await nextTick()
|
||||
source.value = false
|
||||
await nextTick()
|
||||
|
||||
vi.advanceTimersByTime(249)
|
||||
await nextTick()
|
||||
expect(result.value).toBe(true)
|
||||
|
||||
vi.advanceTimersByTime(1)
|
||||
await nextTick()
|
||||
expect(result.value).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -13,8 +13,7 @@
|
||||
"src/**/*.ts",
|
||||
"src/**/*.vue",
|
||||
"src/**/*.d.ts",
|
||||
"vite.config.mts",
|
||||
"vitest.config.mts"
|
||||
"vite.config.mts"
|
||||
],
|
||||
"references": []
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { config as dotenvConfig } from 'dotenv'
|
||||
import dotenv from 'dotenv'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { FileSystemIconLoader } from 'unplugin-icons/loaders'
|
||||
@@ -11,7 +11,7 @@ import { defineConfig } from 'vite'
|
||||
import { createHtmlPlugin } from 'vite-plugin-html'
|
||||
import vueDevTools from 'vite-plugin-vue-devtools'
|
||||
|
||||
dotenvConfig()
|
||||
dotenv.config()
|
||||
|
||||
const projectRoot = fileURLToPath(new URL('.', import.meta.url))
|
||||
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
const projectRoot = fileURLToPath(new URL('.', import.meta.url))
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(projectRoot, 'src'),
|
||||
'@frontend-locales': path.resolve(projectRoot, '../../src/locales')
|
||||
}
|
||||
},
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'happy-dom',
|
||||
include: ['src/**/*.{test,spec}.{ts,tsx}'],
|
||||
setupFiles: ['./src/test/setup.ts']
|
||||
}
|
||||
})
|
||||
3
apps/website/.gitignore
vendored
3
apps/website/.gitignore
vendored
@@ -1,3 +0,0 @@
|
||||
dist/
|
||||
.astro/
|
||||
test-results/
|
||||
@@ -1,20 +0,0 @@
|
||||
import { defineConfig } from 'astro/config'
|
||||
import sitemap from '@astrojs/sitemap'
|
||||
import vue from '@astrojs/vue'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
|
||||
export default defineConfig({
|
||||
site: 'https://comfy.org',
|
||||
output: 'static',
|
||||
integrations: [vue(), sitemap()],
|
||||
vite: {
|
||||
plugins: [tailwindcss()]
|
||||
},
|
||||
i18n: {
|
||||
locales: ['en', 'zh-CN'],
|
||||
defaultLocale: 'en',
|
||||
routing: {
|
||||
prefixDefaultLocale: false
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -1,137 +0,0 @@
|
||||
import { expect, test } from '@playwright/test'
|
||||
|
||||
test.describe('Cloud page @smoke', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/cloud')
|
||||
})
|
||||
|
||||
test('has correct title', async ({ page }) => {
|
||||
await expect(page).toHaveTitle('Comfy Cloud — AI in the Cloud')
|
||||
})
|
||||
|
||||
test('HeroSection heading and subtitle are visible', async ({ page }) => {
|
||||
await expect(
|
||||
page.getByRole('heading', { name: /The full power of/i, level: 1 })
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
page.getByText(/The easiest way to start with ComfyUI/)
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('HeroSection has CTA button linking to cloud', async ({ page }) => {
|
||||
const cta = page.getByRole('link', { name: /TRY COMFY CLOUD FOR FREE/i })
|
||||
await expect(cta).toBeVisible()
|
||||
})
|
||||
|
||||
test('ReasonSection heading and reasons are visible', async ({ page }) => {
|
||||
await expect(
|
||||
page.getByRole('heading', { name: /Why.*professionals.*choose/i })
|
||||
).toBeVisible()
|
||||
|
||||
for (const title of [
|
||||
'Powerful GPUs',
|
||||
'All models',
|
||||
'More control',
|
||||
'Community workflows'
|
||||
]) {
|
||||
await expect(page.getByText(title).first()).toBeVisible()
|
||||
}
|
||||
})
|
||||
|
||||
test('AIModelsSection heading and 5 model cards are visible', async ({
|
||||
page
|
||||
}) => {
|
||||
await expect(
|
||||
page.getByRole('heading', { name: /leading AI models/i })
|
||||
).toBeVisible()
|
||||
|
||||
const grid = page.locator('.grid', {
|
||||
has: page.getByText('Grok Imagine')
|
||||
})
|
||||
const modelCards = grid.locator('a[href="https://comfy.org/workflows"]')
|
||||
await expect(modelCards).toHaveCount(5)
|
||||
})
|
||||
|
||||
test('AIModelsSection CTA links to workflows', async ({ page }) => {
|
||||
const cta = page.getByRole('link', {
|
||||
name: /EXPLORE WORKFLOWS/i
|
||||
})
|
||||
await expect(cta.first()).toBeVisible()
|
||||
await expect(cta.first()).toHaveAttribute(
|
||||
'href',
|
||||
'https://comfy.org/workflows'
|
||||
)
|
||||
})
|
||||
|
||||
test('AudienceSection heading and cards are visible', async ({ page }) => {
|
||||
await expect(page.getByText(/creators/i).first()).toBeVisible()
|
||||
|
||||
for (const label of ['CREATORS', 'TEAMS & STUDIOS']) {
|
||||
await expect(page.getByText(label).first()).toBeVisible()
|
||||
}
|
||||
})
|
||||
|
||||
test('PricingSection heading and CTA are visible', async ({ page }) => {
|
||||
await expect(page.getByText(/Simple, credit-based pricing/)).toBeVisible()
|
||||
|
||||
const cta = page.getByRole('link', { name: /SEE PRICING PLANS/i })
|
||||
await expect(cta).toBeVisible()
|
||||
await expect(cta).toHaveAttribute('href', '/cloud/pricing')
|
||||
})
|
||||
|
||||
test('ProductCardsSection has 3 product cards', async ({ page }) => {
|
||||
const section = page.locator('section', {
|
||||
has: page.getByRole('heading', { name: /The AI creation/ })
|
||||
})
|
||||
const cards = section.locator('a[href]')
|
||||
await expect(cards).toHaveCount(3)
|
||||
})
|
||||
|
||||
test('FAQSection heading is visible with 15 items', async ({ page }) => {
|
||||
await expect(page.getByRole('heading', { name: /FAQ/i })).toBeVisible()
|
||||
|
||||
const faqButtons = page.locator('button[aria-controls^="faq-panel-"]')
|
||||
await expect(faqButtons).toHaveCount(15)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Cloud FAQ accordion @interaction', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/cloud')
|
||||
})
|
||||
|
||||
test('all FAQs are expanded by default', async ({ page }) => {
|
||||
await expect(
|
||||
page.getByText(/Comfy Cloud is a version of ComfyUI/i)
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('clicking an expanded FAQ collapses it', async ({ page }) => {
|
||||
const firstQuestion = page.getByRole('button', {
|
||||
name: /What is Comfy Cloud/i
|
||||
})
|
||||
await firstQuestion.scrollIntoViewIfNeeded()
|
||||
await firstQuestion.click()
|
||||
|
||||
await expect(
|
||||
page.getByText(/Comfy Cloud is a version of ComfyUI/i)
|
||||
).toBeHidden()
|
||||
})
|
||||
|
||||
test('clicking a collapsed FAQ expands it again', async ({ page }) => {
|
||||
const firstQuestion = page.getByRole('button', {
|
||||
name: /What is Comfy Cloud/i
|
||||
})
|
||||
await firstQuestion.scrollIntoViewIfNeeded()
|
||||
|
||||
await firstQuestion.click()
|
||||
await expect(
|
||||
page.getByText(/Comfy Cloud is a version of ComfyUI/i)
|
||||
).toBeHidden()
|
||||
|
||||
await firstQuestion.click()
|
||||
await expect(
|
||||
page.getByText(/Comfy Cloud is a version of ComfyUI/i)
|
||||
).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -1,167 +0,0 @@
|
||||
import { expect, test } from '@playwright/test'
|
||||
|
||||
test.describe('Download page @smoke', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/download')
|
||||
})
|
||||
|
||||
test('has correct title', async ({ page }) => {
|
||||
await expect(page).toHaveTitle('Download Comfy — Run AI Locally')
|
||||
})
|
||||
|
||||
test('CloudBannerSection is visible with cloud link', async ({ page }) => {
|
||||
const link = page.getByRole('link', { name: /TRY COMFY CLOUD/i })
|
||||
await expect(link).toBeVisible()
|
||||
await expect(link).toHaveAttribute('href', 'https://cloud.comfy.org')
|
||||
})
|
||||
|
||||
test('HeroSection heading and subtitle are visible', async ({ page }) => {
|
||||
await expect(
|
||||
page.getByRole('heading', { name: /Run on your hardware/i, level: 1 })
|
||||
).toBeVisible()
|
||||
await expect(page.getByText(/The full ComfyUI engine/)).toBeVisible()
|
||||
})
|
||||
|
||||
test('HeroSection has download and GitHub buttons', async ({ page }) => {
|
||||
const hero = page.locator('section', {
|
||||
has: page.getByRole('heading', {
|
||||
name: /Run on your hardware/i,
|
||||
level: 1
|
||||
})
|
||||
})
|
||||
const downloadBtn = hero.getByRole('link', { name: /DOWNLOAD LOCAL/i })
|
||||
await expect(downloadBtn).toBeVisible()
|
||||
await expect(downloadBtn).toHaveAttribute('target', '_blank')
|
||||
|
||||
const githubBtn = hero.getByRole('link', { name: /INSTALL FROM GITHUB/i })
|
||||
await expect(githubBtn).toBeVisible()
|
||||
await expect(githubBtn).toHaveAttribute(
|
||||
'href',
|
||||
'https://github.com/Comfy-Org/ComfyUI'
|
||||
)
|
||||
})
|
||||
|
||||
test('ReasonSection heading and reasons are visible', async ({ page }) => {
|
||||
await expect(
|
||||
page.getByRole('heading', { name: /Why.*professionals.*choose/i })
|
||||
).toBeVisible()
|
||||
|
||||
for (const title of [
|
||||
'Unlimited',
|
||||
'Any model',
|
||||
'Your machine',
|
||||
'Free. Open Source'
|
||||
]) {
|
||||
await expect(page.getByText(title).first()).toBeVisible()
|
||||
}
|
||||
})
|
||||
|
||||
test('EcoSystemSection heading is visible', async ({ page }) => {
|
||||
await expect(page.getByText(/An ecosystem that moves faster/)).toBeVisible()
|
||||
})
|
||||
|
||||
test('ProductCardsSection has 3 product cards', async ({ page }) => {
|
||||
const section = page.locator('section', {
|
||||
has: page.getByRole('heading', { name: /The AI creation/ })
|
||||
})
|
||||
const cards = section.locator('a[href]')
|
||||
await expect(cards).toHaveCount(3)
|
||||
})
|
||||
|
||||
test('ProductCardsSection links to cloud, api, enterprise', async ({
|
||||
page
|
||||
}) => {
|
||||
const section = page.locator('section', {
|
||||
has: page.getByRole('heading', { name: /The AI creation/ })
|
||||
})
|
||||
|
||||
for (const href of ['/cloud', '/api', '/cloud/enterprise']) {
|
||||
await expect(section.locator(`a[href="${href}"]`)).toBeVisible()
|
||||
}
|
||||
})
|
||||
|
||||
test('FAQSection heading is visible with 8 items', async ({ page }) => {
|
||||
await expect(page.getByRole('heading', { name: /FAQ/i })).toBeVisible()
|
||||
|
||||
const faqButtons = page.locator('button[aria-controls^="faq-panel-"]')
|
||||
await expect(faqButtons).toHaveCount(8)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('FAQ accordion @interaction', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/download')
|
||||
})
|
||||
|
||||
test('all FAQs are expanded by default', async ({ page }) => {
|
||||
await expect(
|
||||
page.getByText(/A dedicated GPU is strongly recommended/i)
|
||||
).toBeVisible()
|
||||
await expect(page.getByText(/ComfyUI is lightweight/i)).toBeVisible()
|
||||
})
|
||||
|
||||
test('clicking an expanded FAQ collapses it', async ({ page }) => {
|
||||
const firstQuestion = page.getByRole('button', {
|
||||
name: /Do I need a GPU/i
|
||||
})
|
||||
await firstQuestion.scrollIntoViewIfNeeded()
|
||||
await firstQuestion.click()
|
||||
|
||||
await expect(
|
||||
page.getByText(/A dedicated GPU is strongly recommended/i)
|
||||
).toBeHidden()
|
||||
})
|
||||
|
||||
test('clicking a collapsed FAQ expands it again', async ({ page }) => {
|
||||
const firstQuestion = page.getByRole('button', {
|
||||
name: /Do I need a GPU/i
|
||||
})
|
||||
await firstQuestion.scrollIntoViewIfNeeded()
|
||||
|
||||
await firstQuestion.click()
|
||||
await expect(
|
||||
page.getByText(/A dedicated GPU is strongly recommended/i)
|
||||
).toBeHidden()
|
||||
|
||||
await firstQuestion.click()
|
||||
await expect(
|
||||
page.getByText(/A dedicated GPU is strongly recommended/i)
|
||||
).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Download page mobile @mobile', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/download')
|
||||
})
|
||||
|
||||
test('CloudBannerSection is visible', async ({ page }) => {
|
||||
await expect(page.getByText(/Need more power/)).toBeVisible()
|
||||
})
|
||||
|
||||
test('HeroSection heading is visible', async ({ page }) => {
|
||||
await expect(
|
||||
page.getByRole('heading', { name: /Run on your hardware/i, level: 1 })
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('download buttons are stacked vertically', async ({ page }) => {
|
||||
const hero = page.locator('section', {
|
||||
has: page.getByRole('heading', {
|
||||
name: /Run on your hardware/i,
|
||||
level: 1
|
||||
})
|
||||
})
|
||||
const downloadBtn = hero.getByRole('link', { name: /DOWNLOAD LOCAL/i })
|
||||
const githubBtn = hero.getByRole('link', { name: /INSTALL FROM GITHUB/i })
|
||||
|
||||
await downloadBtn.scrollIntoViewIfNeeded()
|
||||
|
||||
const downloadBox = await downloadBtn.boundingBox()
|
||||
const githubBox = await githubBtn.boundingBox()
|
||||
|
||||
expect(downloadBox, 'download button bounding box').not.toBeNull()
|
||||
expect(githubBox, 'github button bounding box').not.toBeNull()
|
||||
expect(githubBox!.y).toBeGreaterThan(downloadBox!.y)
|
||||
})
|
||||
})
|
||||
@@ -1,130 +0,0 @@
|
||||
import { expect, test } from '@playwright/test'
|
||||
|
||||
test.describe('Homepage @smoke', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/')
|
||||
})
|
||||
|
||||
test('has correct title', async ({ page }) => {
|
||||
await expect(page).toHaveTitle('Comfy — Professional Control of Visual AI')
|
||||
})
|
||||
|
||||
test('HeroSection heading is visible', async ({ page }) => {
|
||||
await expect(
|
||||
page.getByRole('heading', { name: /Professional Control/i, level: 1 })
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('SocialProofBar logos are visible', async ({ page }) => {
|
||||
await expect(
|
||||
page.locator('img[src*="/icons/clients/"]').first()
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('ProductShowcase section is visible', async ({ page }) => {
|
||||
await expect(page.getByText('HOW', { exact: true }).first()).toBeVisible()
|
||||
await expect(
|
||||
page.getByText(/Connect models, processing steps, and outputs/)
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('UseCaseSection is visible', async ({ page }) => {
|
||||
await expect(
|
||||
page.getByText('Industries that create with ComfyUI')
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('GetStartedSection with heading is visible', async ({ page }) => {
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Get started in minutes' })
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('ProductCardsSection has 4 product cards', async ({ page }) => {
|
||||
const section = page.locator('section', {
|
||||
has: page.getByRole('heading', { name: /The AI creation/ })
|
||||
})
|
||||
const cards = section.locator('a[href]')
|
||||
await expect(cards).toHaveCount(4)
|
||||
})
|
||||
|
||||
test('CaseStudySpotlight section is visible', async ({ page }) => {
|
||||
const section = page.locator('section', {
|
||||
has: page.getByText('Customer Stories')
|
||||
})
|
||||
await expect(section).toBeVisible()
|
||||
await expect(
|
||||
section.getByRole('heading', { name: /See Comfy/i })
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Product showcase accordion @interaction', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/')
|
||||
})
|
||||
|
||||
test('first feature is active by default', async ({ page }) => {
|
||||
await expect(
|
||||
page.getByText(/Build powerful AI pipelines by connecting nodes/).first()
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('clicking inactive feature expands it and collapses previous', async ({
|
||||
page
|
||||
}) => {
|
||||
const secondFeature = page
|
||||
.getByRole('button', { name: /App mode/i })
|
||||
.first()
|
||||
|
||||
await secondFeature.scrollIntoViewIfNeeded()
|
||||
|
||||
await expect(async () => {
|
||||
await secondFeature.click()
|
||||
await expect(
|
||||
page.getByText(/If you are new to ComfyUI/).first()
|
||||
).toBeVisible({ timeout: 1000 })
|
||||
}).toPass({ timeout: 10000 })
|
||||
|
||||
await expect(
|
||||
page.getByText(/Build powerful AI pipelines by connecting nodes/).first()
|
||||
).toBeHidden()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Product cards links @smoke', () => {
|
||||
test('cards have correct hrefs', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
|
||||
const section = page.locator('section', {
|
||||
has: page.getByRole('heading', { name: /The AI creation/ })
|
||||
})
|
||||
|
||||
for (const href of ['/download', '/cloud', '/api', '/cloud/enterprise']) {
|
||||
await expect(section.locator(`a[href="${href}"]`)).toBeVisible()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Get started section links @smoke', () => {
|
||||
test('has download and cloud links', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
|
||||
const section = page.locator('section', {
|
||||
has: page.getByRole('heading', { name: 'Get started in minutes' })
|
||||
})
|
||||
|
||||
const downloadLink = section.getByRole('link', { name: 'Download Local' })
|
||||
await expect(downloadLink).toBeVisible()
|
||||
await expect(downloadLink).toHaveAttribute('href', '/download')
|
||||
|
||||
const cloudLink = section.getByRole('link', { name: 'Launch Cloud' })
|
||||
await expect(cloudLink).toBeVisible()
|
||||
await expect(cloudLink).toHaveAttribute('href', 'https://app.comfy.org')
|
||||
})
|
||||
})
|
||||
@@ -1,161 +0,0 @@
|
||||
import { expect, test } from '@playwright/test'
|
||||
|
||||
test.describe('Desktop navigation @smoke', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/')
|
||||
})
|
||||
|
||||
test('logo links to homepage', async ({ page }) => {
|
||||
const logo = page.getByRole('link', { name: 'Comfy home' })
|
||||
await expect(logo).toBeVisible()
|
||||
await expect(logo).toHaveAttribute('href', '/')
|
||||
})
|
||||
|
||||
test('has all top-level nav items', async ({ page }) => {
|
||||
const nav = page.getByRole('navigation', { name: 'Main navigation' })
|
||||
const desktopLinks = nav.getByTestId('desktop-nav-links')
|
||||
|
||||
for (const label of [
|
||||
'PRODUCTS',
|
||||
'PRICING',
|
||||
'COMMUNITY',
|
||||
'RESOURCES',
|
||||
'COMPANY'
|
||||
]) {
|
||||
await expect(desktopLinks.getByText(label).first()).toBeVisible()
|
||||
}
|
||||
})
|
||||
|
||||
test('CTA buttons are visible', async ({ page }) => {
|
||||
const nav = page.getByRole('navigation', { name: 'Main navigation' })
|
||||
const desktopCTA = nav.getByTestId('desktop-nav-cta')
|
||||
await expect(
|
||||
desktopCTA.getByRole('link', { name: 'DOWNLOAD LOCAL' })
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
desktopCTA.getByRole('link', { name: 'LAUNCH CLOUD' })
|
||||
).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Desktop dropdown @interaction', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/')
|
||||
})
|
||||
|
||||
test('hovering PRODUCTS shows dropdown items', async ({ page }) => {
|
||||
const nav = page.getByRole('navigation', { name: 'Main navigation' })
|
||||
const desktopLinks = nav.getByTestId('desktop-nav-links')
|
||||
const productsButton = desktopLinks.getByRole('button', {
|
||||
name: /PRODUCTS/i
|
||||
})
|
||||
await productsButton.hover()
|
||||
|
||||
const dropdown = productsButton.locator('..').getByTestId('nav-dropdown')
|
||||
for (const item of [
|
||||
'Comfy Local',
|
||||
'Comfy Cloud',
|
||||
'Comfy API',
|
||||
'Comfy Enterprise'
|
||||
]) {
|
||||
await expect(dropdown.getByText(item)).toBeVisible()
|
||||
}
|
||||
})
|
||||
|
||||
test('moving mouse away closes dropdown', async ({ page }) => {
|
||||
const nav = page.getByRole('navigation', { name: 'Main navigation' })
|
||||
const desktopLinks = nav.getByTestId('desktop-nav-links')
|
||||
await desktopLinks.getByRole('button', { name: /PRODUCTS/i }).hover()
|
||||
|
||||
const comfyLocal = nav.getByRole('link', { name: 'Comfy Local' }).first()
|
||||
await expect(comfyLocal).toBeVisible()
|
||||
|
||||
await page.locator('main').hover()
|
||||
await expect(comfyLocal).toBeHidden()
|
||||
})
|
||||
|
||||
test('Escape key closes dropdown', async ({ page }) => {
|
||||
const nav = page.getByRole('navigation', { name: 'Main navigation' })
|
||||
const desktopLinks = nav.getByTestId('desktop-nav-links')
|
||||
await desktopLinks.getByRole('button', { name: /PRODUCTS/i }).hover()
|
||||
|
||||
const comfyLocal = nav.getByRole('link', { name: 'Comfy Local' }).first()
|
||||
await expect(comfyLocal).toBeVisible()
|
||||
|
||||
await page.keyboard.press('Escape')
|
||||
await expect(comfyLocal).toBeHidden()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Mobile menu @mobile', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/')
|
||||
})
|
||||
|
||||
test('hamburger button is visible', async ({ page }) => {
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Toggle menu' })
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('clicking hamburger opens mobile menu with nav items', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.getByRole('button', { name: 'Toggle menu' }).click()
|
||||
|
||||
const menu = page.locator('#site-mobile-menu')
|
||||
await expect(menu).toBeVisible()
|
||||
|
||||
for (const label of ['PRODUCTS', 'PRICING', 'COMMUNITY']) {
|
||||
await expect(menu.getByText(label).first()).toBeVisible()
|
||||
}
|
||||
})
|
||||
|
||||
test('clicking section with subitems drills down and back works', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.getByRole('button', { name: 'Toggle menu' }).click()
|
||||
|
||||
const menu = page.locator('#site-mobile-menu')
|
||||
await menu.getByText('PRODUCTS').first().click()
|
||||
|
||||
await expect(menu.getByText('Comfy Local')).toBeVisible()
|
||||
await expect(menu.getByText('Comfy Cloud')).toBeVisible()
|
||||
|
||||
await menu.getByRole('button', { name: /BACK/i }).click()
|
||||
await expect(menu.getByText('PRODUCTS').first()).toBeVisible()
|
||||
})
|
||||
|
||||
test('CTA buttons visible in mobile menu', async ({ page }) => {
|
||||
await page.getByRole('button', { name: 'Toggle menu' }).click()
|
||||
|
||||
const menu = page.locator('#site-mobile-menu')
|
||||
await expect(
|
||||
menu.getByRole('link', { name: 'DOWNLOAD LOCAL' })
|
||||
).toBeVisible()
|
||||
await expect(menu.getByRole('link', { name: 'LAUNCH CLOUD' })).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Footer @smoke', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/')
|
||||
})
|
||||
|
||||
test('footer is visible with link sections', async ({ page }) => {
|
||||
const footer = page.locator('footer')
|
||||
await expect(footer).toBeVisible()
|
||||
|
||||
for (const heading of ['Products', 'Resources', 'Company']) {
|
||||
await expect(
|
||||
footer.getByRole('heading', { name: heading }).first()
|
||||
).toBeVisible()
|
||||
}
|
||||
})
|
||||
|
||||
test('copyright text is visible', async ({ page }) => {
|
||||
await expect(
|
||||
page.locator('footer').getByText(/© \d{4} Comfy Org/)
|
||||
).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -1,48 +0,0 @@
|
||||
import { expect, test } from '@playwright/test'
|
||||
|
||||
test.describe('Desktop layout @smoke', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/')
|
||||
})
|
||||
|
||||
test('navigation links visible and hamburger hidden', async ({ page }) => {
|
||||
const nav = page.getByRole('navigation', { name: 'Main navigation' })
|
||||
const desktopLinks = nav.getByTestId('desktop-nav-links')
|
||||
await expect(desktopLinks.getByText('PRODUCTS').first()).toBeVisible()
|
||||
await expect(desktopLinks.getByText('PRICING').first()).toBeVisible()
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Toggle menu' })).toBeHidden()
|
||||
})
|
||||
|
||||
test('product cards in grid layout', async ({ page }) => {
|
||||
const section = page.locator('section', {
|
||||
has: page.getByRole('heading', { name: /The AI creation/ })
|
||||
})
|
||||
const cards = section.locator('a[href]')
|
||||
await expect(cards).toHaveCount(4)
|
||||
|
||||
const firstBox = await cards.nth(0).boundingBox()
|
||||
const secondBox = await cards.nth(1).boundingBox()
|
||||
|
||||
expect(firstBox, 'first card bounding box').not.toBeNull()
|
||||
expect(secondBox, 'second card bounding box').not.toBeNull()
|
||||
expect(firstBox!.y).toBeCloseTo(secondBox!.y, 0)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Mobile layout @mobile', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/')
|
||||
})
|
||||
|
||||
test('hamburger visible and desktop nav hidden', async ({ page }) => {
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Toggle menu' })
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('SocialProofBar shows two marquee rows on mobile', async ({ page }) => {
|
||||
const mobileContainer = page.getByTestId('social-proof-mobile')
|
||||
await expect(mobileContainer).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -1,99 +0,0 @@
|
||||
{
|
||||
"name": "@comfyorg/website",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"build": "astro build",
|
||||
"preview": "astro preview",
|
||||
"typecheck": "astro check",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:local": "PLAYWRIGHT_LOCAL=1 playwright test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/sitemap": "catalog:",
|
||||
"@comfyorg/design-system": "workspace:*",
|
||||
"@comfyorg/tailwind-utils": "workspace:*",
|
||||
"@vercel/analytics": "catalog:",
|
||||
"gsap": "catalog:",
|
||||
"lenis": "catalog:",
|
||||
"vue": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@astrojs/check": "catalog:",
|
||||
"@astrojs/vue": "catalog:",
|
||||
"@playwright/test": "catalog:",
|
||||
"@tailwindcss/vite": "catalog:",
|
||||
"astro": "catalog:",
|
||||
"tailwindcss": "catalog:",
|
||||
"typescript": "catalog:"
|
||||
},
|
||||
"nx": {
|
||||
"tags": [
|
||||
"scope:website",
|
||||
"type:app"
|
||||
],
|
||||
"targets": {
|
||||
"dev": {
|
||||
"executor": "nx:run-commands",
|
||||
"continuous": true,
|
||||
"options": {
|
||||
"cwd": "apps/website",
|
||||
"command": "astro dev"
|
||||
}
|
||||
},
|
||||
"serve": {
|
||||
"executor": "nx:run-commands",
|
||||
"continuous": true,
|
||||
"options": {
|
||||
"cwd": "apps/website",
|
||||
"command": "astro dev"
|
||||
}
|
||||
},
|
||||
"build": {
|
||||
"executor": "nx:run-commands",
|
||||
"cache": true,
|
||||
"dependsOn": [
|
||||
"^build"
|
||||
],
|
||||
"options": {
|
||||
"cwd": "apps/website",
|
||||
"command": "astro build"
|
||||
},
|
||||
"outputs": [
|
||||
"{projectRoot}/dist"
|
||||
]
|
||||
},
|
||||
"preview": {
|
||||
"executor": "nx:run-commands",
|
||||
"continuous": true,
|
||||
"dependsOn": [
|
||||
"build"
|
||||
],
|
||||
"options": {
|
||||
"cwd": "apps/website",
|
||||
"command": "astro preview"
|
||||
}
|
||||
},
|
||||
"typecheck": {
|
||||
"executor": "nx:run-commands",
|
||||
"cache": true,
|
||||
"options": {
|
||||
"cwd": "apps/website",
|
||||
"command": "astro check"
|
||||
}
|
||||
},
|
||||
"test:e2e": {
|
||||
"executor": "nx:run-commands",
|
||||
"dependsOn": [
|
||||
"build"
|
||||
],
|
||||
"options": {
|
||||
"cwd": "apps/website",
|
||||
"command": "playwright test"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import { defineConfig, devices } from '@playwright/test'
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
reporter: 'html',
|
||||
use: {
|
||||
baseURL: 'http://localhost:4321',
|
||||
trace: process.env.CI ? 'on-first-retry' : 'on'
|
||||
},
|
||||
webServer: {
|
||||
command: 'pnpm preview',
|
||||
port: 4321,
|
||||
reuseExistingServer: !process.env.CI
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: 'desktop',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
grepInvert: /@mobile/
|
||||
},
|
||||
{
|
||||
name: 'mobile',
|
||||
use: { ...devices['Pixel 5'] },
|
||||
grep: /@mobile/
|
||||
}
|
||||
]
|
||||
})
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,13 +0,0 @@
|
||||
<svg width="32" height="32" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_1471_12658)">
|
||||
<path d="M324.094 389.858L284.667 379.567V191.5L326.871 180.816C350.01 174.941 369.446 170.154 370.371 170.339C371.112 170.339 371.667 222.027 371.667 285.326V400.334L367.594 400.15C365.189 400.15 345.566 395.361 324.094 389.835V389.857V389.858Z" fill="white"/>
|
||||
<path d="M138.667 343.325C138.667 279.602 139.229 227.339 140.166 227.339C140.914 227.154 160.573 231.998 184.164 237.913L226.667 248.65L226.292 342.975L225.73 437.278L187.535 447.107C166.565 452.463 146.906 457.47 144.097 458.029L138.667 459.334V343.325Z" fill="white"/>
|
||||
<path d="M423.667 248.299C423.667 38.7081 423.853 27.4506 427.037 28.3797C428.722 28.9368 445.386 33.1843 463.921 37.8029C482.458 42.6075 500.807 47.2031 504.739 48.1312L511.667 49.9884L511.293 248.67L510.731 447.539L472.722 457.148C451.939 462.486 432.279 467.291 429.284 468.057L423.667 469.334V248.299Z" fill="white"/>
|
||||
<path d="M-0.333038 248.845C-0.333038 140.208 0.222275 51.334 1.14852 51.334C1.88822 51.334 21.3242 56.1412 44.4631 61.8769L86.667 72.583V248.66C86.667 345.267 86.296 424.55 85.9262 424.55C85.3709 424.55 65.7494 429.544 42.4262 435.466L-0.333038 446.334V248.823V248.844V248.845Z" fill="white"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_1471_12658">
|
||||
<rect width="512" height="512" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.4 KiB |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user