mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-03 13:48:49 +00:00
Compare commits
1 Commits
nathaniel/
...
shihchi/co
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6ded9561f2 |
154
.github/workflows/ci-tests-custom-nodes.yaml
vendored
154
.github/workflows/ci-tests-custom-nodes.yaml
vendored
@@ -1,154 +0,0 @@
|
||||
# Runs the custom-node regression suite against a backend that has the manifest
|
||||
# packs actually installed, so the load/run tiers execute for real. This is a
|
||||
# GATING check: if a pack fails to install or any tier is skipped, the job goes
|
||||
# red - a regression gate that let a broken pack through as a "skip" would be
|
||||
# pointless. Mark `custom-nodes-e2e` as a required status check in branch
|
||||
# protection to block merges on failure.
|
||||
name: 'CI: Tests Custom Nodes'
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches-ignore: [wip/*, draft/*, temp/*]
|
||||
push:
|
||||
branches: [main, master]
|
||||
merge_group:
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# Path gating lives here, not in a trigger-level `paths:` filter: a required
|
||||
# check gated by trigger paths never creates a check run on an unrelated PR
|
||||
# and leaves branch protection stuck Pending. A job-level `if:` still creates
|
||||
# the check and marks it Skipped (= passing). Mirrors ci-tests-unit.yaml.
|
||||
changes:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
should-run: ${{ steps.changes.outputs.should-run }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- id: changes
|
||||
uses: ./.github/actions/changes-filter
|
||||
|
||||
custom-nodes-e2e:
|
||||
needs: changes
|
||||
# Run only when non-docs code changed AND the PR is same-repo. Fork PRs can
|
||||
# edit the manifest's repo/pin URLs, and this job clones and pip-installs
|
||||
# whatever they point at (setup.py runs at install time), so an untrusted
|
||||
# fork must not be able to aim the clone at an attacker-controlled repo.
|
||||
# Fork PRs still get the environment-agnostic coverage via the main e2e
|
||||
# shards. A skipped job counts as passing, so this stays required-safe.
|
||||
if: >-
|
||||
needs.changes.outputs.should-run == 'true' &&
|
||||
(github.event_name != 'pull_request' ||
|
||||
github.event.pull_request.head.repo.full_name == github.repository)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup frontend
|
||||
uses: ./.github/actions/setup-frontend
|
||||
with:
|
||||
include_build_step: true
|
||||
|
||||
- name: Setup Playwright
|
||||
uses: ./.github/actions/setup-playwright
|
||||
|
||||
# Checks out ComfyUI, installs Python/torch/requirements and ComfyUI_devtools.
|
||||
# launch_server:false so we can add the manifest packs before booting.
|
||||
- name: Setup ComfyUI server
|
||||
uses: ./.github/actions/setup-comfyui-server
|
||||
with:
|
||||
launch_server: 'false'
|
||||
|
||||
# Install every pack the manifest declares (DRY: a new pack row installs
|
||||
# itself here, no workflow change). A clone or dependency failure fails the
|
||||
# job - if a pack can't be installed, its coverage can't run, and that is a
|
||||
# gate failure, not something to paper over. The `jq | while` pipe hides
|
||||
# failures in a subshell, so read into an array and loop with `set -e`.
|
||||
- name: Install manifest custom nodes
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Pin the CPU torch stack that setup-comfyui-server installed so no
|
||||
# pack's requirements.txt can pull a GPU/incompatible torch onto this
|
||||
# --cpu runner. A pack that genuinely needs a different torch fails
|
||||
# the constrained install loudly rather than silently swapping it.
|
||||
pip freeze | grep -iE '^(torch|torchvision|torchaudio)==' \
|
||||
> /tmp/torch-constraints.txt || true
|
||||
manifest=browser_tests/fixtures/data/customNodeManifest.json
|
||||
mapfile -t entries < <(jq -c '.[]' "$manifest")
|
||||
for entry in "${entries[@]}"; do
|
||||
repo=$(jq -r '.repo' <<<"$entry")
|
||||
pin=$(jq -r '.pin' <<<"$entry")
|
||||
name=$(basename "$repo")
|
||||
dir="ComfyUI/custom_nodes/$name"
|
||||
echo "::group::install $name"
|
||||
git clone --depth 1 "$repo" "$dir"
|
||||
if [ -n "$pin" ]; then
|
||||
git -C "$dir" fetch --depth 1 origin "$pin"
|
||||
git -C "$dir" checkout "$pin"
|
||||
fi
|
||||
if [ -f "$dir/requirements.txt" ]; then
|
||||
pip install -r "$dir/requirements.txt" -c /tmp/torch-constraints.txt
|
||||
fi
|
||||
echo "::endgroup::"
|
||||
done
|
||||
|
||||
# The VHS run-tier workflow reads input/plain_video.mp4.
|
||||
- name: Stage run-tier assets
|
||||
shell: bash
|
||||
run: cp browser_tests/assets/plain_video.mp4 ComfyUI/input/plain_video.mp4
|
||||
|
||||
# --cache-none so retried run-tier tests re-execute every node (a cached
|
||||
# node emits no `executing` event and would false-fail PARTIAL).
|
||||
- name: Start ComfyUI server
|
||||
shell: bash
|
||||
working-directory: ComfyUI
|
||||
run: |
|
||||
python main.py --cpu --multi-user --cache-none --front-end-root ../dist &
|
||||
wait-for-it --service 127.0.0.1:8188 -t 600
|
||||
|
||||
- name: Run custom-node suite
|
||||
env:
|
||||
PLAYWRIGHT_JSON_OUTPUT_NAME: custom-nodes-results.json
|
||||
run: |
|
||||
pnpm exec playwright test browser_tests/tests/customNodes/ \
|
||||
--project=chromium --reporter=list,json
|
||||
|
||||
# A skip here means a pack or devtools did not load: on this backend every
|
||||
# tier is meant to run, so a skip is a gate failure, not an honest pass.
|
||||
- name: Forbid skipped tests
|
||||
if: always()
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
skipped=$(jq '.stats.skipped' custom-nodes-results.json)
|
||||
echo "skipped tests: $skipped"
|
||||
if [ "$skipped" != "0" ]; then
|
||||
echo "::error::$skipped test(s) skipped - a manifest pack or devtools failed to load; skips are not acceptable in the gating job"
|
||||
# Recurse so specs nested under describe() blocks are found, and
|
||||
# print only the specs that actually skipped.
|
||||
jq -r '.. | objects
|
||||
| select(has("title") and has("tests"))
|
||||
| select(any(.tests[]?; .status == "skipped"))
|
||||
| .title' custom-nodes-results.json | sort -u | head -40
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Upload Playwright report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: playwright-report-custom-nodes
|
||||
path: playwright-report/
|
||||
retention-days: 7
|
||||
if-no-files-found: warn
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 59 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 58 KiB |
@@ -1,3 +1,3 @@
|
||||
<svg width="20" height="32" viewBox="0 0 20 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M20 32V0C20 5.39616 15.5172 9.78053 10 9.78053C4.48276 9.78053 0 5.416 0 0V32C0 26.6038 4.48276 22.2195 10 22.2195C15.5172 22.2195 20 26.6038 20 32Z" fill="#F2FF59"/>
|
||||
<svg preserveAspectRatio="none" width="100%" height="100%" overflow="visible" style="display: block;" viewBox="0 0 20 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path id="Vector" d="M20 32V0C20 5.39616 15.5172 9.78053 10 9.78053C4.48276 9.78053 0 5.416 0 0V32C0 26.6038 4.48276 22.2195 10 22.2195C15.5172 22.2195 20 26.6038 20 32Z" fill="var(--fill-0, #F2FF59)"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 279 B After Width: | Height: | Size: 380 B |
@@ -15,7 +15,7 @@ const { categories } = defineProps<{
|
||||
|
||||
const activeSection = ref(categories[0]?.value ?? '')
|
||||
|
||||
const HEADER_OFFSET_PX = -144
|
||||
const HEADER_OFFSET = -144
|
||||
const BOTTOM_THRESHOLD_PX = 4
|
||||
const SCROLL_SAFETY_MS = 1500
|
||||
|
||||
@@ -52,7 +52,7 @@ function scrollToSection(id: string) {
|
||||
const el = document.getElementById(id)
|
||||
if (el) {
|
||||
scrollTo(el, {
|
||||
offset: HEADER_OFFSET_PX,
|
||||
offset: HEADER_OFFSET,
|
||||
duration: 0.8,
|
||||
immediate: prefersReducedMotion(),
|
||||
onComplete: clearScrollLock
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<li
|
||||
class="flex items-start gap-2 text-primary-comfy-canvas before:mt-1.5 before:size-1.5 before:shrink-0 before:rounded-full before:bg-primary-comfy-yellow"
|
||||
class="flex items-start gap-2 text-primary-comfy-canvas before:mt-1.5 before:size-1.5 before:shrink-0 before:rounded-full before:bg-primary-comfy-yellow before:content-['']"
|
||||
>
|
||||
<slot />
|
||||
</li>
|
||||
|
||||
@@ -123,15 +123,6 @@ Browser tests in this project follow a specific organization pattern:
|
||||
- **Utilities**: Located in `utils/` - Common utility functions
|
||||
- `litegraphUtils.ts` - Utilities for working with LiteGraph nodes
|
||||
|
||||
### Custom-node regression suite
|
||||
|
||||
`tests/customNodes/` holds the manifest-driven suite that proves community
|
||||
custom-node packs load, render in both renderers (LiteGraph canvas and Vue
|
||||
Nodes 2.0), and execute real workflows. It has its own prerequisites, pnpm
|
||||
scripts (`pnpm test:custom-nodes` and per-pack variants), and a
|
||||
one-JSON-row process for adding packs - see
|
||||
[tests/customNodes/README.md](tests/customNodes/README.md).
|
||||
|
||||
## Writing Effective Tests
|
||||
|
||||
When writing new tests, follow these patterns:
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
{
|
||||
"last_node_id": 2,
|
||||
"last_link_id": 1,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "PrimitiveInt",
|
||||
"pos": { "0": 20, "1": 60 },
|
||||
"size": { "0": 250, "1": 80 },
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "INT",
|
||||
"type": "INT",
|
||||
"links": [1],
|
||||
"slot_index": 0
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "PrimitiveInt"
|
||||
},
|
||||
"widgets_values": [42, "fixed"]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "PreviewAny",
|
||||
"pos": { "0": 340, "1": 60 },
|
||||
"size": { "0": 220, "1": 60 },
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "source",
|
||||
"type": "*",
|
||||
"link": 1
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {
|
||||
"Node name for S&R": "PreviewAny"
|
||||
}
|
||||
}
|
||||
],
|
||||
"links": [[1, 1, 0, 2, 0, "INT"]],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
{
|
||||
"last_node_id": 2,
|
||||
"last_link_id": 1,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "StringFunction|pysssss",
|
||||
"pos": { "0": 20, "1": 60 },
|
||||
"size": { "0": 300, "1": 240 },
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "STRING",
|
||||
"type": "STRING",
|
||||
"links": [1],
|
||||
"slot_index": 0
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "StringFunction|pysssss"
|
||||
},
|
||||
"widgets_values": ["append", "yes", "hello", " world", ""]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "ShowText|pysssss",
|
||||
"pos": { "0": 380, "1": 60 },
|
||||
"size": { "0": 220, "1": 80 },
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "text",
|
||||
"type": "STRING",
|
||||
"link": 1
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "STRING",
|
||||
"type": "STRING",
|
||||
"links": null,
|
||||
"slot_index": 0
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "ShowText|pysssss"
|
||||
}
|
||||
}
|
||||
],
|
||||
"links": [[1, 1, 0, 2, 0, "STRING"]],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
{
|
||||
"last_node_id": 2,
|
||||
"last_link_id": 1,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "SimpleMathInt+",
|
||||
"pos": { "0": 20, "1": 60 },
|
||||
"size": { "0": 250, "1": 60 },
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "INT",
|
||||
"type": "INT",
|
||||
"links": [1],
|
||||
"slot_index": 0
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "SimpleMathInt+"
|
||||
},
|
||||
"widgets_values": [5]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "DisplayAny",
|
||||
"pos": { "0": 340, "1": 60 },
|
||||
"size": { "0": 220, "1": 80 },
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "input",
|
||||
"type": "*",
|
||||
"link": 1
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "STRING",
|
||||
"type": "STRING",
|
||||
"links": null,
|
||||
"slot_index": 0
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "DisplayAny"
|
||||
},
|
||||
"widgets_values": ["raw value"]
|
||||
}
|
||||
],
|
||||
"links": [[1, 1, 0, 2, 0, "INT"]],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
{
|
||||
"last_node_id": 4,
|
||||
"last_link_id": 2,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "ImpactInt",
|
||||
"pos": { "0": 20, "1": 60 },
|
||||
"size": { "0": 250, "1": 60 },
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "INT",
|
||||
"type": "INT",
|
||||
"links": [1],
|
||||
"slot_index": 0
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "ImpactInt"
|
||||
},
|
||||
"widgets_values": [42]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "PreviewAny",
|
||||
"pos": { "0": 340, "1": 60 },
|
||||
"size": { "0": 220, "1": 60 },
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "source",
|
||||
"type": "*",
|
||||
"link": 1
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {
|
||||
"Node name for S&R": "PreviewAny"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "ImpactFloat",
|
||||
"pos": { "0": 20, "1": 220 },
|
||||
"size": { "0": 250, "1": 60 },
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "FLOAT",
|
||||
"type": "FLOAT",
|
||||
"links": [2],
|
||||
"slot_index": 0
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "ImpactFloat"
|
||||
},
|
||||
"widgets_values": [3.14]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"type": "PreviewAny",
|
||||
"pos": { "0": 340, "1": 220 },
|
||||
"size": { "0": 220, "1": 60 },
|
||||
"flags": {},
|
||||
"order": 3,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "source",
|
||||
"type": "*",
|
||||
"link": 2
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {
|
||||
"Node name for S&R": "PreviewAny"
|
||||
}
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
[1, 1, 0, 2, 0, "INT"],
|
||||
[2, 3, 0, 4, 0, "FLOAT"]
|
||||
],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
{
|
||||
"last_node_id": 4,
|
||||
"last_link_id": 2,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "INTConstant",
|
||||
"pos": { "0": 20, "1": 60 },
|
||||
"size": { "0": 250, "1": 60 },
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "value",
|
||||
"type": "INT",
|
||||
"links": [1],
|
||||
"slot_index": 0
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "INTConstant"
|
||||
},
|
||||
"widgets_values": [42]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "PreviewAny",
|
||||
"pos": { "0": 340, "1": 60 },
|
||||
"size": { "0": 220, "1": 60 },
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "source",
|
||||
"type": "*",
|
||||
"link": 1
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {
|
||||
"Node name for S&R": "PreviewAny"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "FloatConstant",
|
||||
"pos": { "0": 20, "1": 220 },
|
||||
"size": { "0": 250, "1": 60 },
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "value",
|
||||
"type": "FLOAT",
|
||||
"links": [2],
|
||||
"slot_index": 0
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "FloatConstant"
|
||||
},
|
||||
"widgets_values": [3.14]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"type": "PreviewAny",
|
||||
"pos": { "0": 340, "1": 220 },
|
||||
"size": { "0": 220, "1": 60 },
|
||||
"flags": {},
|
||||
"order": 3,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "source",
|
||||
"type": "*",
|
||||
"link": 2
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {
|
||||
"Node name for S&R": "PreviewAny"
|
||||
}
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
[1, 1, 0, 2, 0, "INT"],
|
||||
[2, 3, 0, 4, 0, "FLOAT"]
|
||||
],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
{
|
||||
"last_node_id": 2,
|
||||
"last_link_id": 1,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "Seed (rgthree)",
|
||||
"pos": { "0": 20, "1": 60 },
|
||||
"size": { "0": 250, "1": 130 },
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "SEED",
|
||||
"type": "INT",
|
||||
"links": [1],
|
||||
"slot_index": 0
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "Seed (rgthree)"
|
||||
},
|
||||
"widgets_values": [12345]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "Display Any (rgthree)",
|
||||
"pos": { "0": 340, "1": 60 },
|
||||
"size": { "0": 220, "1": 60 },
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "source",
|
||||
"type": "*",
|
||||
"link": 1
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {
|
||||
"Node name for S&R": "Display Any (rgthree)"
|
||||
}
|
||||
}
|
||||
],
|
||||
"links": [[1, 1, 0, 2, 0, "INT"]],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
{
|
||||
"last_node_id": 3,
|
||||
"last_link_id": 2,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "VHS_LoadVideoPath",
|
||||
"pos": { "0": 20, "1": 60 },
|
||||
"size": { "0": 320, "1": 260 },
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": null
|
||||
},
|
||||
{
|
||||
"name": "frame_count",
|
||||
"type": "INT",
|
||||
"links": null
|
||||
},
|
||||
{
|
||||
"name": "audio",
|
||||
"type": "AUDIO",
|
||||
"links": null
|
||||
},
|
||||
{
|
||||
"name": "video_info",
|
||||
"type": "VHS_VIDEOINFO",
|
||||
"links": [1],
|
||||
"slot_index": 3
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "VHS_LoadVideoPath"
|
||||
},
|
||||
"widgets_values": ["input/plain_video.mp4", 0, 0, 0, 0, 0, 1]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "VHS_VideoInfo",
|
||||
"pos": { "0": 400, "1": 60 },
|
||||
"size": { "0": 240, "1": 260 },
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "video_info",
|
||||
"type": "VHS_VIDEOINFO",
|
||||
"link": 1
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "source_fps🟨",
|
||||
"type": "FLOAT",
|
||||
"links": [2],
|
||||
"slot_index": 0
|
||||
},
|
||||
{ "name": "source_frame_count🟨", "type": "INT", "links": null },
|
||||
{ "name": "source_duration🟨", "type": "FLOAT", "links": null },
|
||||
{ "name": "source_width🟨", "type": "INT", "links": null },
|
||||
{ "name": "source_height🟨", "type": "INT", "links": null },
|
||||
{ "name": "loaded_fps🟦", "type": "FLOAT", "links": null },
|
||||
{ "name": "loaded_frame_count🟦", "type": "INT", "links": null },
|
||||
{ "name": "loaded_duration🟦", "type": "FLOAT", "links": null },
|
||||
{ "name": "loaded_width🟦", "type": "INT", "links": null },
|
||||
{ "name": "loaded_height🟦", "type": "INT", "links": null }
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "VHS_VideoInfo"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "PreviewAny",
|
||||
"pos": { "0": 700, "1": 60 },
|
||||
"size": { "0": 220, "1": 60 },
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "source",
|
||||
"type": "*",
|
||||
"link": 2
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {
|
||||
"Node name for S&R": "PreviewAny"
|
||||
}
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
[1, 1, 3, 2, 0, "VHS_VIDEOINFO"],
|
||||
[2, 2, 0, 3, 0, "FLOAT"]
|
||||
],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
{
|
||||
"last_node_id": 3,
|
||||
"last_link_id": 2,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "Constant Number",
|
||||
"pos": { "0": 20, "1": 60 },
|
||||
"size": { "0": 250, "1": 100 },
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "NUMBER",
|
||||
"type": "NUMBER",
|
||||
"links": [1],
|
||||
"slot_index": 0
|
||||
},
|
||||
{
|
||||
"name": "FLOAT",
|
||||
"type": "FLOAT",
|
||||
"links": null,
|
||||
"slot_index": 1
|
||||
},
|
||||
{
|
||||
"name": "INT",
|
||||
"type": "INT",
|
||||
"links": null,
|
||||
"slot_index": 2
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "Constant Number"
|
||||
},
|
||||
"widgets_values": ["integer", 7]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "Number to Text",
|
||||
"pos": { "0": 340, "1": 60 },
|
||||
"size": { "0": 220, "1": 60 },
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "number",
|
||||
"type": "NUMBER",
|
||||
"link": 1
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "STRING",
|
||||
"type": "STRING",
|
||||
"links": [2],
|
||||
"slot_index": 0
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "Number to Text"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "Text to Console",
|
||||
"pos": { "0": 640, "1": 60 },
|
||||
"size": { "0": 250, "1": 80 },
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "text",
|
||||
"type": "STRING",
|
||||
"link": 2
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "STRING",
|
||||
"type": "STRING",
|
||||
"links": null,
|
||||
"slot_index": 0
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "Text to Console"
|
||||
},
|
||||
"widgets_values": ["Text Output"]
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
[1, 1, 0, 2, 0, "NUMBER"],
|
||||
[2, 2, 0, 3, 0, "STRING"]
|
||||
],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
{
|
||||
"last_node_id": 9,
|
||||
"last_link_id": 9,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 9,
|
||||
"type": "SaveImage",
|
||||
"pos": {
|
||||
"0": 64,
|
||||
"1": 104
|
||||
},
|
||||
"size": {
|
||||
"0": 210,
|
||||
"1": 58
|
||||
},
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {},
|
||||
"widgets_values": ["ComfyUI"]
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 1,
|
||||
"offset": [0, 0]
|
||||
},
|
||||
"linearData": {
|
||||
"inputs": [],
|
||||
"outputs": ["9"]
|
||||
}
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -268,16 +268,8 @@ export class ComfyPage {
|
||||
data: { username }
|
||||
})
|
||||
|
||||
if (resp.status() !== 200) {
|
||||
const body = await resp.text()
|
||||
// Persistent backends (Comfy Desktop server user storage) keep the user
|
||||
// across runs and do not list it via GET /api/users, so a duplicate means
|
||||
// it already exists. Returns the username since the generated id is not
|
||||
// retrievable here; only reached on single-user / default-resolving backends.
|
||||
if (resp.status() === 400 && body.includes('Duplicate username.'))
|
||||
return username
|
||||
throw new Error(`Failed to create user: ${body}`)
|
||||
}
|
||||
if (resp.status() !== 200)
|
||||
throw new Error(`Failed to create user: ${await resp.text()}`)
|
||||
|
||||
return await resp.json()
|
||||
}
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
import type { ObjectInfo } from '@e2e/fixtures/customNode/objectInfoValidator'
|
||||
import type {
|
||||
ExecutionError,
|
||||
PromptEvent,
|
||||
RunResult
|
||||
} from '@e2e/fixtures/customNode/runResult'
|
||||
import { classifyRun } from '@e2e/fixtures/customNode/runResult'
|
||||
|
||||
interface RawEvent {
|
||||
type: string
|
||||
node?: string | null
|
||||
exception_type?: string
|
||||
node_id?: string
|
||||
node_type?: string
|
||||
traceback?: string[]
|
||||
}
|
||||
|
||||
const TERMINAL = [
|
||||
'execution_success',
|
||||
'execution_error',
|
||||
'execution_interrupted'
|
||||
]
|
||||
|
||||
function toPromptEvent(raw: RawEvent): PromptEvent {
|
||||
if (raw.type === 'executing')
|
||||
return { type: 'executing', node: raw.node ?? null }
|
||||
if (raw.type === 'execution_error' || raw.type === 'execution_interrupted') {
|
||||
const error: ExecutionError = {
|
||||
exceptionType: raw.exception_type,
|
||||
nodeId: raw.node_id,
|
||||
nodeType: raw.node_type,
|
||||
traceback: raw.traceback
|
||||
}
|
||||
return { type: raw.type, error }
|
||||
}
|
||||
return { type: raw.type as 'execution_start' | 'execution_success' }
|
||||
}
|
||||
|
||||
/**
|
||||
* Drives a real ComfyUI backend through the running frontend. The verdict logic
|
||||
* lives in the pure `classifyRun`; this class is only the in-page IO plumbing.
|
||||
*/
|
||||
export class LocalDesktopTarget {
|
||||
async getObjectInfo(page: Page): Promise<ObjectInfo> {
|
||||
return await page.evaluate(async () => {
|
||||
const defs = await window.app!.api.getNodeDefs()
|
||||
const out: Record<
|
||||
string,
|
||||
{ input?: { required?: Record<string, unknown> } }
|
||||
> = {}
|
||||
for (const [name, def] of Object.entries(defs)) {
|
||||
const required = (
|
||||
def as { input?: { required?: Record<string, unknown> } }
|
||||
).input?.required
|
||||
out[name] = { input: { required } }
|
||||
}
|
||||
return out
|
||||
})
|
||||
}
|
||||
|
||||
async runWorkflow(
|
||||
page: Page,
|
||||
opts: { expectedNodeIds: string[]; timeoutMs: number }
|
||||
): Promise<RunResult> {
|
||||
await page.evaluate(
|
||||
(types) => {
|
||||
const sink = window as unknown as {
|
||||
__cnEvents: RawEvent[]
|
||||
__cnTapInstalled?: boolean
|
||||
}
|
||||
sink.__cnEvents = []
|
||||
if (sink.__cnTapInstalled) return
|
||||
sink.__cnTapInstalled = true
|
||||
for (const type of types)
|
||||
(window.app!.api as EventTarget).addEventListener(
|
||||
type,
|
||||
(event: Event) => {
|
||||
const detail: unknown = (event as CustomEvent).detail
|
||||
// `executing` dispatches a bare node-id string (api.ts
|
||||
// dispatchCustomEvent('executing', msg.data.node)); the other
|
||||
// events dispatch object payloads.
|
||||
sink.__cnEvents.push(
|
||||
detail !== null && typeof detail === 'object'
|
||||
? { type, ...(detail as Record<string, unknown>) }
|
||||
: { type, node: (detail as string | undefined) ?? null }
|
||||
)
|
||||
}
|
||||
)
|
||||
},
|
||||
['execution_start', ...TERMINAL, 'executing']
|
||||
)
|
||||
|
||||
// Browser path: app.queuePrompt runs graphToPrompt internally. Do NOT call
|
||||
// app.api.queuePrompt, which submits an already-serialized (empty) prompt.
|
||||
await page.evaluate(() => window.app!.queuePrompt(0))
|
||||
|
||||
await page
|
||||
.waitForFunction(
|
||||
(terminal) => {
|
||||
const events =
|
||||
(window as unknown as { __cnEvents?: { type: string }[] })
|
||||
.__cnEvents ?? []
|
||||
return events.some((event) => terminal.includes(event.type))
|
||||
},
|
||||
TERMINAL,
|
||||
{ timeout: opts.timeoutMs }
|
||||
)
|
||||
.catch((error: unknown) => {
|
||||
// Only a Playwright wait timeout means "no terminal event"; surface any
|
||||
// other fault instead of masquerading it as a run TIMEOUT.
|
||||
if (error instanceof Error && error.name === 'TimeoutError') return
|
||||
throw error
|
||||
})
|
||||
|
||||
const raw = await page.evaluate(
|
||||
() => (window as unknown as { __cnEvents?: RawEvent[] }).__cnEvents ?? []
|
||||
)
|
||||
const timedOut = !raw.some((event) => TERMINAL.includes(event.type))
|
||||
return classifyRun({
|
||||
events: raw.map(toPromptEvent),
|
||||
expectedNodeIds: opts.expectedNodeIds,
|
||||
timedOut
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const MANIFEST_PATH = fileURLToPath(
|
||||
new URL('../data/customNodeManifest.json', import.meta.url)
|
||||
)
|
||||
|
||||
const VALID_TIERS = ['load', 'run', 'connectivity', 'io'] as const
|
||||
|
||||
type CustomNodeTier = (typeof VALID_TIERS)[number]
|
||||
|
||||
export interface CustomNodeManifestEntry {
|
||||
pack: string
|
||||
repo: string
|
||||
pin: string
|
||||
tiers: CustomNodeTier[]
|
||||
// Frontend-format workflow (path relative to browser_tests/) loaded and queued
|
||||
// by the run/io tiers; empty or absent file = tier skips. Run the backend with
|
||||
// --cache-none, or repeat runs classify PARTIAL when cached nodes skip executing.
|
||||
workflow: string
|
||||
// Runtime class_type / object_info keys, NOT Python class names (e.g. rgthree
|
||||
// registers "Power Primitive (rgthree)", not RgthreePowerPrimitive).
|
||||
expectedNodes: string[]
|
||||
requiresGpu: boolean
|
||||
requiresModels: string[]
|
||||
timeoutMs: number
|
||||
// Optional; absent means true. Set false ONLY with evidence that the pack's
|
||||
// nodes fail to mount under Vue Nodes 2.0 (probe it - a README grumble is
|
||||
// not evidence). When false, renderer-specific Vue assertions are not
|
||||
// applied to this pack: its tests still run and pass their LiteGraph-canvas
|
||||
// assertions, so the zero-skip gate is preserved.
|
||||
vueNodesCompatible?: boolean
|
||||
}
|
||||
|
||||
function assertEntry(entry: CustomNodeManifestEntry, index: number): void {
|
||||
const missing: string[] = []
|
||||
if (typeof entry.pack !== 'string' || entry.pack.length === 0)
|
||||
missing.push('pack')
|
||||
// CI clones from repo, so an empty value must fail here, not mid-clone.
|
||||
// pin stays optional ("" = default branch head).
|
||||
if (typeof entry.repo !== 'string' || entry.repo.length === 0)
|
||||
missing.push('repo')
|
||||
// workflow may be an empty string until the pack gains a run-tier fixture.
|
||||
if (typeof entry.workflow !== 'string') missing.push('workflow')
|
||||
// A run-tier row with no workflow would otherwise skip locally, leaving
|
||||
// only CI's skip gate to notice the lost coverage. Fail at load instead.
|
||||
else if (
|
||||
entry.workflow === '' &&
|
||||
Array.isArray(entry.tiers) &&
|
||||
entry.tiers.includes('run')
|
||||
)
|
||||
missing.push('workflow (required when tiers includes "run")')
|
||||
if (!Array.isArray(entry.expectedNodes) || entry.expectedNodes.length === 0)
|
||||
missing.push('expectedNodes')
|
||||
if (!Array.isArray(entry.tiers) || entry.tiers.length === 0)
|
||||
missing.push('tiers')
|
||||
// A typo like "connectivty" would otherwise pass and silently drop that
|
||||
// tier's coverage - the exact drift this manifest exists to catch.
|
||||
else if (entry.tiers.some((tier) => !VALID_TIERS.includes(tier)))
|
||||
missing.push(`tiers (unknown value; allowed: ${VALID_TIERS.join(', ')})`)
|
||||
if (!Array.isArray(entry.requiresModels)) missing.push('requiresModels')
|
||||
if (typeof entry.requiresGpu !== 'boolean') missing.push('requiresGpu')
|
||||
if (!Number.isFinite(entry.timeoutMs) || entry.timeoutMs <= 0)
|
||||
missing.push('timeoutMs')
|
||||
if (
|
||||
entry.vueNodesCompatible !== undefined &&
|
||||
typeof entry.vueNodesCompatible !== 'boolean'
|
||||
)
|
||||
missing.push('vueNodesCompatible')
|
||||
if (missing.length > 0)
|
||||
throw new Error(
|
||||
`custom-node manifest entry ${index} (${entry.pack ?? '?'}) missing: ${missing.join(', ')}`
|
||||
)
|
||||
}
|
||||
|
||||
// Renderer passes for the load tier: LiteGraph canvas always, Vue Nodes 2.0
|
||||
// unless the pack declares itself incompatible. Conditional coverage, never a
|
||||
// test.skip - the caller still runs and gates on the returned passes.
|
||||
export function rendererPassesFor(
|
||||
entry: Pick<CustomNodeManifestEntry, 'vueNodesCompatible'>
|
||||
): boolean[] {
|
||||
return entry.vueNodesCompatible === false ? [false] : [false, true]
|
||||
}
|
||||
|
||||
export function loadManifest(): CustomNodeManifestEntry[] {
|
||||
const entries = JSON.parse(
|
||||
readFileSync(MANIFEST_PATH, 'utf-8')
|
||||
) as CustomNodeManifestEntry[]
|
||||
entries.forEach(assertEntry)
|
||||
return entries
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
import type { CustomNodeOutcome } from '@e2e/fixtures/customNode/runResult'
|
||||
|
||||
interface ObjectInfoNode {
|
||||
input?: { required?: Record<string, unknown> }
|
||||
}
|
||||
export type ObjectInfo = Record<string, ObjectInfoNode>
|
||||
|
||||
export interface ApiPromptNode {
|
||||
id: string
|
||||
classType: string
|
||||
inputs: Record<string, unknown>
|
||||
}
|
||||
|
||||
export function expectedNodesPresent(
|
||||
objectInfo: ObjectInfo,
|
||||
expectedNodes: string[]
|
||||
): { present: string[]; missing: string[] } {
|
||||
const present: string[] = []
|
||||
const missing: string[] = []
|
||||
for (const name of expectedNodes) {
|
||||
if (name in objectInfo) present.push(name)
|
||||
else missing.push(name)
|
||||
}
|
||||
return { present, missing }
|
||||
}
|
||||
|
||||
export interface PreValidationFailure {
|
||||
outcome: Extract<CustomNodeOutcome, 'MISSING_NODE' | 'VALIDATION_FAIL'>
|
||||
message: string
|
||||
}
|
||||
|
||||
// Turns an opaque backend 400 into a precise infra error before submit (BE-401):
|
||||
// every required input declared in object_info must be present in the fixture node.
|
||||
export function preValidate(
|
||||
objectInfo: ObjectInfo,
|
||||
nodes: ApiPromptNode[]
|
||||
): PreValidationFailure | null {
|
||||
for (const node of nodes) {
|
||||
const def = objectInfo[node.classType]
|
||||
if (!def)
|
||||
return {
|
||||
outcome: 'MISSING_NODE',
|
||||
message: `node ${node.id} ${node.classType} missing from object_info`
|
||||
}
|
||||
for (const name of Object.keys(def.input?.required ?? {})) {
|
||||
if (!(name in node.inputs))
|
||||
return {
|
||||
outcome: 'VALIDATION_FAIL',
|
||||
message: `node ${node.id} ${node.classType} missing required input "${name}"`
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
export type CustomNodeOutcome =
|
||||
| 'NOT_INSTALLED'
|
||||
| 'IMPORT_ERROR'
|
||||
| 'MISSING_NODE'
|
||||
| 'VALIDATION_FAIL'
|
||||
| 'EXECUTION_ERROR'
|
||||
| 'PARTIAL'
|
||||
| 'TIMEOUT'
|
||||
| 'PASS'
|
||||
|
||||
export interface ExecutionError {
|
||||
exceptionType?: string
|
||||
nodeId?: string
|
||||
nodeType?: string
|
||||
traceback?: string[]
|
||||
}
|
||||
|
||||
export type PromptEvent =
|
||||
| { type: 'execution_start' }
|
||||
| { type: 'executing'; node: string | null }
|
||||
| { type: 'execution_success' }
|
||||
| { type: 'execution_error'; error: ExecutionError }
|
||||
| { type: 'execution_interrupted'; error?: ExecutionError }
|
||||
|
||||
export interface RunResult {
|
||||
outcome: CustomNodeOutcome
|
||||
executedNodes: string[]
|
||||
error?: ExecutionError
|
||||
}
|
||||
|
||||
// `executing` with a non-null node is the only cache-safe "this node actually ran"
|
||||
// signal: ComfyUI emits it solely for non-cached nodes (execution.py:493), while the
|
||||
// `executed` message and /history outputs are replayed for cached nodes too.
|
||||
function executedNodesFrom(events: PromptEvent[]): string[] {
|
||||
const executed = new Set<string>()
|
||||
for (const event of events) {
|
||||
if (event.type === 'executing' && event.node !== null)
|
||||
executed.add(event.node)
|
||||
}
|
||||
return [...executed]
|
||||
}
|
||||
|
||||
export function classifyRun(input: {
|
||||
events: PromptEvent[]
|
||||
expectedNodeIds: string[]
|
||||
timedOut?: boolean
|
||||
}): RunResult {
|
||||
const { events, expectedNodeIds, timedOut = false } = input
|
||||
const executedNodes = executedNodesFrom(events)
|
||||
|
||||
if (timedOut) return { outcome: 'TIMEOUT', executedNodes }
|
||||
|
||||
const failure = events.find(
|
||||
(
|
||||
event
|
||||
): event is Extract<
|
||||
PromptEvent,
|
||||
{ type: 'execution_error' | 'execution_interrupted' }
|
||||
> =>
|
||||
event.type === 'execution_error' || event.type === 'execution_interrupted'
|
||||
)
|
||||
if (failure)
|
||||
return { outcome: 'EXECUTION_ERROR', executedNodes, error: failure.error }
|
||||
|
||||
if (!events.some((event) => event.type === 'execution_success'))
|
||||
return { outcome: 'TIMEOUT', executedNodes }
|
||||
|
||||
const ranEveryExpected = expectedNodeIds.every((node) =>
|
||||
executedNodes.includes(node)
|
||||
)
|
||||
return { outcome: ranEveryExpected ? 'PASS' : 'PARTIAL', executedNodes }
|
||||
}
|
||||
@@ -1,216 +0,0 @@
|
||||
// Type-driven pairing generator for the connectivity (contract) tier.
|
||||
// Wildcard `*` slots are excluded from pairing: LiteGraph.isValidConnection
|
||||
// short-circuits on `*` before the real type compare, so a wildcard link
|
||||
// proves reachability, not type interop.
|
||||
|
||||
export interface RawNodeDef {
|
||||
input?: {
|
||||
required?: Record<string, unknown>
|
||||
optional?: Record<string, unknown>
|
||||
}
|
||||
output?: unknown[]
|
||||
output_name?: string[]
|
||||
python_module?: string
|
||||
}
|
||||
|
||||
interface NormalizedSlot {
|
||||
name: string
|
||||
type: string
|
||||
}
|
||||
|
||||
export interface NormalizedNode {
|
||||
type: string
|
||||
pack: string
|
||||
inputs: NormalizedSlot[]
|
||||
outputs: NormalizedSlot[]
|
||||
}
|
||||
|
||||
interface SlotRef {
|
||||
nodeType: string
|
||||
pack: string
|
||||
slotName: string
|
||||
slotType: string
|
||||
}
|
||||
|
||||
export interface PlannedPair {
|
||||
producer: SlotRef
|
||||
consumer: SlotRef
|
||||
}
|
||||
|
||||
export interface PairingPlan {
|
||||
pairs: PlannedPair[]
|
||||
// No compatible partner in the loaded corpus: a health signal, not a failure.
|
||||
orphans: Array<SlotRef & { dir: 'in' | 'out' }>
|
||||
// `*` / empty-typed slots, excluded by design (false confidence).
|
||||
wildcards: Array<SlotRef & { dir: 'in' | 'out' }>
|
||||
// COMBO-literal slots, excluded by design: isValidConnection only compares
|
||||
// the string COMBO while each slot carries its own option set, so a
|
||||
// type-level pairing proves nothing (a checkpoint dropdown would "connect"
|
||||
// to a scheduler dropdown). Targeted fixtures cover combo behavior.
|
||||
combos: Array<SlotRef & { dir: 'in' | 'out' }>
|
||||
}
|
||||
|
||||
// Extends the shared outcome taxonomy (runResult.ts); ORPHAN_TYPE is a
|
||||
// plan-time skip so it never reaches the executor.
|
||||
// WIDGET_ONLY_ON_INSTANCE: the pack's own frontend JS rebuilt a declared
|
||||
// input as a widget-only control, so there is no socket to wire - excluded
|
||||
// like wildcards, never a failure and never a silent pass.
|
||||
export type ConnectivityOutcome =
|
||||
| 'PASS'
|
||||
| 'CONNECT_REJECTED'
|
||||
| 'ROUNDTRIP_LOST'
|
||||
| 'SLOT_CONTRACT_MISMATCH'
|
||||
| 'WIDGET_ONLY_ON_INSTANCE'
|
||||
|
||||
export function packOf(pythonModule: string | undefined): string {
|
||||
if (pythonModule?.startsWith('custom_nodes.'))
|
||||
return pythonModule.slice('custom_nodes.'.length)
|
||||
return 'core'
|
||||
}
|
||||
|
||||
export function isWildcard(type: string): boolean {
|
||||
return type === '' || type === '*'
|
||||
}
|
||||
|
||||
// COMBO list literals are arrays; their connectable socket type is COMBO.
|
||||
function slotTypeOf(rawType: unknown): string | null {
|
||||
if (Array.isArray(rawType)) return 'COMBO'
|
||||
return typeof rawType === 'string' ? rawType : null
|
||||
}
|
||||
|
||||
function inputSlots(
|
||||
entries: Record<string, unknown> | undefined
|
||||
): NormalizedSlot[] {
|
||||
if (!entries) return []
|
||||
const slots: NormalizedSlot[] = []
|
||||
for (const [name, spec] of Object.entries(entries)) {
|
||||
const specArray = Array.isArray(spec) ? spec : [spec]
|
||||
const type = slotTypeOf(specArray[0])
|
||||
if (type === null) continue
|
||||
const opts = specArray[1] as { socketless?: boolean } | undefined
|
||||
// socketless = widget only, no slot: not connectable, out of the matrix.
|
||||
if (opts?.socketless) continue
|
||||
slots.push({ name, type })
|
||||
}
|
||||
return slots
|
||||
}
|
||||
|
||||
export function normalizeNodeDefs(
|
||||
defs: Record<string, RawNodeDef>
|
||||
): NormalizedNode[] {
|
||||
return Object.entries(defs).map(([type, def]) => ({
|
||||
type,
|
||||
pack: packOf(def.python_module),
|
||||
inputs: [
|
||||
...inputSlots(def.input?.required),
|
||||
...inputSlots(def.input?.optional)
|
||||
],
|
||||
outputs: (def.output ?? []).flatMap((rawType, index) => {
|
||||
const slotType = slotTypeOf(rawType)
|
||||
if (slotType === null) return []
|
||||
// output_name entries can be non-strings (COMBO literals repeat the
|
||||
// option array); the slot name must stay a string.
|
||||
const rawName = def.output_name?.[index]
|
||||
return [
|
||||
{
|
||||
name: typeof rawName === 'string' ? rawName : slotType,
|
||||
type: slotType
|
||||
}
|
||||
]
|
||||
})
|
||||
}))
|
||||
}
|
||||
|
||||
// Faithful mirror of LiteGraph.isValidConnection (LiteGraphGlobal.ts):
|
||||
// wildcard/empty always match, comparison is case-insensitive, comma-unions
|
||||
// match if any member pair matches. The live sweep still connects through the
|
||||
// REAL validator, so any drift here surfaces as CONNECT_REJECTED, not a
|
||||
// silent false green.
|
||||
export function isTypeCompatible(a: string, b: string): boolean {
|
||||
if (isWildcard(a) || isWildcard(b)) return true
|
||||
const typeA = a.toLowerCase()
|
||||
const typeB = b.toLowerCase()
|
||||
if (typeA === typeB) return true
|
||||
if (!typeA.includes(',') && !typeB.includes(',')) return false
|
||||
return typeA
|
||||
.split(',')
|
||||
.some((memberA) =>
|
||||
typeB.split(',').some((memberB) => isTypeCompatible(memberA, memberB))
|
||||
)
|
||||
}
|
||||
|
||||
function slotRef(node: NormalizedNode, slot: NormalizedSlot): SlotRef {
|
||||
return {
|
||||
nodeType: node.type,
|
||||
pack: node.pack,
|
||||
slotName: slot.name,
|
||||
slotType: slot.type
|
||||
}
|
||||
}
|
||||
|
||||
// One representative compatible edge per slot, deterministically the first
|
||||
// partner in (nodeType, slotName) order. This bounds cost to O(slots) but
|
||||
// does NOT prove every pair; a full cross-product is an opt-in deep mode.
|
||||
export function planPairs(
|
||||
all: NormalizedNode[],
|
||||
corpusTypes: string[]
|
||||
): PairingPlan {
|
||||
const sorted = [...all].sort((a, b) => a.type.localeCompare(b.type))
|
||||
const pairable = (slot: NormalizedSlot) =>
|
||||
!isWildcard(slot.type) && slot.type !== 'COMBO'
|
||||
const producers: Array<SlotRef> = sorted.flatMap((node) =>
|
||||
node.outputs.filter(pairable).map((slot) => slotRef(node, slot))
|
||||
)
|
||||
const consumers: Array<SlotRef> = sorted.flatMap((node) =>
|
||||
node.inputs.filter(pairable).map((slot) => slotRef(node, slot))
|
||||
)
|
||||
|
||||
const plan: PairingPlan = {
|
||||
pairs: [],
|
||||
orphans: [],
|
||||
wildcards: [],
|
||||
combos: []
|
||||
}
|
||||
const seen = new Set<string>()
|
||||
const addPair = (producer: SlotRef, consumer: SlotRef) => {
|
||||
const key = `${producer.nodeType}.${producer.slotName}->${consumer.nodeType}.${consumer.slotName}`
|
||||
if (seen.has(key)) return
|
||||
seen.add(key)
|
||||
plan.pairs.push({ producer, consumer })
|
||||
}
|
||||
|
||||
const corpus = all.filter((node) => corpusTypes.includes(node.type))
|
||||
for (const node of corpus) {
|
||||
for (const slot of node.inputs) {
|
||||
if (isWildcard(slot.type)) {
|
||||
plan.wildcards.push({ ...slotRef(node, slot), dir: 'in' })
|
||||
continue
|
||||
}
|
||||
if (slot.type === 'COMBO') {
|
||||
plan.combos.push({ ...slotRef(node, slot), dir: 'in' })
|
||||
continue
|
||||
}
|
||||
const producer = producers.find((candidate) =>
|
||||
isTypeCompatible(candidate.slotType, slot.type)
|
||||
)
|
||||
if (producer) addPair(producer, slotRef(node, slot))
|
||||
else plan.orphans.push({ ...slotRef(node, slot), dir: 'in' })
|
||||
}
|
||||
for (const slot of node.outputs) {
|
||||
if (isWildcard(slot.type)) {
|
||||
plan.wildcards.push({ ...slotRef(node, slot), dir: 'out' })
|
||||
continue
|
||||
}
|
||||
if (slot.type === 'COMBO') {
|
||||
plan.combos.push({ ...slotRef(node, slot), dir: 'out' })
|
||||
continue
|
||||
}
|
||||
const consumer = consumers.find((candidate) =>
|
||||
isTypeCompatible(slot.type, candidate.slotType)
|
||||
)
|
||||
if (consumer) addPair(slotRef(node, slot), consumer)
|
||||
else plan.orphans.push({ ...slotRef(node, slot), dir: 'out' })
|
||||
}
|
||||
}
|
||||
return plan
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
[
|
||||
{
|
||||
"pack": "ComfyUI-Impact-Pack",
|
||||
"repo": "https://github.com/ltdrdata/ComfyUI-Impact-Pack",
|
||||
"pin": "",
|
||||
"tiers": ["load", "connectivity", "run"],
|
||||
"workflow": "assets/customNodes/impact_primitives_run.json",
|
||||
"expectedNodes": ["ImpactInt", "ImpactFloat"],
|
||||
"requiresGpu": false,
|
||||
"requiresModels": [],
|
||||
"timeoutMs": 30000
|
||||
},
|
||||
{
|
||||
"pack": "ComfyUI-VideoHelperSuite",
|
||||
"repo": "https://github.com/Kosinkadink/ComfyUI-VideoHelperSuite",
|
||||
"pin": "",
|
||||
"tiers": ["load", "connectivity", "run"],
|
||||
"workflow": "assets/customNodes/vhs_video_pipeline_run.json",
|
||||
"expectedNodes": ["VHS_LoadVideoPath", "VHS_VideoInfo"],
|
||||
"requiresGpu": false,
|
||||
"requiresModels": [],
|
||||
"timeoutMs": 90000
|
||||
},
|
||||
{
|
||||
"pack": "rgthree-comfy",
|
||||
"repo": "https://github.com/rgthree/rgthree-comfy",
|
||||
"pin": "",
|
||||
"tiers": ["load", "connectivity", "run"],
|
||||
"workflow": "assets/customNodes/rgthree_seed_display_run.json",
|
||||
"expectedNodes": ["Seed (rgthree)", "Display Any (rgthree)"],
|
||||
"requiresGpu": false,
|
||||
"requiresModels": [],
|
||||
"timeoutMs": 30000
|
||||
},
|
||||
{
|
||||
"pack": "ComfyUI_essentials",
|
||||
"repo": "https://github.com/cubiq/ComfyUI_essentials",
|
||||
"pin": "",
|
||||
"tiers": ["load", "connectivity", "run"],
|
||||
"workflow": "assets/customNodes/essentials_math_display_run.json",
|
||||
"expectedNodes": ["SimpleMathInt+", "DisplayAny"],
|
||||
"requiresGpu": false,
|
||||
"requiresModels": [],
|
||||
"timeoutMs": 30000
|
||||
},
|
||||
{
|
||||
"pack": "ComfyUI-KJNodes",
|
||||
"repo": "https://github.com/kijai/ComfyUI-KJNodes",
|
||||
"pin": "",
|
||||
"tiers": ["load", "connectivity", "run"],
|
||||
"workflow": "assets/customNodes/kjnodes_constants_run.json",
|
||||
"expectedNodes": ["INTConstant", "FloatConstant"],
|
||||
"requiresGpu": false,
|
||||
"requiresModels": [],
|
||||
"timeoutMs": 30000
|
||||
},
|
||||
{
|
||||
"pack": "ComfyUI-Custom-Scripts",
|
||||
"repo": "https://github.com/pythongosssss/ComfyUI-Custom-Scripts",
|
||||
"pin": "",
|
||||
"tiers": ["load", "connectivity", "run"],
|
||||
"workflow": "assets/customNodes/customscripts_string_show_run.json",
|
||||
"expectedNodes": ["StringFunction|pysssss", "ShowText|pysssss"],
|
||||
"requiresGpu": false,
|
||||
"requiresModels": [],
|
||||
"timeoutMs": 30000
|
||||
},
|
||||
{
|
||||
"pack": "was-node-suite-comfyui",
|
||||
"repo": "https://github.com/WASasquatch/was-node-suite-comfyui",
|
||||
"pin": "",
|
||||
"tiers": ["load", "connectivity", "run"],
|
||||
"workflow": "assets/customNodes/was_number_text_run.json",
|
||||
"expectedNodes": ["Constant Number", "Number to Text", "Text to Console"],
|
||||
"requiresGpu": false,
|
||||
"requiresModels": [],
|
||||
"timeoutMs": 30000
|
||||
}
|
||||
]
|
||||
@@ -34,10 +34,6 @@ export class AppModeHelper {
|
||||
public readonly outputPlaceholder: Locator
|
||||
/** The linear-mode widget list container (visible in app mode). */
|
||||
public readonly linearWidgets: Locator
|
||||
/** The validation warning shown above the app mode run button. */
|
||||
public readonly validationWarning: Locator
|
||||
/** The action that opens graph mode errors from the validation warning. */
|
||||
public readonly viewErrorsInGraphButton: Locator
|
||||
/** The PrimeVue Popover for the image picker (renders with role="dialog"). */
|
||||
public readonly imagePickerPopover: Locator
|
||||
/** The Run button in the app mode footer. */
|
||||
@@ -96,19 +92,13 @@ export class AppModeHelper {
|
||||
this.outputPlaceholder = this.page.getByTestId(
|
||||
TestIds.builder.outputPlaceholder
|
||||
)
|
||||
this.linearWidgets = this.page.getByTestId(TestIds.linear.widgetContainer)
|
||||
this.validationWarning = this.page.getByTestId(
|
||||
TestIds.linear.validationWarning
|
||||
)
|
||||
this.viewErrorsInGraphButton = this.validationWarning.getByTestId(
|
||||
TestIds.linear.viewErrorsInGraph
|
||||
)
|
||||
this.linearWidgets = this.page.getByTestId('linear-widgets')
|
||||
this.imagePickerPopover = this.page
|
||||
.getByRole('dialog')
|
||||
.filter({ has: this.page.getByRole('button', { name: 'All' }) })
|
||||
.first()
|
||||
this.runButton = this.page
|
||||
.getByTestId(TestIds.linear.runButton)
|
||||
.getByTestId('linear-run-button')
|
||||
.getByRole('button', { name: /run/i })
|
||||
this.welcome = this.page.getByTestId(TestIds.appMode.welcome)
|
||||
this.emptyWorkflowText = this.page.getByTestId(
|
||||
|
||||
@@ -172,9 +172,6 @@ export const TestIds = {
|
||||
mobileNavigation: 'linear-mobile-navigation',
|
||||
mobileWorkflows: 'linear-mobile-workflows',
|
||||
outputInfo: 'linear-output-info',
|
||||
runButton: 'linear-run-button',
|
||||
validationWarning: 'linear-validation-warning',
|
||||
viewErrorsInGraph: 'linear-view-errors',
|
||||
widgetContainer: 'linear-widgets'
|
||||
},
|
||||
builder: {
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
import type { ConsoleMessage, Page } from '@playwright/test'
|
||||
|
||||
export function collectConsoleErrors(page: Page): {
|
||||
errors: string[]
|
||||
stop: () => void
|
||||
} {
|
||||
const errors: string[] = []
|
||||
const listener = (message: ConsoleMessage) => {
|
||||
if (message.type() === 'error') errors.push(message.text())
|
||||
}
|
||||
page.on('console', listener)
|
||||
return { errors, stop: () => page.off('console', listener) }
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
// Boot every session with a blank graph (loadBlankWorkflow) instead of the
|
||||
// bundled default template, whose model references error on a model-less
|
||||
// harness backend and would trip the zero-visible-errors invariant. The
|
||||
// backend must run --multi-user (the repo-wide prerequisite for browser
|
||||
// tests): the fixture then writes these settings to the same per-worker
|
||||
// user the session reads, on CI and locally alike.
|
||||
// The shared fixture disables the errors tab to hide missing-model
|
||||
// indicators in unrelated suites; this suite exists to SEE errors, so every
|
||||
// error surface stays live.
|
||||
export const customNodeSuiteSettings = {
|
||||
'Comfy.TutorialCompleted': false,
|
||||
'Comfy.RightSidePanel.ShowErrorsTab': true
|
||||
}
|
||||
|
||||
// The tutorial path auto-opens the templates browser over the blank graph.
|
||||
// Dismiss it deterministically so no window ever shows unexpected UI.
|
||||
export async function dismissTemplatesDialog(
|
||||
comfyPage: ComfyPage
|
||||
): Promise<void> {
|
||||
const templates = comfyPage.page.getByTestId(TestIds.templates.content)
|
||||
await templates.waitFor({ state: 'visible' })
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await templates.waitFor({ state: 'hidden' })
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
// The app's user-visible error surfaces. A regression run is green only if a
|
||||
// human looking at the screen would see zero errors - not merely a clean
|
||||
// console. The harness self-check asserts the overlay IS visible after a
|
||||
// forced execution error, so these selectors are permanently proven live.
|
||||
export function errorSurfaces(page: Page): Record<string, Locator> {
|
||||
return {
|
||||
errorOverlay: page.getByTestId(TestIds.dialogs.errorOverlay),
|
||||
errorDialog: page.getByTestId(TestIds.dialogs.errorDialog),
|
||||
nodeRenderErrors: page.locator('.node-error'),
|
||||
errorToasts: page.locator('.p-toast-message-error')
|
||||
}
|
||||
}
|
||||
@@ -119,14 +119,6 @@ class NodeSlotReference {
|
||||
const rawPos = node.getConnectionPos(type === 'input', index)
|
||||
const convertedPos =
|
||||
window.app!.canvas.ds!.convertOffsetToCanvas(rawPos)
|
||||
// convertOffsetToCanvas is canvas-relative; page.mouse needs page
|
||||
// coordinates. Identical when the canvas sits at (0,0), but custom-node
|
||||
// JS can inject page chrome above it (e.g. rgthree's progress bar
|
||||
// shifts the canvas 16px down), which silently turned every slot drag
|
||||
// into a title-bar node drag.
|
||||
const rect = window.app!.canvas.canvas.getBoundingClientRect()
|
||||
convertedPos[0] += rect.left
|
||||
convertedPos[1] += rect.top
|
||||
|
||||
// Debug logging - convert Float64Arrays to regular arrays for visibility
|
||||
console.warn(
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import type { NodeError, PromptResponse } from '@/schemas/apiSchema'
|
||||
import { ExecutionHelper } from '@e2e/fixtures/helpers/ExecutionHelper'
|
||||
import { enableErrorsOverlay } from '@e2e/fixtures/helpers/ErrorsTabHelper'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
const SAVE_IMAGE_NODE_ID = '9'
|
||||
|
||||
function buildSaveImageRequiredInputError(): NodeError {
|
||||
return {
|
||||
class_type: 'SaveImage',
|
||||
dependent_outputs: [],
|
||||
errors: [
|
||||
{
|
||||
type: 'required_input_missing',
|
||||
message: 'Required input is missing: images',
|
||||
details: '',
|
||||
extra_info: { input_name: 'images' }
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
test.describe(
|
||||
'App mode validation warning',
|
||||
{ tag: ['@ui', '@workflow'] },
|
||||
() => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await enableErrorsOverlay(comfyPage)
|
||||
await comfyPage.workflow.loadWorkflow('linear-validation-warning')
|
||||
await comfyPage.appMode.toggleAppMode()
|
||||
await expect(comfyPage.appMode.linearWidgets).toBeVisible()
|
||||
})
|
||||
|
||||
test('opens graph errors from the app mode validation warning', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await expect(comfyPage.appMode.validationWarning).toBeHidden()
|
||||
|
||||
const exec = new ExecutionHelper(comfyPage)
|
||||
await exec.mockValidationFailure({
|
||||
[SAVE_IMAGE_NODE_ID]: buildSaveImageRequiredInputError()
|
||||
})
|
||||
|
||||
await comfyPage.appMode.runButton.click()
|
||||
const appModeOverlay = comfyPage.appMode.centerPanel.getByTestId(
|
||||
TestIds.dialogs.errorOverlay
|
||||
)
|
||||
await expect(appModeOverlay).toBeHidden()
|
||||
|
||||
await expect(comfyPage.appMode.validationWarning).toBeVisible()
|
||||
await expect(comfyPage.appMode.validationWarning).toContainText(
|
||||
/Required input missing/i
|
||||
)
|
||||
await expect(comfyPage.appMode.viewErrorsInGraphButton).toBeVisible()
|
||||
|
||||
await comfyPage.appMode.viewErrorsInGraphButton.click()
|
||||
|
||||
await expect(comfyPage.appMode.linearWidgets).toBeHidden()
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.propertiesPanel.root)
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.propertiesPanel.errorsTab)
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('keeps the app mode run button enabled when the warning is visible', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const exec = new ExecutionHelper(comfyPage)
|
||||
await exec.mockValidationFailure({
|
||||
[SAVE_IMAGE_NODE_ID]: buildSaveImageRequiredInputError()
|
||||
})
|
||||
|
||||
await comfyPage.appMode.runButton.click()
|
||||
await expect(comfyPage.appMode.validationWarning).toBeVisible()
|
||||
await expect(comfyPage.appMode.runButton).toBeEnabled()
|
||||
|
||||
let promptQueued = false
|
||||
const mockResponse: PromptResponse = {
|
||||
prompt_id: 'test-id',
|
||||
node_errors: {},
|
||||
error: ''
|
||||
}
|
||||
await comfyPage.page.route(
|
||||
'**/api/prompt',
|
||||
async (route) => {
|
||||
promptQueued = true
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
body: JSON.stringify(mockResponse)
|
||||
})
|
||||
},
|
||||
{ times: 1 }
|
||||
)
|
||||
|
||||
await comfyPage.appMode.runButton.click()
|
||||
|
||||
await expect.poll(() => promptQueued).toBe(true)
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -1,273 +0,0 @@
|
||||
# Adding a custom-node pack to the regression suite
|
||||
|
||||
The authoritative, step-by-step process for onboarding a new pack. Written to
|
||||
be followable by a human or an agent with no prior context. The suite itself
|
||||
(what it asserts, how to run it) is documented in [README.md](README.md);
|
||||
this file is only about adding coverage for a new pack.
|
||||
|
||||
The short version: install the pack on a local test backend, read the pack's
|
||||
real node keys out of `/object_info`, author one small model-free workflow,
|
||||
add one row to the manifest, prove it green locally, push. No new test code
|
||||
is ever needed - the specs iterate the manifest.
|
||||
|
||||
## Step 0 - prerequisites
|
||||
|
||||
- A local test backend and dev server set up exactly per the
|
||||
[README prerequisites](README.md#prerequisites). Do not skip `--multi-user`
|
||||
or `--cache-none`.
|
||||
- The pack's GitHub URL. The CI job clones and pip-installs it, so the repo
|
||||
must be public and its `requirements.txt` must install on a CPU-only
|
||||
runner. Packs that hard-require CUDA at import time cannot be onboarded
|
||||
until they guard that import.
|
||||
|
||||
## Step 1 - install the pack on the test backend
|
||||
|
||||
```bash
|
||||
cd <test-backend>/custom_nodes
|
||||
git clone https://github.com/<owner>/<pack>
|
||||
pip install -r <pack>/requirements.txt # if the pack has one
|
||||
```
|
||||
|
||||
If you run a CPU-only backend, constrain pip so the pack cannot swap in a
|
||||
different torch (CI does the same):
|
||||
|
||||
```bash
|
||||
pip freeze | grep -iE '^(torch|torchvision|torchaudio)==' > /tmp/torch-constraints.txt
|
||||
pip install -r <pack>/requirements.txt -c /tmp/torch-constraints.txt
|
||||
```
|
||||
|
||||
Restart the backend and check its log: the `Import times for custom nodes`
|
||||
block must list the pack with no `IMPORT FAILED` marker. An import failure is
|
||||
a pack bug or a missing dependency - fix that first; nothing downstream can
|
||||
work without a clean import.
|
||||
|
||||
While you are here, note whether the pack ships frontend JS:
|
||||
|
||||
```bash
|
||||
curl -s http://127.0.0.1:8288/extensions | python3 -c '
|
||||
import json, sys
|
||||
print(sum(1 for p in json.load(sys.stdin) if p.startswith("/extensions/<pack-dir-name>/")))
|
||||
'
|
||||
```
|
||||
|
||||
Non-zero means the pack patches the frontend at runtime (restyled nodes,
|
||||
rebuilt widgets, injected page chrome). Write that down - it decides whether
|
||||
Step 6 needs the CI-parity run. Both "green locally, red on CI" failures in
|
||||
the first 5-pack onboarding came from exactly this.
|
||||
|
||||
## Step 2 - read the pack's real node keys
|
||||
|
||||
The manifest's `expectedNodes` are the pack's `object_info` keys (the same
|
||||
strings the API uses as `class_type`). They are NOT Python class names and
|
||||
NOT display names. Get them from the running backend:
|
||||
|
||||
```bash
|
||||
curl -s http://127.0.0.1:8288/object_info | python3 -c '
|
||||
import json, sys
|
||||
d = json.load(sys.stdin)
|
||||
for key, node in sorted(d.items()):
|
||||
if node.get("python_module") == "custom_nodes.<pack-dir-name>":
|
||||
print(key)
|
||||
'
|
||||
```
|
||||
|
||||
Real traps this step catches (each one shipped in a real pack):
|
||||
|
||||
| Pack | Correct key | Wrong guesses that look right |
|
||||
| ---------------------- | ------------------- | ------------------------------------------------------------------------------- |
|
||||
| ComfyUI_essentials | `SimpleMathInt+` | `SimpleMathInt` (keys carry a trailing `+`, except `DisplayAny` which has none) |
|
||||
| ComfyUI-KJNodes | `INTConstant` | `INT Constant` (that is the display name) |
|
||||
| ComfyUI-Custom-Scripts | `ShowText\|pysssss` | `ShowText` (keys carry a `\|pysssss` suffix) |
|
||||
| rgthree-comfy | `Seed (rgthree)` | `RgthreeSeed` (the Python class name) |
|
||||
|
||||
## Step 3 - pick the expected nodes
|
||||
|
||||
Choose 2-3 nodes that are:
|
||||
|
||||
- **Model-free**: no checkpoint / VAE / CLIP inputs, no file downloads. The
|
||||
gate runs on CPU with no models installed. Constants, math, text, and
|
||||
display nodes are ideal.
|
||||
- **Wireable into a chain**: at least one producer (has a typed output) and
|
||||
one terminal node. A terminal node either has `output_node: true` in
|
||||
`/object_info` (it terminates a workflow by itself) or you end the chain in
|
||||
the core `PreviewAny` node, which accepts any type.
|
||||
|
||||
Check a candidate's inputs, outputs, and `output_node` flag:
|
||||
|
||||
```bash
|
||||
curl -s http://127.0.0.1:8288/object_info | python3 -c '
|
||||
import json, sys
|
||||
node = json.load(sys.stdin)["<exact key>"]
|
||||
print(json.dumps({k: node[k] for k in ("input", "output", "output_name", "output_node")}, indent=1))
|
||||
'
|
||||
```
|
||||
|
||||
Every node you list in `expectedNodes` must appear in the run workflow: the
|
||||
run tier asserts each one actually executes on the backend.
|
||||
|
||||
## Step 4 - author the run-tier workflow
|
||||
|
||||
Add one JSON file under `browser_tests/assets/customNodes/`, named
|
||||
`<pack>_<what it does>_run.json`. Copy an existing asset as the template
|
||||
(`rgthree_seed_display_run.json` is the simplest two-node example;
|
||||
`was_number_text_run.json` shows a 3-node chain). It is the frontend
|
||||
workflow format, hand-authorable:
|
||||
|
||||
- `nodes[].type` is the exact `object_info` key from Step 2.
|
||||
- `widgets_values` is an array in the node's widget order: the `input`
|
||||
entries from `/object_info` in declaration order (`required` first, then
|
||||
`optional`), keeping only widget-type inputs (INT, FLOAT, STRING, BOOLEAN,
|
||||
and combo lists) and skipping any input whose options say
|
||||
`"forceInput": true` (those are sockets, never widgets). A required input
|
||||
that is neither a widget type nor `forceInput` (a custom type like
|
||||
`NUMBER`) is also a socket: wire a link into it or the run fails on a
|
||||
missing required input.
|
||||
- A link is one row in `links`: `[link_id, from_node_id, from_slot,
|
||||
to_node_id, to_slot, "TYPE"]`, plus the matching `link`/`links` ids on the
|
||||
two nodes' `inputs`/`outputs` entries.
|
||||
- To wire INTO an input that would normally be a widget (no `forceInput`),
|
||||
the input entry also needs a `"widget": { "name": "<input name>" }` key -
|
||||
see `browser_tests/assets/vueNodes/linked-int-widget.json`.
|
||||
- Keep it tiny. Two to four nodes proving "this pack executes" is the whole
|
||||
job; feature-depth testing belongs to the pack's own repo.
|
||||
- If the workflow needs a media file, reuse something already under
|
||||
`browser_tests/assets/` (e.g. `plain_video.mp4`) - never commit new binary
|
||||
assets. CI stages `plain_video.mp4` into the backend's `input/` dir; if
|
||||
your workflow needs a different existing asset staged, extend the
|
||||
`Stage run-tier assets` step in
|
||||
`.github/workflows/ci-tests-custom-nodes.yaml`.
|
||||
- A media path in the workflow (e.g. `input/plain_video.mp4`) resolves
|
||||
against the backend process's working directory, not the repo. Locally,
|
||||
copy the file into the `input/` dir of the directory you launched
|
||||
`main.py` from, or the run tier fails validation with
|
||||
`Invalid file path` and the test reports `TIMEOUT`.
|
||||
|
||||
## Step 5 - add the manifest row
|
||||
|
||||
Append one object to `browser_tests/fixtures/data/customNodeManifest.json`:
|
||||
|
||||
| Field | Meaning |
|
||||
| -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `pack` | The pack's directory name under `custom_nodes/` (what `git clone` creates). |
|
||||
| `repo` | The GitHub URL CI clones. Required non-empty. |
|
||||
| `pin` | Commit SHA or tag CI checks out after cloning; `""` = default branch head. Pin when a pack breaks often; `""` also means new upstream regressions surface here first. |
|
||||
| `tiers` | Which tiers run: `load` (registers + renders in both renderers), `connectivity` (typed links + slot drags), `run` (executes the workflow). Use all three unless a tier is impossible for the pack. |
|
||||
| `workflow` | Path relative to `browser_tests/` of the Step 4 file. `""` only while the pack has no `run` tier. |
|
||||
| `expectedNodes` | The Step 2/3 keys. The load tier mounts each in both renderers; the run tier asserts each executes. |
|
||||
| `requiresGpu` | `true` only if execution genuinely needs CUDA. Such packs cannot use the `run` tier on the CPU gate. |
|
||||
| `requiresModels` | Model files the workflow needs (`[]` for the packs onboarded so far - keep it that way whenever possible). |
|
||||
| `timeoutMs` | Per-test budget. `30000` unless the workflow does real work (video decode uses `90000`). |
|
||||
| `vueNodesCompatible` | Optional, default `true`. See the policy below. Only ever set `false`, and only with evidence. |
|
||||
|
||||
`loadManifest()` (`browser_tests/fixtures/customNode/manifest.ts`) validates
|
||||
every row and fails loudly on a missing field, an empty `repo`, a misspelled
|
||||
tier, or a `run` tier with an empty `workflow`.
|
||||
|
||||
## Step 6 - prove it green locally, in both environments
|
||||
|
||||
### 6a - fast loop (dev server)
|
||||
|
||||
```bash
|
||||
pnpm test:custom-nodes
|
||||
```
|
||||
|
||||
Green means: every tier for every pack passes, zero skips, and the suite's
|
||||
zero-visible-errors invariant held (no error overlay, dialog, node error, or
|
||||
error toast at any point). Iterate here - it is the fastest loop.
|
||||
|
||||
### 6b - CI-parity run (required if the pack ships frontend JS)
|
||||
|
||||
The dev server never loads pack frontend JS (its `/extensions` list is
|
||||
core-only), so 6a exercises vanilla nodes. If Step 1 found frontend JS, a
|
||||
6a green proves nothing about the pack's real runtime behavior. CI serves
|
||||
the built frontend from the backend, so reproduce that exactly:
|
||||
|
||||
```bash
|
||||
pnpm build
|
||||
# relaunch the test backend with the same flags plus:
|
||||
# --front-end-root <repo>/dist
|
||||
# and make sure any run-tier media is in that process's input/ dir
|
||||
PLAYWRIGHT_TEST_URL=http://127.0.0.1:8288 pnpm exec playwright test \
|
||||
browser_tests/tests/customNodes/ --config playwright.chrome.config.ts --workers=1
|
||||
```
|
||||
|
||||
Both real failures during the first 5-pack onboarding only existed here:
|
||||
rgthree's progress bar shifted the canvas and broke slot-drag coordinates,
|
||||
and rgthree's Seed rebuilt a declared input as widget-only. Skipping 6b
|
||||
means discovering that class of problem one CI round at a time.
|
||||
|
||||
### Failure classes and what they mean
|
||||
|
||||
- **T0 fails only in the Vue Nodes pass** (the LiteGraph pass is green):
|
||||
suspected Vue Nodes 2.0 incompatibility. Follow the policy below - do not
|
||||
delete the pack, do not skip the test.
|
||||
- **Run tier fails with `PARTIAL`** (some expected nodes never executed):
|
||||
either the backend is missing `--cache-none` (cached nodes emit no
|
||||
`executing` event) or an expected node is not actually in the workflow.
|
||||
- **Run tier fails with an execution error**: the workflow JSON is wrong
|
||||
(bad key, wrong `widgets_values` order, type-mismatched link) or the pack
|
||||
cannot execute model-free. Fix the workflow or drop the node for a
|
||||
simpler one.
|
||||
- **Connectivity reports zero planned pairs**: the pack's slots are all
|
||||
wildcard or combo typed (both are excluded from pairing by design because
|
||||
they bypass the real type compare). The pack still gets load/run coverage.
|
||||
- **Connectivity logs `widget-only on instance` exclusions**: the pack's own
|
||||
frontend JS rebuilt a declared input as a widget-only control (rgthree's
|
||||
Seed does this to `seed`), so there is no socket to wire. Recorded and
|
||||
excluded, like wildcards - pack design, not a regression.
|
||||
|
||||
## Step 7 - push and watch CI
|
||||
|
||||
The `CI: Tests Custom Nodes` job (gating) re-does Steps 1-6 from scratch on
|
||||
every PR: clones every manifest `repo` at its `pin`, pip-installs under CPU
|
||||
torch constraints, boots the backend, runs the suite, and fails on any
|
||||
install error, any test failure, or any skipped test. A new pack row is
|
||||
automatically picked up; no workflow edit is needed unless you must stage an
|
||||
extra asset (Step 4).
|
||||
|
||||
If CI goes red where local was green, reproduce under the Step 6b
|
||||
environment before changing anything - the first such failure looked like
|
||||
upstream drift but was actually pack frontend JS that never loads under
|
||||
the dev server. Only after 6b reproduces it, decide: adjust the suite's
|
||||
expectation honestly (the way widget-only instance slots became a recorded
|
||||
exclusion) or, for genuine upstream drift (`pin: ""` tracks the pack's
|
||||
default branch head), pin the pack to its last good commit. Never paper
|
||||
over it with a skip.
|
||||
|
||||
## Vue Nodes 2.0 compatibility policy
|
||||
|
||||
Some packs only work under the LiteGraph canvas renderer and fail to mount
|
||||
under Vue Nodes 2.0. The suite must state that fact without producing false
|
||||
failures and without skipping tests:
|
||||
|
||||
1. **Default**: every pack is assumed compatible. New rows omit
|
||||
`vueNodesCompatible`.
|
||||
2. **Evidence rule**: set `"vueNodesCompatible": false` ONLY after the T0
|
||||
Vue pass fails for the pack locally while the LiteGraph pass is green,
|
||||
and the failure reproduces on a retry. A README grumble, a hunch, or an
|
||||
old forum thread is not evidence. Record the evidence (the failing
|
||||
assertion and the pack version) in the PR description of the change that
|
||||
sets the flag.
|
||||
3. **Effect of `false`**: the load tier runs its LiteGraph pass only, and
|
||||
the connectivity drag test does not drag that pack's edges under Vue
|
||||
Nodes. The tests still run and pass their canvas assertions - nothing is
|
||||
`test.skip`ped, so the CI skip gate stays honest. The run tier and the
|
||||
connectivity contract sweep are renderer-independent (they never toggle
|
||||
the Vue Nodes setting) and run for the pack regardless of the flag - a
|
||||
flagged pack must still execute and wire cleanly there.
|
||||
4. **Un-flagging**: if a pack ships Vue Nodes support later, delete the flag
|
||||
and prove T0 green in both passes locally.
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] Pack installs clean on the test backend (no `IMPORT FAILED`)
|
||||
- [ ] Checked whether the pack ships frontend JS (Step 1 `/extensions` probe)
|
||||
- [ ] `expectedNodes` copied exactly from `/object_info` (Step 2 traps checked)
|
||||
- [ ] All expected nodes are model-free and present in the run workflow
|
||||
- [ ] Workflow JSON under `browser_tests/assets/customNodes/`, no new binaries
|
||||
- [ ] Any media staged into the backend's own `input/` dir locally (Step 4)
|
||||
- [ ] Manifest row appended with every field (Step 5 table)
|
||||
- [ ] `vueNodesCompatible` omitted, or set `false` with recorded evidence
|
||||
- [ ] 6a green: `pnpm test:custom-nodes` against the dev server, zero skips
|
||||
- [ ] 6b green when the pack ships frontend JS: built dist + backend-served run
|
||||
- [ ] Pushed; `CI: Tests Custom Nodes` green on the PR
|
||||
@@ -1,104 +0,0 @@
|
||||
# Custom-node regression suite
|
||||
|
||||
Proves community custom-node packs work against this frontend across both
|
||||
renderers: nodes register, render under LiteGraph (canvas) AND Vue Nodes 2.0
|
||||
(DOM), and execute real workflows end to end. Manifest-driven: adding a pack
|
||||
is one JSON row, no new test code.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. A ComfyUI backend on `127.0.0.1:8288` with every manifest pack (the
|
||||
`pack` entries in `browser_tests/fixtures/data/customNodeManifest.json`)
|
||||
and ComfyUI_devtools
|
||||
installed. Launch it with `--multi-user` (the repo-wide browser-test
|
||||
prerequisite; the fixture writes per-worker user settings and the suite
|
||||
depends on them landing), `--cache-none` (repeat runs must re-execute
|
||||
every node or the executed-set check fails honestly with `PARTIAL`), and
|
||||
with `browser_tests/assets/plain_video.mp4` copied into its `input/` dir.
|
||||
2. The dev server proxying that backend:
|
||||
`DEV_SERVER_COMFYUI_URL=http://127.0.0.1:8288 pnpm dev`
|
||||
|
||||
## Running
|
||||
|
||||
| Script | What it does |
|
||||
| -------------------------------------- | ------------------------------------------------------------------------------------- |
|
||||
| `pnpm test:custom-nodes` | whole suite headless - the pass/fail gate (every tier passes, zero skips) |
|
||||
| `pnpm test:custom-nodes:watch` | headed slow-motion run of the browser tiers, hands-off watching |
|
||||
| `pnpm test:custom-nodes:debug` | step through the browser tiers in the Playwright Inspector (F10 step, F8 resume) |
|
||||
| `pnpm test:custom-nodes:impact-render` | Impact nodes render in both renderers (Inspector) |
|
||||
| `pnpm test:custom-nodes:impact-run` | Impact group workflow executes on the backend (Inspector) |
|
||||
| `pnpm test:custom-nodes:vhs-render` | VHS nodes render in both renderers (Inspector) |
|
||||
| `pnpm test:custom-nodes:vhs-run` | VHS decodes a real video through its node chain (Inspector) |
|
||||
| `pnpm test:custom-nodes:connectivity` | slot/type contract: type-paired links + real slot drags in both renderers (Inspector) |
|
||||
| `pnpm test:custom-nodes:self-check` | watches the harness catch a deliberate execution error |
|
||||
|
||||
Example - watch the VHS video-decode run step by step:
|
||||
|
||||
```bash
|
||||
pnpm test:custom-nodes:vhs-run
|
||||
```
|
||||
|
||||
Two windows open: the app under test and the Playwright Inspector. Press F10
|
||||
to execute one robot action at a time (workflow loads, queue fires, backend
|
||||
decodes the video), F8 to run to the end. While paused, look but do not click
|
||||
inside the app window - your clicks change the state the next assertion
|
||||
checks.
|
||||
|
||||
Any `-g` pattern works against the generic scripts, e.g.
|
||||
`pnpm test:custom-nodes:debug -g "Impact-Pack.*T0"`.
|
||||
|
||||
## What the tests assert
|
||||
|
||||
- **T0 load**: pack nodes are registered in `/object_info`, added to a
|
||||
cleared graph, counted exactly, and each added node's own `[data-node-id]`
|
||||
element mounts under Vue Nodes 2.0. Both renderer passes - unless the pack
|
||||
declares `vueNodesCompatible: false` in the manifest (evidence required;
|
||||
see [ADDING_PACKS.md](ADDING_PACKS.md)), in which case its tests run their
|
||||
LiteGraph-canvas assertions only. Never a skip.
|
||||
- **T1 run**: the manifest workflow is loaded and queued; the backend's
|
||||
`executing` event stream must contain every expected node id, and the run
|
||||
must end in `execution_success`.
|
||||
- **connectivity (contract)**: wiring-only, no execution. A
|
||||
type-pairing generator (`fixtures/customNode/typePairing.ts`) indexes
|
||||
`/object_info` producers/consumers and plans one representative typed edge
|
||||
per slot (wildcard `*` slots excluded - they bypass the real type compare
|
||||
and prove nothing). Each planned edge must connect through the real
|
||||
`isValidConnection` veto, then survive `serialize()` -> `configure()` and
|
||||
appear in `graphToPrompt()` output. A curated subset is additionally
|
||||
dragged for real - slot dot to slot dot - under both renderers. Orphan
|
||||
types (no partner in the corpus) are reported, never fake-failed. One
|
||||
representative edge per slot bounds cost; it does not prove all pairs.
|
||||
- **Zero visible errors, always**: every browser test asserts the app's
|
||||
error surfaces (error overlay, error dialog, node render errors, error
|
||||
toasts) are absent at start and after every pass. A run is green only if a
|
||||
human watching the screen sees no errors. The self-check inverts this: it
|
||||
forces a real execution error and asserts the overlay IS visible, proving
|
||||
the selectors stay live.
|
||||
|
||||
## Adding a pack
|
||||
|
||||
One manifest row plus one small workflow JSON - no new test code. The
|
||||
authoritative step-by-step process (verifying the pack's real node keys,
|
||||
authoring the run workflow, the `vueNodesCompatible` evidence rule, what CI
|
||||
does with the row) lives in [ADDING_PACKS.md](ADDING_PACKS.md). Follow it
|
||||
exactly; the traps it lists all shipped in real packs.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Pack frontend JS does not load under the Vite dev server.** The dev
|
||||
server's `/extensions` endpoint lists core extensions only, so nodes render
|
||||
vanilla locally even when the backend has the packs installed. CI serves
|
||||
the built frontend from the backend, where every pack's JS loads and can
|
||||
restyle nodes, rebuild widgets, or inject page chrome. Before pushing
|
||||
changes that could interact with pack JS, reproduce CI locally:
|
||||
`pnpm build`, relaunch the backend with `--front-end-root <repo>/dist`,
|
||||
and run the suite with `PLAYWRIGHT_TEST_URL` pointed at the backend.
|
||||
- Do not run with `--trace on` against system Chrome
|
||||
(`playwright.chrome.config.ts` pins trace off): the trace recorder crashes
|
||||
pages under the branded Chrome channel and every test reports a bogus 15s
|
||||
timeout.
|
||||
- In a git worktree whose `node_modules` is symlinked from another checkout,
|
||||
prefix scripts with `pnpm --config.verify-deps-before-run=false ...` to
|
||||
skip pnpm's auto-install check.
|
||||
- First run against a cold dev server can exceed the 15s per-test setup
|
||||
budget while Vite compiles; just run again.
|
||||
@@ -1,443 +0,0 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import {
|
||||
customNodeSuiteSettings,
|
||||
dismissTemplatesDialog
|
||||
} from '@e2e/fixtures/utils/customNodeSuite'
|
||||
import { loadManifest } from '@e2e/fixtures/customNode/manifest'
|
||||
import type {
|
||||
ConnectivityOutcome,
|
||||
PlannedPair,
|
||||
RawNodeDef
|
||||
} from '@e2e/fixtures/customNode/typePairing'
|
||||
import {
|
||||
isWildcard,
|
||||
normalizeNodeDefs,
|
||||
planPairs
|
||||
} from '@e2e/fixtures/customNode/typePairing'
|
||||
import { collectConsoleErrors } from '@e2e/fixtures/utils/consoleErrorCollector'
|
||||
import { errorSurfaces } from '@e2e/fixtures/utils/errorSurfaces'
|
||||
|
||||
const CORE_PROOF_NODE_COUNT = 16
|
||||
// A node may legitimately veto a wiring via onConnectInput; committed
|
||||
// entries here must name the veto. Green means actual rejections are a
|
||||
// subset of this list.
|
||||
const CONNECT_REJECTED_ALLOWLIST: string[] = []
|
||||
|
||||
test.use({ initialSettings: customNodeSuiteSettings })
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await dismissTemplatesDialog(comfyPage)
|
||||
})
|
||||
|
||||
async function expectNoVisibleErrors(
|
||||
page: Page,
|
||||
context: string
|
||||
): Promise<void> {
|
||||
for (const [surface, locator] of Object.entries(errorSurfaces(page)))
|
||||
await expect(locator, `${context}: ${surface}`).toHaveCount(0)
|
||||
}
|
||||
|
||||
function concrete(slot: { type: string }): boolean {
|
||||
return !isWildcard(slot.type)
|
||||
}
|
||||
|
||||
function isEntryInstalled(
|
||||
nodeTypes: Set<string>,
|
||||
entry: { expectedNodes: string[] }
|
||||
): boolean {
|
||||
return entry.expectedNodes.every((type) => nodeTypes.has(type))
|
||||
}
|
||||
|
||||
const connectivityEntries = loadManifest().filter((entry) =>
|
||||
entry.tiers.includes('connectivity')
|
||||
)
|
||||
|
||||
test('connectivity: every type-paired link survives model, serialize, and prompt round-trips', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
test.setTimeout(120_000)
|
||||
const defs = (await comfyPage.page.evaluate(() =>
|
||||
window.app!.api.getNodeDefs()
|
||||
)) as unknown as Record<string, RawNodeDef>
|
||||
const nodes = normalizeNodeDefs(defs)
|
||||
|
||||
// Pack-specific expectations apply only where the pack is installed; on a
|
||||
// backend without it (e.g. a generic CI runner) the core sweep still runs
|
||||
// and the absence is reported, never fake-failed or fake-passed.
|
||||
const nodeTypes = new Set(nodes.map((node) => node.type))
|
||||
const installedEntries = connectivityEntries.filter((entry) =>
|
||||
isEntryInstalled(nodeTypes, entry)
|
||||
)
|
||||
for (const entry of connectivityEntries)
|
||||
if (!installedEntries.includes(entry))
|
||||
console.log(`connectivity: ${entry.pack} not installed on this backend`)
|
||||
const packTypes = installedEntries.flatMap((entry) => entry.expectedNodes)
|
||||
const coreProof = nodes
|
||||
.filter(
|
||||
(node) =>
|
||||
node.pack === 'core' &&
|
||||
node.inputs.some(concrete) &&
|
||||
node.outputs.some(concrete)
|
||||
)
|
||||
.map((node) => node.type)
|
||||
.sort()
|
||||
.slice(0, CORE_PROOF_NODE_COUNT)
|
||||
const plan = planPairs(nodes, [...packTypes, ...coreProof])
|
||||
|
||||
expect(plan.pairs.length, 'pairing produced no edges').toBeGreaterThan(0)
|
||||
console.log(
|
||||
`connectivity plan: ${plan.pairs.length} pairs, ${plan.orphans.length} orphan slots, ${plan.wildcards.length} wildcard + ${plan.combos.length} combo slots (excluded by design)`
|
||||
)
|
||||
|
||||
for (const entry of installedEntries) {
|
||||
expect(
|
||||
plan.pairs.some(
|
||||
(pair) =>
|
||||
pair.producer.pack === entry.pack || pair.consumer.pack === entry.pack
|
||||
),
|
||||
`${entry.pack} contributes no pairs - corpus or pack attribution broke`
|
||||
).toBe(true)
|
||||
}
|
||||
|
||||
const consoleErrors = collectConsoleErrors(comfyPage.page)
|
||||
const results = await runPairsInPage(comfyPage.page, plan.pairs)
|
||||
consoleErrors.stop()
|
||||
expect(consoleErrors.errors, 'console errors during breadth sweep').toEqual(
|
||||
[]
|
||||
)
|
||||
|
||||
const widgetOnly = results.filter(
|
||||
(result) =>
|
||||
result.outcome ===
|
||||
('WIDGET_ONLY_ON_INSTANCE' satisfies ConnectivityOutcome)
|
||||
)
|
||||
if (widgetOnly.length > 0)
|
||||
console.log(
|
||||
`connectivity sweep: ${widgetOnly.length} pair(s) excluded - pack JS made the declared input widget-only: ${widgetOnly.map((result) => result.key).join('; ')}`
|
||||
)
|
||||
const failures = results.filter(
|
||||
(result) =>
|
||||
result.outcome !== ('PASS' satisfies ConnectivityOutcome) &&
|
||||
result.outcome !==
|
||||
('WIDGET_ONLY_ON_INSTANCE' satisfies ConnectivityOutcome) &&
|
||||
!(
|
||||
result.outcome === ('CONNECT_REJECTED' satisfies ConnectivityOutcome) &&
|
||||
CONNECT_REJECTED_ALLOWLIST.includes(result.key)
|
||||
)
|
||||
)
|
||||
const passed = results.filter((result) => result.outcome === 'PASS').length
|
||||
console.log(`connectivity sweep: ${passed}/${results.length} pairs PASS`)
|
||||
expect(failures, JSON.stringify(failures, null, 1)).toEqual([])
|
||||
expect(passed).toBeGreaterThan(0)
|
||||
await expectNoVisibleErrors(comfyPage.page, 'after breadth sweep')
|
||||
})
|
||||
|
||||
// Instance-level probe for the drag test: the first planned pair whose
|
||||
// producer output AND consumer input both exist on freshly created node
|
||||
// instances (pack JS can rebuild declared inputs as widget-only controls).
|
||||
function firstMaterializedPair(
|
||||
page: Page,
|
||||
pairs: PlannedPair[]
|
||||
): Promise<PlannedPair | null> {
|
||||
return page.evaluate((pairsInPage) => {
|
||||
for (const pair of pairsInPage) {
|
||||
const producer = window.LiteGraph!.createNode(pair.producer.nodeType)
|
||||
const consumer = window.LiteGraph!.createNode(pair.consumer.nodeType)
|
||||
const outFound = producer?.outputs.some(
|
||||
(slot) => slot.name === pair.producer.slotName
|
||||
)
|
||||
const inFound = consumer?.inputs.some(
|
||||
(slot) => slot.name === pair.consumer.slotName
|
||||
)
|
||||
if (outFound && inFound) return pair
|
||||
}
|
||||
return null
|
||||
}, pairs)
|
||||
}
|
||||
|
||||
// The self-check below runs THIS SAME executor on poisoned pairs; if it stops
|
||||
// being able to reject, every green sweep above is meaningless.
|
||||
function runPairsInPage(
|
||||
page: Page,
|
||||
pairs: PlannedPair[]
|
||||
): Promise<Array<{ key: string; outcome: string; detail?: string }>> {
|
||||
return page.evaluate(async (pairsInPage) => {
|
||||
const graph = window.app!.graph
|
||||
const report: Array<{
|
||||
key: string
|
||||
outcome: string
|
||||
detail?: string
|
||||
}> = []
|
||||
for (const pair of pairsInPage) {
|
||||
const key = `${pair.producer.nodeType}.${pair.producer.slotName} -> ${pair.consumer.nodeType}.${pair.consumer.slotName}`
|
||||
try {
|
||||
graph.clear()
|
||||
const producer = window.LiteGraph!.createNode(pair.producer.nodeType)
|
||||
const consumer = window.LiteGraph!.createNode(pair.consumer.nodeType)
|
||||
if (!producer || !consumer) {
|
||||
report.push({
|
||||
key,
|
||||
outcome: 'SLOT_CONTRACT_MISMATCH',
|
||||
detail: 'createNode returned null for a registered type'
|
||||
})
|
||||
continue
|
||||
}
|
||||
graph.add(producer)
|
||||
graph.add(consumer)
|
||||
const outIndex = producer.outputs.findIndex(
|
||||
(slot) => slot.name === pair.producer.slotName
|
||||
)
|
||||
const inIndex = consumer.inputs.findIndex(
|
||||
(slot) => slot.name === pair.consumer.slotName
|
||||
)
|
||||
if (outIndex < 0 || inIndex < 0) {
|
||||
// A pack's own frontend JS may rebuild a declared input as a
|
||||
// widget-only control (rgthree's Seed does this to `seed`). That is
|
||||
// pack design, not a wiring regression - excluded like wildcards.
|
||||
// A name that exists NEITHER as slot nor widget stays a hard fail.
|
||||
const widgetOnly =
|
||||
outIndex >= 0 &&
|
||||
(consumer.widgets ?? []).some(
|
||||
(widget) => widget.name === pair.consumer.slotName
|
||||
)
|
||||
report.push({
|
||||
key,
|
||||
outcome: widgetOnly
|
||||
? 'WIDGET_ONLY_ON_INSTANCE'
|
||||
: 'SLOT_CONTRACT_MISMATCH',
|
||||
detail: `declared slot missing on instance (out=${outIndex}, in=${inIndex})`
|
||||
})
|
||||
continue
|
||||
}
|
||||
const link = producer.connect(outIndex, consumer, inIndex)
|
||||
if (!link || consumer.inputs[inIndex]?.link == null) {
|
||||
report.push({ key, outcome: 'CONNECT_REJECTED' })
|
||||
continue
|
||||
}
|
||||
const serialized = graph.serialize()
|
||||
graph.configure(serialized)
|
||||
const restored = graph.getNodeById(consumer.id)
|
||||
if (restored?.inputs?.[inIndex]?.link == null) {
|
||||
report.push({
|
||||
key,
|
||||
outcome: 'ROUNDTRIP_LOST',
|
||||
detail: 'serialize/configure dropped the link'
|
||||
})
|
||||
continue
|
||||
}
|
||||
const prompt = (await window.app!.graphToPrompt()) as {
|
||||
output?: Record<string, { inputs?: Record<string, unknown> }>
|
||||
}
|
||||
const promptInput =
|
||||
prompt.output?.[String(consumer.id)]?.inputs?.[pair.consumer.slotName]
|
||||
if (!Array.isArray(promptInput)) {
|
||||
report.push({
|
||||
key,
|
||||
outcome: 'ROUNDTRIP_LOST',
|
||||
detail: 'link missing from graphToPrompt output'
|
||||
})
|
||||
continue
|
||||
}
|
||||
report.push({ key, outcome: 'PASS' })
|
||||
} catch (error) {
|
||||
report.push({
|
||||
key,
|
||||
outcome: 'SLOT_CONTRACT_MISMATCH',
|
||||
detail: `threw: ${String(error)}`
|
||||
})
|
||||
}
|
||||
}
|
||||
graph.clear()
|
||||
return report
|
||||
}, pairs)
|
||||
}
|
||||
|
||||
test('connectivity self-check: the executor rejects broken pairs', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const slot = (nodeType: string, slotName: string, slotType: string) => ({
|
||||
nodeType,
|
||||
pack: 'core',
|
||||
slotName,
|
||||
slotType
|
||||
})
|
||||
const results = await runPairsInPage(comfyPage.page, [
|
||||
{
|
||||
producer: slot('CheckpointLoaderSimple', 'MODEL', 'MODEL'),
|
||||
consumer: slot('KSampler', 'latent_image', 'LATENT')
|
||||
},
|
||||
{
|
||||
producer: slot('EmptyLatentImage', 'LATENT', 'LATENT'),
|
||||
consumer: slot('KSampler', 'does_not_exist', 'LATENT')
|
||||
}
|
||||
])
|
||||
expect(results.map((result) => result.outcome)).toEqual([
|
||||
'CONNECT_REJECTED',
|
||||
'SLOT_CONTRACT_MISMATCH'
|
||||
])
|
||||
})
|
||||
|
||||
test('connectivity drags: curated slot-to-slot wires connect under both renderers', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
test.setTimeout(120_000)
|
||||
const defs = (await comfyPage.page.evaluate(() =>
|
||||
window.app!.api.getNodeDefs()
|
||||
)) as unknown as Record<string, RawNodeDef>
|
||||
const nodes = normalizeNodeDefs(defs)
|
||||
|
||||
// Native anchor pair plus one in-pack, link-typed pair per connectivity
|
||||
// pack (derived from the same generator the breadth sweep uses).
|
||||
const dragEdges: PlannedPair[] = [
|
||||
{
|
||||
producer: {
|
||||
nodeType: 'EmptyLatentImage',
|
||||
pack: 'core',
|
||||
slotName: 'LATENT',
|
||||
slotType: 'LATENT'
|
||||
},
|
||||
consumer: {
|
||||
nodeType: 'KSampler',
|
||||
pack: 'core',
|
||||
slotName: 'latent_image',
|
||||
slotType: 'LATENT'
|
||||
}
|
||||
}
|
||||
]
|
||||
const nodeTypes = new Set(nodes.map((node) => node.type))
|
||||
for (const entry of connectivityEntries) {
|
||||
if (!isEntryInstalled(nodeTypes, entry)) {
|
||||
console.log(
|
||||
`connectivity drag: ${entry.pack} not installed on this backend`
|
||||
)
|
||||
continue
|
||||
}
|
||||
// Restrict the partner pool to the pack itself so the drag proves an
|
||||
// in-pack wiring; widget-backed primitive inputs render real slot dots
|
||||
// in Vue (verified empirically), so no slot type is excluded at plan time.
|
||||
const packPlan = planPairs(
|
||||
nodes.filter((node) => node.pack === entry.pack),
|
||||
entry.expectedNodes
|
||||
)
|
||||
expect(
|
||||
packPlan.pairs.length,
|
||||
`${entry.pack} has no in-pack draggable pair - drag coverage lost`
|
||||
).toBeGreaterThan(0)
|
||||
// The plan comes from object_info, but a pack's own JS can rebuild a
|
||||
// declared input as widget-only on the instance (rgthree's Seed does).
|
||||
// Drag the first pair whose slots actually materialize; a pack whose
|
||||
// every planned pair is customized away has no socket contract to drag.
|
||||
const inPack = await firstMaterializedPair(comfyPage.page, packPlan.pairs)
|
||||
if (!inPack) {
|
||||
console.log(
|
||||
`connectivity drag: ${entry.pack} planned pairs are widget-only on instances; drag not applicable`
|
||||
)
|
||||
continue
|
||||
}
|
||||
dragEdges.push(inPack)
|
||||
}
|
||||
|
||||
const vueIncompatiblePacks = new Set(
|
||||
connectivityEntries
|
||||
.filter((entry) => entry.vueNodesCompatible === false)
|
||||
.map((entry) => entry.pack)
|
||||
)
|
||||
for (const vueNodesEnabled of [false, true]) {
|
||||
const consoleErrors = collectConsoleErrors(comfyPage.page)
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.VueNodes.Enabled',
|
||||
vueNodesEnabled
|
||||
)
|
||||
|
||||
for (const edge of dragEdges) {
|
||||
if (vueNodesEnabled && vueIncompatiblePacks.has(edge.producer.pack)) {
|
||||
console.log(
|
||||
`connectivity drag: ${edge.producer.pack} declares vueNodesCompatible=false; Vue drag not applicable`
|
||||
)
|
||||
continue
|
||||
}
|
||||
await comfyPage.nodeOps.clearGraph()
|
||||
const producer = await comfyPage.nodeOps.addNode(
|
||||
edge.producer.nodeType,
|
||||
undefined,
|
||||
{ x: 150, y: 200 }
|
||||
)
|
||||
const consumer = await comfyPage.nodeOps.addNode(
|
||||
edge.consumer.nodeType,
|
||||
undefined,
|
||||
{ x: 700, y: 200 }
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const [outIndex, inIndex] = await comfyPage.page.evaluate(
|
||||
([producerId, consumerId, outName, inName]) => {
|
||||
const byId = (id: string) =>
|
||||
window.app!.graph.nodes.find((node) => String(node.id) === id)!
|
||||
const src = byId(producerId)
|
||||
const dst = byId(consumerId)
|
||||
return [
|
||||
src.outputs.findIndex((slot) => slot.name === outName),
|
||||
dst.inputs.findIndex((slot) => slot.name === inName)
|
||||
]
|
||||
},
|
||||
[
|
||||
String(producer.id),
|
||||
String(consumer.id),
|
||||
edge.producer.slotName,
|
||||
edge.consumer.slotName
|
||||
] as const
|
||||
)
|
||||
const key = `${edge.producer.nodeType}.${edge.producer.slotName} -> ${edge.consumer.nodeType}.${edge.consumer.slotName}`
|
||||
expect(outIndex, `${key}: producer slot on instance`).toBeGreaterThan(-1)
|
||||
expect(inIndex, `${key}: consumer slot on instance`).toBeGreaterThan(-1)
|
||||
|
||||
if (vueNodesEnabled) {
|
||||
await comfyPage.vueNodes.waitForNodes(2)
|
||||
// Output-side mirror of getInputSlotConnectionDot, addressed by
|
||||
// data-slot-key so shared-label ambiguity cannot misfire the drag.
|
||||
const outDot = comfyPage.page
|
||||
.locator(`[data-node-id="${String(producer.id)}"]`)
|
||||
.locator('.lg-slot--output')
|
||||
.filter({
|
||||
has: comfyPage.page.locator(
|
||||
`[data-slot-key="${String(producer.id)}-out-${outIndex}"]`
|
||||
)
|
||||
})
|
||||
.getByTestId('slot-connection-dot')
|
||||
const inDot = comfyPage.vueNodes.getInputSlotConnectionDot(
|
||||
String(consumer.id),
|
||||
inIndex
|
||||
)
|
||||
await outDot.dragTo(inDot)
|
||||
} else {
|
||||
await producer.connectOutput(outIndex, consumer, inIndex)
|
||||
}
|
||||
|
||||
const linked = await comfyPage.page.evaluate(
|
||||
([consumerId, index]) => {
|
||||
const node = window.app!.graph.nodes.find(
|
||||
(candidate) => String(candidate.id) === consumerId
|
||||
)
|
||||
return node?.inputs?.[Number(index)]?.link != null
|
||||
},
|
||||
[String(consumer.id), String(inIndex)] as const
|
||||
)
|
||||
expect(linked, `${key} with VueNodes=${vueNodesEnabled}`).toBe(true)
|
||||
}
|
||||
|
||||
consoleErrors.stop()
|
||||
expect(
|
||||
consoleErrors.errors,
|
||||
`console errors with VueNodes=${vueNodesEnabled}`
|
||||
).toEqual([])
|
||||
await expectNoVisibleErrors(
|
||||
comfyPage.page,
|
||||
`after drag pass VueNodes=${vueNodesEnabled}`
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -1,58 +0,0 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
|
||||
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import {
|
||||
customNodeSuiteSettings,
|
||||
dismissTemplatesDialog
|
||||
} from '@e2e/fixtures/utils/customNodeSuite'
|
||||
import { collectConsoleErrors } from '@e2e/fixtures/utils/consoleErrorCollector'
|
||||
import { errorSurfaces } from '@e2e/fixtures/utils/errorSurfaces'
|
||||
import { assetPath } from '@e2e/fixtures/utils/paths'
|
||||
|
||||
// Core-only, model-free workflow: the bundled default template references
|
||||
// model files a scoped test backend does not have, which rightly trips the
|
||||
// error surfaces this suite asserts are clean.
|
||||
const smokeWorkflow = JSON.parse(
|
||||
readFileSync(resolve(assetPath('customNodes/core_smoke.json')), 'utf-8')
|
||||
) as ComfyWorkflowJSON
|
||||
|
||||
test.use({ initialSettings: customNodeSuiteSettings })
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await dismissTemplatesDialog(comfyPage)
|
||||
})
|
||||
|
||||
test.describe('smoke: core workflow', () => {
|
||||
test('loads without console errors in both renderers', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
for (const vueNodesEnabled of [false, true]) {
|
||||
const consoleErrors = collectConsoleErrors(comfyPage.page)
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.VueNodes.Enabled',
|
||||
vueNodesEnabled
|
||||
)
|
||||
await comfyPage.workflow.loadGraphData(smokeWorkflow)
|
||||
await comfyPage.nextFrame()
|
||||
consoleErrors.stop()
|
||||
|
||||
expect(await comfyPage.nodeOps.getGraphNodesCount()).toBeGreaterThan(0)
|
||||
expect(
|
||||
consoleErrors.errors,
|
||||
`console errors (VueNodes=${vueNodesEnabled})`
|
||||
).toEqual([])
|
||||
for (const [surface, locator] of Object.entries(
|
||||
errorSurfaces(comfyPage.page)
|
||||
))
|
||||
await expect(
|
||||
locator,
|
||||
`${surface} (VueNodes=${vueNodesEnabled})`
|
||||
).toHaveCount(0)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,191 +0,0 @@
|
||||
/* oxlint-disable playwright/no-skipped-test -- tiers conditionally skip when the target backend lacks the required packs (installed custom nodes or devtools); this is the framework's designed environment gating, not a disabled test */
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import {
|
||||
customNodeSuiteSettings,
|
||||
dismissTemplatesDialog
|
||||
} from '@e2e/fixtures/utils/customNodeSuite'
|
||||
import { LocalDesktopTarget } from '@e2e/fixtures/customNode/ComfyTarget'
|
||||
import {
|
||||
loadManifest,
|
||||
rendererPassesFor
|
||||
} from '@e2e/fixtures/customNode/manifest'
|
||||
import { expectedNodesPresent } from '@e2e/fixtures/customNode/objectInfoValidator'
|
||||
import { collectConsoleErrors } from '@e2e/fixtures/utils/consoleErrorCollector'
|
||||
import { errorSurfaces } from '@e2e/fixtures/utils/errorSurfaces'
|
||||
import { assetPath } from '@e2e/fixtures/utils/paths'
|
||||
|
||||
const target = new LocalDesktopTarget()
|
||||
const OBJECT_INFO_SANITY_FLOOR = 50
|
||||
|
||||
test.use({ initialSettings: customNodeSuiteSettings })
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await dismissTemplatesDialog(comfyPage)
|
||||
})
|
||||
|
||||
async function expectNoVisibleErrors(
|
||||
page: Page,
|
||||
context: string
|
||||
): Promise<void> {
|
||||
for (const [surface, locator] of Object.entries(errorSurfaces(page)))
|
||||
await expect(locator, `${context}: ${surface}`).toHaveCount(0)
|
||||
}
|
||||
|
||||
function readWorkflow(relativePath: string): ComfyWorkflowJSON {
|
||||
return JSON.parse(
|
||||
readFileSync(resolve(relativePath), 'utf-8')
|
||||
) as ComfyWorkflowJSON
|
||||
}
|
||||
|
||||
async function nodeIdsByType(
|
||||
page: Page,
|
||||
classTypes: string[]
|
||||
): Promise<string[]> {
|
||||
return await page.evaluate((types) => {
|
||||
const nodes = window.app!.graph.nodes ?? []
|
||||
return nodes
|
||||
.filter((node) => {
|
||||
const n = node as { comfyClass?: string; type?: string }
|
||||
return types.includes(n.comfyClass ?? n.type ?? '')
|
||||
})
|
||||
.map((node) => String(node.id))
|
||||
}, classTypes)
|
||||
}
|
||||
|
||||
for (const entry of loadManifest()) {
|
||||
const workflowRelative = `browser_tests/${entry.workflow}`
|
||||
|
||||
test.describe(`custom node: ${entry.pack}`, () => {
|
||||
test('T0 load: expected nodes register and render in both renderers', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
test.setTimeout(entry.timeoutMs)
|
||||
const objectInfo = await target.getObjectInfo(comfyPage.page)
|
||||
expect(
|
||||
Object.keys(objectInfo).length,
|
||||
'object_info sanity floor'
|
||||
).toBeGreaterThan(OBJECT_INFO_SANITY_FLOOR)
|
||||
const { missing } = expectedNodesPresent(objectInfo, entry.expectedNodes)
|
||||
test.skip(
|
||||
missing.length > 0,
|
||||
`${entry.pack} not installed on this backend (missing: ${missing.join(', ')})`
|
||||
)
|
||||
await expectNoVisibleErrors(comfyPage.page, 'at startup')
|
||||
|
||||
// A pack that declares vueNodesCompatible: false is exercised under the
|
||||
// LiteGraph canvas only - rendering its nodes under Vue Nodes 2.0 would
|
||||
// fail for a known pack limitation, not a frontend regression. This is
|
||||
// conditional coverage, not a test skip: the test still runs and gates.
|
||||
const rendererPasses = rendererPassesFor(entry)
|
||||
if (entry.vueNodesCompatible === false)
|
||||
console.log(
|
||||
`${entry.pack} declares vueNodesCompatible=false; Vue Nodes pass not applicable`
|
||||
)
|
||||
for (const vueNodesEnabled of rendererPasses) {
|
||||
const consoleErrors = collectConsoleErrors(comfyPage.page)
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.VueNodes.Enabled',
|
||||
vueNodesEnabled
|
||||
)
|
||||
await comfyPage.nodeOps.clearGraph()
|
||||
|
||||
const addedIds: string[] = []
|
||||
for (const classType of entry.expectedNodes) {
|
||||
const node = await comfyPage.nodeOps.addNode(classType)
|
||||
addedIds.push(String(node.id))
|
||||
}
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe(
|
||||
entry.expectedNodes.length
|
||||
)
|
||||
// Vue Nodes 2.0 mounts each node as a [data-node-id] element; assert
|
||||
// the pack's own nodes rendered, not just any node count.
|
||||
if (vueNodesEnabled)
|
||||
for (const id of addedIds)
|
||||
await expect(comfyPage.vueNodes.getNodeLocator(id)).toBeVisible()
|
||||
|
||||
consoleErrors.stop()
|
||||
expect(
|
||||
consoleErrors.errors,
|
||||
`console errors with VueNodes=${vueNodesEnabled}`
|
||||
).toEqual([])
|
||||
await expectNoVisibleErrors(
|
||||
comfyPage.page,
|
||||
`after VueNodes=${vueNodesEnabled} pass`
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
test('T1 run: workflow executes without error', async ({ comfyPage }) => {
|
||||
test.setTimeout(entry.timeoutMs + 15_000)
|
||||
const objectInfo = await target.getObjectInfo(comfyPage.page)
|
||||
const { missing } = expectedNodesPresent(objectInfo, entry.expectedNodes)
|
||||
test.skip(
|
||||
!entry.tiers.includes('run') ||
|
||||
missing.length > 0 ||
|
||||
entry.requiresGpu ||
|
||||
entry.requiresModels.length > 0 ||
|
||||
!entry.workflow ||
|
||||
!existsSync(resolve(workflowRelative)),
|
||||
`run tier unavailable for ${entry.pack}`
|
||||
)
|
||||
await expectNoVisibleErrors(comfyPage.page, 'at startup')
|
||||
|
||||
await comfyPage.workflow.loadGraphData(readWorkflow(workflowRelative))
|
||||
const result = await target.runWorkflow(comfyPage.page, {
|
||||
expectedNodeIds: await nodeIdsByType(
|
||||
comfyPage.page,
|
||||
entry.expectedNodes
|
||||
),
|
||||
timeoutMs: entry.timeoutMs
|
||||
})
|
||||
|
||||
expect(result.outcome, JSON.stringify(result.error ?? {})).toBe('PASS')
|
||||
await expectNoVisibleErrors(comfyPage.page, 'after run')
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
test('harness self-check: captures a real execution error', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
test.setTimeout(30_000)
|
||||
const objectInfo = await target.getObjectInfo(comfyPage.page)
|
||||
expect(
|
||||
Object.keys(objectInfo).length,
|
||||
'object_info sanity floor'
|
||||
).toBeGreaterThan(OBJECT_INFO_SANITY_FLOOR)
|
||||
test.skip(
|
||||
!('DevToolsErrorRaiseNode' in objectInfo),
|
||||
'ComfyUI_devtools not installed on this backend'
|
||||
)
|
||||
|
||||
await comfyPage.workflow.loadGraphData(
|
||||
readWorkflow(assetPath('nodes/execution_error.json'))
|
||||
)
|
||||
const result = await target.runWorkflow(comfyPage.page, {
|
||||
expectedNodeIds: [],
|
||||
timeoutMs: 15000
|
||||
})
|
||||
|
||||
expect(result.outcome).toBe('EXECUTION_ERROR')
|
||||
expect(result.error?.exceptionType).toBeTruthy()
|
||||
// Proves the event tap captures node ids from the live `executing` stream
|
||||
// (its detail is a bare string): the failing node starts before it raises.
|
||||
expect(result.executedNodes.length).toBeGreaterThan(0)
|
||||
// Positive control for the zero-visible-errors invariant: a real execution
|
||||
// error MUST surface in the app's error overlay. If this fails, the
|
||||
// expectNoVisibleErrors selectors have rotted and every clean assertion in
|
||||
// this suite is meaningless.
|
||||
await expect(errorSurfaces(comfyPage.page).errorOverlay).toBeVisible()
|
||||
})
|
||||
@@ -1,29 +0,0 @@
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import {
|
||||
loadManifest,
|
||||
rendererPassesFor
|
||||
} from '@e2e/fixtures/customNode/manifest'
|
||||
|
||||
test.describe('customNode manifest', () => {
|
||||
test('loads entries with the shape the regression spec depends on', () => {
|
||||
const entries = loadManifest()
|
||||
expect(entries.length).toBeGreaterThan(0)
|
||||
for (const entry of entries) {
|
||||
expect(entry.pack).toBeTruthy()
|
||||
expect(entry.expectedNodes.length).toBeGreaterThan(0)
|
||||
expect(entry.tiers.length).toBeGreaterThan(0)
|
||||
}
|
||||
})
|
||||
|
||||
test('rendererPassesFor drops only the Vue pass, only on an explicit false', () => {
|
||||
expect(rendererPassesFor({})).toEqual([false, true])
|
||||
expect(rendererPassesFor({ vueNodesCompatible: true })).toEqual([
|
||||
false,
|
||||
true
|
||||
])
|
||||
expect(rendererPassesFor({ vueNodesCompatible: false })).toEqual([false])
|
||||
})
|
||||
})
|
||||
@@ -1,47 +0,0 @@
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import type { ObjectInfo } from '@e2e/fixtures/customNode/objectInfoValidator'
|
||||
import {
|
||||
expectedNodesPresent,
|
||||
preValidate
|
||||
} from '@e2e/fixtures/customNode/objectInfoValidator'
|
||||
|
||||
const objectInfo: ObjectInfo = {
|
||||
KSampler: { input: { required: { model: {}, seed: {} } } }
|
||||
}
|
||||
|
||||
test.describe('objectInfoValidator', () => {
|
||||
test('expectedNodesPresent splits present from missing', () => {
|
||||
const { present, missing } = expectedNodesPresent(objectInfo, [
|
||||
'KSampler',
|
||||
'Missing (rgthree)'
|
||||
])
|
||||
expect(present).toEqual(['KSampler'])
|
||||
expect(missing).toEqual(['Missing (rgthree)'])
|
||||
})
|
||||
|
||||
test('preValidate returns MISSING_NODE for an unregistered class', () => {
|
||||
const failure = preValidate(objectInfo, [
|
||||
{ id: '1', classType: 'Ghost', inputs: {} }
|
||||
])
|
||||
expect(failure?.outcome).toBe('MISSING_NODE')
|
||||
})
|
||||
|
||||
test('preValidate returns VALIDATION_FAIL naming the missing required input', () => {
|
||||
const failure = preValidate(objectInfo, [
|
||||
{ id: '3', classType: 'KSampler', inputs: { model: 0 } }
|
||||
])
|
||||
expect(failure?.outcome).toBe('VALIDATION_FAIL')
|
||||
expect(failure?.message).toContain('missing required input "seed"')
|
||||
})
|
||||
|
||||
test('preValidate passes when every required input is present', () => {
|
||||
expect(
|
||||
preValidate(objectInfo, [
|
||||
{ id: '3', classType: 'KSampler', inputs: { model: 0, seed: 1 } }
|
||||
])
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -1,71 +0,0 @@
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import { classifyRun } from '@e2e/fixtures/customNode/runResult'
|
||||
|
||||
test.describe('classifyRun', () => {
|
||||
test('PASS when every expected node appears in the executing stream', () => {
|
||||
const result = classifyRun({
|
||||
events: [
|
||||
{ type: 'execution_start' },
|
||||
{ type: 'executing', node: '1' },
|
||||
{ type: 'executing', node: '2' },
|
||||
{ type: 'executing', node: null },
|
||||
{ type: 'execution_success' }
|
||||
],
|
||||
expectedNodeIds: ['1', '2']
|
||||
})
|
||||
expect(result.outcome).toBe('PASS')
|
||||
expect(result.executedNodes).toEqual(['1', '2'])
|
||||
})
|
||||
|
||||
test('PARTIAL when a succeeding run replays a cached node that never emitted executing', () => {
|
||||
const result = classifyRun({
|
||||
events: [{ type: 'executing', node: '1' }, { type: 'execution_success' }],
|
||||
expectedNodeIds: ['1', '2']
|
||||
})
|
||||
expect(result.outcome).toBe('PARTIAL')
|
||||
expect(result.executedNodes).toEqual(['1'])
|
||||
})
|
||||
|
||||
test('EXECUTION_ERROR captures the failing node details', () => {
|
||||
const result = classifyRun({
|
||||
events: [
|
||||
{ type: 'executing', node: '1' },
|
||||
{
|
||||
type: 'execution_error',
|
||||
error: { exceptionType: 'ValueError', nodeId: '1' }
|
||||
}
|
||||
],
|
||||
expectedNodeIds: ['1']
|
||||
})
|
||||
expect(result.outcome).toBe('EXECUTION_ERROR')
|
||||
expect(result.error?.exceptionType).toBe('ValueError')
|
||||
})
|
||||
|
||||
test('EXECUTION_ERROR when the run is interrupted', () => {
|
||||
const result = classifyRun({
|
||||
events: [
|
||||
{ type: 'executing', node: '1' },
|
||||
{ type: 'execution_interrupted' }
|
||||
],
|
||||
expectedNodeIds: ['1']
|
||||
})
|
||||
expect(result.outcome).toBe('EXECUTION_ERROR')
|
||||
})
|
||||
|
||||
test('TIMEOUT when flagged or when no terminal event arrived', () => {
|
||||
const flagged = classifyRun({
|
||||
events: [{ type: 'executing', node: '1' }],
|
||||
expectedNodeIds: ['1'],
|
||||
timedOut: true
|
||||
})
|
||||
const noTerminal = classifyRun({
|
||||
events: [{ type: 'executing', node: '1' }],
|
||||
expectedNodeIds: ['1']
|
||||
})
|
||||
expect(flagged.outcome).toBe('TIMEOUT')
|
||||
expect(noTerminal.outcome).toBe('TIMEOUT')
|
||||
})
|
||||
})
|
||||
@@ -1,139 +0,0 @@
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import type { RawNodeDef } from '@e2e/fixtures/customNode/typePairing'
|
||||
import {
|
||||
isTypeCompatible,
|
||||
normalizeNodeDefs,
|
||||
packOf,
|
||||
planPairs
|
||||
} from '@e2e/fixtures/customNode/typePairing'
|
||||
|
||||
const DEFS: Record<string, RawNodeDef> = {
|
||||
LatentSource: {
|
||||
input: { required: {} },
|
||||
output: ['LATENT'],
|
||||
output_name: ['LATENT'],
|
||||
python_module: 'nodes'
|
||||
},
|
||||
LatentSink: {
|
||||
input: { required: { latent: ['LATENT', {}] } },
|
||||
output: [],
|
||||
python_module: 'custom_nodes.SomePack'
|
||||
},
|
||||
UnionSource: {
|
||||
input: { required: {} },
|
||||
output: ['STRING,INT'],
|
||||
output_name: ['value'],
|
||||
python_module: 'nodes'
|
||||
},
|
||||
IntSink: {
|
||||
input: { required: { value: ['int', {}] } },
|
||||
output: [],
|
||||
python_module: 'nodes'
|
||||
},
|
||||
ComboNode: {
|
||||
input: { required: { choice: [['a', 'b'], {}] } },
|
||||
output: [],
|
||||
python_module: 'nodes'
|
||||
},
|
||||
SocketlessNode: {
|
||||
input: { required: { hidden: ['STRING', { socketless: true }] } },
|
||||
output: [],
|
||||
python_module: 'nodes'
|
||||
},
|
||||
WildcardNode: {
|
||||
input: { required: { anything: ['*', {}] } },
|
||||
output: ['*'],
|
||||
output_name: ['out'],
|
||||
python_module: 'nodes'
|
||||
},
|
||||
OrphanNode: {
|
||||
input: { required: {} },
|
||||
output: ['NOBODY_CONSUMES_THIS'],
|
||||
output_name: ['orphan'],
|
||||
python_module: 'custom_nodes.OrphanPack'
|
||||
}
|
||||
}
|
||||
|
||||
test.describe('typePairing', () => {
|
||||
test('isTypeCompatible mirrors the real validator semantics', () => {
|
||||
expect(isTypeCompatible('LATENT', 'LATENT')).toBe(true)
|
||||
expect(isTypeCompatible('latent', 'LATENT')).toBe(true)
|
||||
expect(isTypeCompatible('LATENT', 'IMAGE')).toBe(false)
|
||||
expect(isTypeCompatible('STRING,INT', 'INT')).toBe(true)
|
||||
expect(isTypeCompatible('STRING,INT', 'FLOAT')).toBe(false)
|
||||
expect(isTypeCompatible('*', 'ANYTHING')).toBe(true)
|
||||
expect(isTypeCompatible('', 'ANYTHING')).toBe(true)
|
||||
})
|
||||
|
||||
test('packOf attributes core vs custom pack', () => {
|
||||
expect(packOf('nodes')).toBe('core')
|
||||
expect(packOf('comfy_extras.nodes_x')).toBe('core')
|
||||
expect(packOf('custom_nodes.ComfyUI-Impact-Pack')).toBe(
|
||||
'ComfyUI-Impact-Pack'
|
||||
)
|
||||
expect(packOf(undefined)).toBe('core')
|
||||
})
|
||||
|
||||
test('normalize maps COMBO literals and drops socketless inputs', () => {
|
||||
const nodes = normalizeNodeDefs(DEFS)
|
||||
const combo = nodes.find((n) => n.type === 'ComboNode')!
|
||||
expect(combo.inputs).toEqual([{ name: 'choice', type: 'COMBO' }])
|
||||
const socketless = nodes.find((n) => n.type === 'SocketlessNode')!
|
||||
expect(socketless.inputs).toEqual([])
|
||||
})
|
||||
|
||||
test('planPairs pairs exact and union types, deterministically', () => {
|
||||
const nodes = normalizeNodeDefs(DEFS)
|
||||
const plan = planPairs(nodes, ['LatentSink', 'IntSink'])
|
||||
const keys = plan.pairs.map(
|
||||
(p) =>
|
||||
`${p.producer.nodeType}.${p.producer.slotName}->${p.consumer.nodeType}.${p.consumer.slotName}`
|
||||
)
|
||||
expect(keys).toContain('LatentSource.LATENT->LatentSink.latent')
|
||||
expect(keys).toContain('UnionSource.value->IntSink.value')
|
||||
const again = planPairs(nodes, ['LatentSink', 'IntSink'])
|
||||
expect(again.pairs).toEqual(plan.pairs)
|
||||
})
|
||||
|
||||
test('COMBO literals are excluded from pairing with names coerced to strings', () => {
|
||||
const nodes = normalizeNodeDefs({
|
||||
ComboSource: {
|
||||
input: { required: {} },
|
||||
output: [['A', 'B', 'C']],
|
||||
output_name: [['A', 'B', 'C'] as unknown as string],
|
||||
python_module: 'nodes'
|
||||
},
|
||||
...DEFS
|
||||
})
|
||||
const source = nodes.find((n) => n.type === 'ComboSource')!
|
||||
expect(source.outputs).toEqual([{ name: 'COMBO', type: 'COMBO' }])
|
||||
const plan = planPairs(nodes, ['ComboSource', 'ComboNode'])
|
||||
expect(plan.pairs).toEqual([])
|
||||
expect(plan.combos.map((s) => `${s.nodeType}.${s.slotName}`)).toEqual([
|
||||
'ComboSource.COMBO',
|
||||
'ComboNode.choice'
|
||||
])
|
||||
})
|
||||
|
||||
test('wildcard slots are excluded, orphan types recorded not failed', () => {
|
||||
const nodes = normalizeNodeDefs(DEFS)
|
||||
const plan = planPairs(nodes, ['WildcardNode', 'OrphanNode'])
|
||||
expect(plan.wildcards.map((w) => w.nodeType)).toEqual([
|
||||
'WildcardNode',
|
||||
'WildcardNode'
|
||||
])
|
||||
expect(plan.orphans).toEqual([
|
||||
{
|
||||
nodeType: 'OrphanNode',
|
||||
pack: 'OrphanPack',
|
||||
slotName: 'orphan',
|
||||
slotType: 'NOBODY_CONSUMES_THIS',
|
||||
dir: 'out'
|
||||
}
|
||||
])
|
||||
expect(plan.pairs).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,5 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { toLinkId } from '@/types/linkId'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
@@ -16,10 +15,9 @@ test.describe('Graph', { tag: ['@smoke', '@canvas'] }, () => {
|
||||
await comfyPage.workflow.loadWorkflow('inputs/input_order_swap')
|
||||
await expect
|
||||
.poll(() =>
|
||||
comfyPage.page.evaluate(
|
||||
(linkId) => window.app!.graph!.links.get(linkId)?.target_slot,
|
||||
toLinkId(1)
|
||||
)
|
||||
comfyPage.page.evaluate(() => {
|
||||
return window.app!.graph!.links.get(1)?.target_slot
|
||||
})
|
||||
)
|
||||
.toBe(1)
|
||||
})
|
||||
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
test.describe('Linear Mode', { tag: '@ui' }, () => {
|
||||
test('Displays linear controls when app mode active', async ({
|
||||
@@ -17,9 +16,7 @@ test.describe('Linear Mode', { tag: '@ui' }, () => {
|
||||
test('Run button visible in linear mode', async ({ comfyPage }) => {
|
||||
await comfyPage.appMode.enterAppModeWithInputs([])
|
||||
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.linear.runButton)
|
||||
).toBeVisible()
|
||||
await expect(comfyPage.page.getByTestId('linear-run-button')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Workflow info section visible', async ({ comfyPage }) => {
|
||||
|
||||
@@ -52,15 +52,6 @@
|
||||
"test:browser": "pnpm exec playwright test",
|
||||
"test:browser:coverage": "cross-env COLLECT_COVERAGE=true pnpm test:browser",
|
||||
"test:browser:local": "cross-env PLAYWRIGHT_LOCAL=1 PLAYWRIGHT_TEST_URL=http://localhost:5173 pnpm test:browser",
|
||||
"test:custom-nodes": "cross-env PLAYWRIGHT_TEST_URL=http://localhost:5173 pnpm exec playwright test browser_tests/tests/customNodes/ --config playwright.chrome.config.ts --workers=1",
|
||||
"test:custom-nodes:watch": "cross-env PLAYWRIGHT_TEST_URL=http://localhost:5173 PLAYWRIGHT_LOCAL=1 SLOW_MO=300 pnpm exec playwright test browser_tests/tests/customNodes/customNode.regression.spec.ts browser_tests/tests/customNodes/connectivity.spec.ts --config playwright.chrome.config.ts --workers=1 --headed",
|
||||
"test:custom-nodes:debug": "cross-env PLAYWRIGHT_TEST_URL=http://localhost:5173 pnpm exec playwright test browser_tests/tests/customNodes/customNode.regression.spec.ts browser_tests/tests/customNodes/connectivity.spec.ts --config playwright.chrome.config.ts --workers=1 --debug",
|
||||
"test:custom-nodes:impact-render": "pnpm test:custom-nodes:debug -g \"ComfyUI-Impact-Pack.*T0\"",
|
||||
"test:custom-nodes:impact-run": "pnpm test:custom-nodes:debug -g \"ComfyUI-Impact-Pack.*T1\"",
|
||||
"test:custom-nodes:vhs-render": "pnpm test:custom-nodes:debug -g \"VideoHelperSuite.*T0\"",
|
||||
"test:custom-nodes:vhs-run": "pnpm test:custom-nodes:debug -g \"VideoHelperSuite.*T1\"",
|
||||
"test:custom-nodes:connectivity": "pnpm test:custom-nodes:debug -g \"connectivity\"",
|
||||
"test:custom-nodes:self-check": "pnpm test:custom-nodes:watch -g \"self-check\"",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"test:coverage:critical": "cross-env COVERAGE_CRITICAL=true vitest run --coverage",
|
||||
"test:unit": "vitest run",
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
import { defineConfig } from '@playwright/test'
|
||||
|
||||
import base from './playwright.config'
|
||||
|
||||
// Run against the system-installed Google Chrome (no bundled-chromium download).
|
||||
// trace stays off: Playwright's trace recorder crashes pages under the branded
|
||||
// Chrome channel on this machine (instant browser close, reported as timeout).
|
||||
export default defineConfig(base, {
|
||||
use: { channel: 'chrome', video: 'off', trace: 'off' }
|
||||
})
|
||||
@@ -37,7 +37,7 @@
|
||||
size="unset"
|
||||
class="min-h-8 rounded-lg px-3 py-2 text-xs font-normal"
|
||||
data-testid="error-overlay-see-errors"
|
||||
@click="viewErrorsInGraph"
|
||||
@click="seeErrors"
|
||||
>
|
||||
{{
|
||||
appMode
|
||||
@@ -67,18 +67,31 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useErrorOverlayState } from '@/components/error/useErrorOverlayState'
|
||||
import { useViewErrorsInGraph } from '@/composables/useViewErrorsInGraph'
|
||||
|
||||
const { appMode = false } = defineProps<{ appMode?: boolean }>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
const { viewErrorsInGraph } = useViewErrorsInGraph()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
|
||||
const { isVisible, overlayMessage, overlayTitle } = useErrorOverlayState()
|
||||
|
||||
function dismiss() {
|
||||
executionErrorStore.dismissErrorOverlay()
|
||||
}
|
||||
|
||||
function seeErrors() {
|
||||
canvasStore.linearMode = false
|
||||
if (canvasStore.canvas) {
|
||||
canvasStore.canvas.deselectAll()
|
||||
canvasStore.updateSelectedItems()
|
||||
}
|
||||
|
||||
rightSidePanelStore.openPanel('errors')
|
||||
executionErrorStore.dismissErrorOverlay()
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -224,7 +224,7 @@ const handleOpenUserSettings = () => {
|
||||
}
|
||||
|
||||
const handleOpenPlansAndPricing = () => {
|
||||
subscriptionDialog.showPricingTable({ reason: 'avatar_menu_plans' })
|
||||
subscriptionDialog.showPricingTable()
|
||||
emit('close')
|
||||
}
|
||||
|
||||
@@ -239,7 +239,8 @@ const handleOpenPlanAndCreditsSettings = () => {
|
||||
}
|
||||
|
||||
const handleTopUp = () => {
|
||||
useTelemetry()?.trackAddApiCreditButtonClicked({ source: 'avatar_menu' })
|
||||
// Track purchase credits entry from avatar popover
|
||||
useTelemetry()?.trackAddApiCreditButtonClicked()
|
||||
dialogService.showTopUpCreditsDialog()
|
||||
emit('close')
|
||||
}
|
||||
@@ -253,7 +254,7 @@ const handleOpenPartnerNodesInfo = () => {
|
||||
}
|
||||
|
||||
const handleUpgradeToAddCredits = () => {
|
||||
subscriptionDialog.showPricingTable({ reason: 'upgrade_to_add_credits' })
|
||||
subscriptionDialog.showPricingTable()
|
||||
emit('close')
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,6 @@ const { isFreeTier } = useBillingContext()
|
||||
const subscriptionDialog = useSubscriptionDialog()
|
||||
|
||||
function handleClick() {
|
||||
subscriptionDialog.showPricingTable({ reason: 'subscribe_now_button' })
|
||||
subscriptionDialog.showPricingTable()
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { ComputedRef, Ref } from 'vue'
|
||||
|
||||
import type { SubscriptionDialogOptions } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
|
||||
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import type {
|
||||
BillingStatus,
|
||||
@@ -76,10 +75,9 @@ export interface BillingActions {
|
||||
*/
|
||||
requireActiveSubscription: () => Promise<void>
|
||||
/**
|
||||
* Shows the subscription dialog. Pass a reason so the paywall open and any
|
||||
* downstream checkout stay attributed to the triggering product moment.
|
||||
* Shows the subscription dialog.
|
||||
*/
|
||||
showSubscriptionDialog: (options?: SubscriptionDialogOptions) => void
|
||||
showSubscriptionDialog: () => void
|
||||
}
|
||||
|
||||
export interface BillingState {
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
getTierFeatures
|
||||
} from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import type { SubscriptionDialogOptions } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
|
||||
import type {
|
||||
PreviewSubscribeOptions,
|
||||
SubscribeOptions
|
||||
@@ -282,8 +281,8 @@ function useBillingContextInternal(): BillingContext {
|
||||
return activeContext.value.requireActiveSubscription()
|
||||
}
|
||||
|
||||
function showSubscriptionDialog(options?: SubscriptionDialogOptions) {
|
||||
return activeContext.value.showSubscriptionDialog(options)
|
||||
function showSubscriptionDialog() {
|
||||
return activeContext.value.showSubscriptionDialog()
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -2,7 +2,6 @@ import { computed, ref } from 'vue'
|
||||
|
||||
import { useAuthActions } from '@/composables/auth/useAuthActions'
|
||||
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
import type { SubscriptionDialogOptions } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
|
||||
import type {
|
||||
BillingStatus,
|
||||
BillingSubscriptionStatus,
|
||||
@@ -190,12 +189,12 @@ export function useLegacyBilling(): BillingState & BillingActions {
|
||||
async function requireActiveSubscription(): Promise<void> {
|
||||
await fetchStatus()
|
||||
if (!isActiveSubscription.value) {
|
||||
legacyShowSubscriptionDialog({ reason: 'subscription_required' })
|
||||
legacyShowSubscriptionDialog()
|
||||
}
|
||||
}
|
||||
|
||||
function showSubscriptionDialog(options?: SubscriptionDialogOptions): void {
|
||||
legacyShowSubscriptionDialog(options)
|
||||
function showSubscriptionDialog(): void {
|
||||
legacyShowSubscriptionDialog()
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -34,17 +34,22 @@ describe('useSelectionToolboxPosition', () => {
|
||||
canvasStore = useCanvasStore()
|
||||
})
|
||||
|
||||
function renderToolboxForSelection(item: Positionable) {
|
||||
function renderToolboxForSelection(
|
||||
items: Iterable<Positionable>,
|
||||
state: Partial<LGraphCanvas['state']> = {},
|
||||
ds: Partial<LGraphCanvas['ds']> = {}
|
||||
) {
|
||||
canvasStore.canvas = markRaw({
|
||||
canvas: document.createElement('canvas'),
|
||||
ds: {
|
||||
offset: [0, 0],
|
||||
scale: 1
|
||||
offset: ds.offset ?? [0, 0],
|
||||
scale: ds.scale ?? 1
|
||||
},
|
||||
selectedItems: new Set([item]),
|
||||
selectedItems: new Set(items),
|
||||
state: {
|
||||
draggingItems: false,
|
||||
selectionChanged: true
|
||||
selectionChanged: true,
|
||||
...state
|
||||
}
|
||||
} as Partial<LGraphCanvas> as LGraphCanvas)
|
||||
|
||||
@@ -69,7 +74,7 @@ describe('useSelectionToolboxPosition', () => {
|
||||
group.pos = [100, 200]
|
||||
group.size = [160, 80]
|
||||
|
||||
const { toolbox, unmount } = renderToolboxForSelection(group)
|
||||
const { toolbox, unmount } = renderToolboxForSelection([group])
|
||||
|
||||
expect(toolbox.style.getPropertyValue('--tb-y')).toBe('190px')
|
||||
unmount()
|
||||
@@ -81,11 +86,64 @@ describe('useSelectionToolboxPosition', () => {
|
||||
node.pos = [100, 200]
|
||||
node.size = [160, 80]
|
||||
|
||||
const { toolbox, unmount } = renderToolboxForSelection(node)
|
||||
const { toolbox, unmount } = renderToolboxForSelection([node])
|
||||
|
||||
expect(toolbox.style.getPropertyValue('--tb-y')).toBe(
|
||||
`${190 - LiteGraph.NODE_TITLE_HEIGHT}px`
|
||||
)
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('does not set coordinates when selection is empty', () => {
|
||||
const { toolbox, unmount } = renderToolboxForSelection([])
|
||||
|
||||
expect(toolbox.style.getPropertyValue('--tb-x')).toBe('')
|
||||
expect(toolbox.style.getPropertyValue('--tb-y')).toBe('')
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('does not set coordinates while selected items are being dragged', () => {
|
||||
const group = new LGraphGroup('Group', 1)
|
||||
group.pos = [100, 200]
|
||||
group.size = [160, 80]
|
||||
|
||||
const { toolbox, unmount } = renderToolboxForSelection([group], {
|
||||
draggingItems: true
|
||||
})
|
||||
|
||||
expect(toolbox.style.getPropertyValue('--tb-x')).toBe('')
|
||||
expect(toolbox.style.getPropertyValue('--tb-y')).toBe('')
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('positions multiple selected items from their union bounds', () => {
|
||||
const first = new LGraphGroup('First', 1)
|
||||
first.pos = [100, 200]
|
||||
first.size = [100, 40]
|
||||
const second = new LGraphGroup('Second', 2)
|
||||
second.pos = [300, 260]
|
||||
second.size = [50, 40]
|
||||
|
||||
const { toolbox, unmount } = renderToolboxForSelection([first, second])
|
||||
|
||||
expect(toolbox.style.getPropertyValue('--tb-x')).toBe('270px')
|
||||
expect(toolbox.style.getPropertyValue('--tb-y')).toBe('190px')
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('applies canvas scale and offset to screen coordinates', () => {
|
||||
const group = new LGraphGroup('Group', 1)
|
||||
group.pos = [100, 200]
|
||||
group.size = [100, 40]
|
||||
|
||||
const { toolbox, unmount } = renderToolboxForSelection(
|
||||
[group],
|
||||
{},
|
||||
{ offset: [10, 20], scale: 2 }
|
||||
)
|
||||
|
||||
expect(toolbox.style.getPropertyValue('--tb-x')).toBe('360px')
|
||||
expect(toolbox.style.getPropertyValue('--tb-y')).toBe('420px')
|
||||
unmount()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { downloadFile, openFileInNewTab } from '@/base/common/downloadUtil'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils'
|
||||
import { useImageMenuOptions } from './useImageMenuOptions'
|
||||
@@ -19,6 +20,11 @@ vi.mock('@/stores/commandStore', () => ({
|
||||
useCommandStore: () => ({ execute: vi.fn() })
|
||||
}))
|
||||
|
||||
vi.mock('@/base/common/downloadUtil', () => ({
|
||||
downloadFile: vi.fn(),
|
||||
openFileInNewTab: vi.fn()
|
||||
}))
|
||||
|
||||
function mockClipboard(clipboard: Partial<Clipboard> | undefined) {
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
value: clipboard,
|
||||
@@ -27,6 +33,15 @@ function mockClipboard(clipboard: Partial<Clipboard> | undefined) {
|
||||
})
|
||||
}
|
||||
|
||||
function stubClipboardItem() {
|
||||
vi.stubGlobal(
|
||||
'ClipboardItem',
|
||||
class ClipboardItemStub {
|
||||
constructor(public readonly items: Record<string, Blob>) {}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
function createImageNode(
|
||||
overrides: Partial<LGraphNode> | Record<string, unknown> = {}
|
||||
): LGraphNode {
|
||||
@@ -45,8 +60,13 @@ function createImageNode(
|
||||
}
|
||||
|
||||
describe('useImageMenuOptions', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('getImageMenuOptions', () => {
|
||||
@@ -182,4 +202,147 @@ describe('useImageMenuOptions', () => {
|
||||
expect(node.pasteFiles).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('image actions', () => {
|
||||
it('opens the selected image without preview query params', () => {
|
||||
const node = createImageNode()
|
||||
node.imgs![0].src = 'http://localhost/test.png?preview=1&foo=bar'
|
||||
|
||||
const { getImageMenuOptions } = useImageMenuOptions()
|
||||
const openOption = getImageMenuOptions(node).find(
|
||||
(o) => o.label === 'Open Image'
|
||||
)
|
||||
openOption?.action?.()
|
||||
|
||||
expect(openFileInNewTab).toHaveBeenCalledWith(
|
||||
'http://localhost/test.png?foo=bar'
|
||||
)
|
||||
})
|
||||
|
||||
it('saves the selected image without preview query params', () => {
|
||||
const node = createImageNode()
|
||||
node.imgs![0].src = 'http://localhost/test.png?preview=1&foo=bar'
|
||||
|
||||
const { getImageMenuOptions } = useImageMenuOptions()
|
||||
const saveOption = getImageMenuOptions(node).find(
|
||||
(o) => o.label === 'Save Image'
|
||||
)
|
||||
saveOption?.action?.()
|
||||
|
||||
expect(downloadFile).toHaveBeenCalledWith(
|
||||
'http://localhost/test.png?foo=bar'
|
||||
)
|
||||
})
|
||||
|
||||
it('does not open or save when the active image is missing', () => {
|
||||
const node = createImageNode({ imageIndex: 1 })
|
||||
|
||||
const { getImageMenuOptions } = useImageMenuOptions()
|
||||
const options = getImageMenuOptions(node)
|
||||
const openOption = options.find((o) => o.label === 'Open Image')
|
||||
const saveOption = options.find((o) => o.label === 'Save Image')
|
||||
|
||||
expect(openOption?.action).toEqual(expect.any(Function))
|
||||
expect(saveOption?.action).toEqual(expect.any(Function))
|
||||
|
||||
openOption?.action?.()
|
||||
saveOption?.action?.()
|
||||
|
||||
expect(openFileInNewTab).not.toHaveBeenCalled()
|
||||
expect(downloadFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('logs save failures for invalid image URLs', () => {
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const node = createImageNode()
|
||||
Object.defineProperty(node.imgs![0], 'src', {
|
||||
value: 'http://[',
|
||||
configurable: true
|
||||
})
|
||||
|
||||
const { getImageMenuOptions } = useImageMenuOptions()
|
||||
getImageMenuOptions(node)
|
||||
.find((o) => o.label === 'Save Image')
|
||||
?.action?.()
|
||||
|
||||
expect(errorSpy).toHaveBeenCalledWith(
|
||||
'Failed to save image:',
|
||||
expect.any(TypeError)
|
||||
)
|
||||
expect(downloadFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('copies the selected image to clipboard', async () => {
|
||||
const node = createImageNode()
|
||||
const drawImage = vi.fn()
|
||||
const write = vi.fn().mockResolvedValue(undefined)
|
||||
stubClipboardItem()
|
||||
mockClipboard(fromPartial<Clipboard>({ write }))
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockImplementation(
|
||||
(() =>
|
||||
fromPartial<CanvasRenderingContext2D>({
|
||||
drawImage
|
||||
})) as unknown as HTMLCanvasElement['getContext']
|
||||
)
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'toBlob').mockImplementation(
|
||||
(callback: BlobCallback) => {
|
||||
callback(new Blob(['image'], { type: 'image/png' }))
|
||||
}
|
||||
)
|
||||
|
||||
const { getImageMenuOptions } = useImageMenuOptions()
|
||||
await getImageMenuOptions(node)
|
||||
.find((o) => o.label === 'Copy Image')
|
||||
?.action?.()
|
||||
|
||||
expect(drawImage).toHaveBeenCalledWith(node.imgs![0], 0, 0)
|
||||
expect(write).toHaveBeenCalledWith([
|
||||
expect.objectContaining({
|
||||
items: { 'image/png': expect.any(Blob) }
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
it('does not copy when canvas context is unavailable', async () => {
|
||||
const node = createImageNode()
|
||||
const write = vi.fn()
|
||||
mockClipboard(fromPartial<Clipboard>({ write }))
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockImplementation(
|
||||
(() => null) as HTMLCanvasElement['getContext']
|
||||
)
|
||||
|
||||
const { getImageMenuOptions } = useImageMenuOptions()
|
||||
await getImageMenuOptions(node)
|
||||
.find((o) => o.label === 'Copy Image')
|
||||
?.action?.()
|
||||
|
||||
expect(write).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not copy when canvas blob creation fails', async () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const node = createImageNode()
|
||||
const write = vi.fn()
|
||||
mockClipboard(fromPartial<Clipboard>({ write }))
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockImplementation(
|
||||
(() =>
|
||||
fromPartial<CanvasRenderingContext2D>({
|
||||
drawImage: vi.fn()
|
||||
})) as unknown as HTMLCanvasElement['getContext']
|
||||
)
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'toBlob').mockImplementation(
|
||||
(callback: BlobCallback) => {
|
||||
callback(null)
|
||||
}
|
||||
)
|
||||
|
||||
const { getImageMenuOptions } = useImageMenuOptions()
|
||||
await getImageMenuOptions(node)
|
||||
.find((o) => o.label === 'Copy Image')
|
||||
?.action?.()
|
||||
|
||||
expect(warnSpy).toHaveBeenCalledWith('Failed to create image blob')
|
||||
expect(write).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
315
src/composables/node/useNodeBadge.test.ts
Normal file
315
src/composables/node/useNodeBadge.test.ts
Normal file
@@ -0,0 +1,315 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createApp, defineComponent, h, nextTick } from 'vue'
|
||||
import type { App as VueApp } from 'vue'
|
||||
|
||||
import { useNodeBadge } from '@/composables/node/useNodeBadge'
|
||||
import { BadgePosition, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraphBadge } from '@/lib/litegraph/src/litegraph'
|
||||
import type { ComfyExtension } from '@/types/comfy'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
import { NodeBadgeMode } from '@/types/nodeSource'
|
||||
|
||||
const {
|
||||
settings,
|
||||
appState,
|
||||
extensionState,
|
||||
nodeDefState,
|
||||
pricingState,
|
||||
setDirtyMock,
|
||||
addEventListenerMock,
|
||||
registerExtensionMock,
|
||||
getCreditsBadgeMock,
|
||||
updateSubgraphCreditsMock,
|
||||
getNodePricingConfigMock,
|
||||
getNodeDisplayPriceMock,
|
||||
getRelevantWidgetNamesMock,
|
||||
triggerPriceRecalculationMock,
|
||||
useComputedWithWidgetWatchMock
|
||||
} = vi.hoisted(() => ({
|
||||
settings: {} as Record<string, unknown>,
|
||||
appState: {
|
||||
graph: {
|
||||
nodes: [] as unknown[]
|
||||
}
|
||||
},
|
||||
extensionState: {
|
||||
installed: false,
|
||||
registered: undefined as ComfyExtension | undefined
|
||||
},
|
||||
nodeDefState: {
|
||||
value: null as Record<string, unknown> | null
|
||||
},
|
||||
pricingState: {
|
||||
revision: { value: 0 },
|
||||
config: undefined as
|
||||
| {
|
||||
depends_on?: {
|
||||
widgets?: string[]
|
||||
inputs?: string[]
|
||||
input_groups?: string[]
|
||||
}
|
||||
}
|
||||
| undefined,
|
||||
label: '1 credit'
|
||||
},
|
||||
setDirtyMock: vi.fn(),
|
||||
addEventListenerMock: vi.fn(),
|
||||
registerExtensionMock: vi.fn((extension: ComfyExtension) => {
|
||||
extensionState.registered = extension
|
||||
}),
|
||||
getCreditsBadgeMock: vi.fn((text: string) => ({ text })),
|
||||
updateSubgraphCreditsMock: vi.fn(),
|
||||
getNodePricingConfigMock: vi.fn(() => pricingState.config),
|
||||
getNodeDisplayPriceMock: vi.fn(() => pricingState.label),
|
||||
getRelevantWidgetNamesMock: vi.fn(() => ['seed']),
|
||||
triggerPriceRecalculationMock: vi.fn(),
|
||||
useComputedWithWidgetWatchMock: vi.fn(() => vi.fn())
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
canvas: {
|
||||
setDirty: setDirtyMock,
|
||||
canvas: {
|
||||
addEventListener: addEventListenerMock
|
||||
},
|
||||
graph: appState.graph
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => ({
|
||||
get: (key: string) => settings[key]
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/extensionStore', () => ({
|
||||
useExtensionStore: () => ({
|
||||
isExtensionInstalled: () => extensionState.installed,
|
||||
registerExtension: registerExtensionMock
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/nodeDefStore', () => ({
|
||||
useNodeDefStore: () => ({
|
||||
fromLGraphNode: () => nodeDefState.value
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/workspace/colorPaletteStore', () => ({
|
||||
useColorPaletteStore: () => ({
|
||||
completedActivePalette: {
|
||||
colors: {
|
||||
litegraph_base: {
|
||||
BADGE_FG_COLOR: '#fff',
|
||||
BADGE_BG_COLOR: '#000'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/node/useNodePricing', () => ({
|
||||
useNodePricing: () => ({
|
||||
pricingRevision: pricingState.revision,
|
||||
getNodePricingConfig: getNodePricingConfigMock,
|
||||
getNodeDisplayPrice: getNodeDisplayPriceMock,
|
||||
getRelevantWidgetNames: getRelevantWidgetNamesMock,
|
||||
triggerPriceRecalculation: triggerPriceRecalculationMock
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/node/usePriceBadge', () => ({
|
||||
usePriceBadge: () => ({
|
||||
getCreditsBadge: getCreditsBadgeMock,
|
||||
updateSubgraphCredits: updateSubgraphCreditsMock
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/node/useWatchWidget', () => ({
|
||||
useComputedWithWidgetWatch: useComputedWithWidgetWatchMock
|
||||
}))
|
||||
|
||||
class ApiNode extends LGraphNode {
|
||||
static override nodeData = { name: 'ApiNode', api_node: true }
|
||||
}
|
||||
|
||||
function mountBadge(): VueApp {
|
||||
const app = createApp(
|
||||
defineComponent({
|
||||
setup() {
|
||||
useNodeBadge()
|
||||
return () => h('div')
|
||||
}
|
||||
})
|
||||
)
|
||||
app.mount(document.createElement('div'))
|
||||
return app
|
||||
}
|
||||
|
||||
function registeredExtension(): ComfyExtension {
|
||||
if (!extensionState.registered)
|
||||
throw new Error('Missing registered extension')
|
||||
return extensionState.registered
|
||||
}
|
||||
|
||||
function comfyApp(): Parameters<NonNullable<ComfyExtension['init']>>[0] {
|
||||
return {} as Parameters<NonNullable<ComfyExtension['init']>>[0]
|
||||
}
|
||||
|
||||
function callNodeCreated(node: LGraphNode) {
|
||||
registeredExtension().nodeCreated?.(node, comfyApp())
|
||||
}
|
||||
|
||||
function inputSlot(name: string) {
|
||||
return new LGraphNode('slot').addInput(name, '*')
|
||||
}
|
||||
|
||||
function defaultSettings() {
|
||||
settings['Comfy.NodeBadge.NodeSourceBadgeMode'] = NodeBadgeMode.None
|
||||
settings['Comfy.NodeBadge.NodeIdBadgeMode'] = NodeBadgeMode.None
|
||||
settings['Comfy.NodeBadge.NodeLifeCycleBadgeMode'] = NodeBadgeMode.None
|
||||
settings['Comfy.NodeBadge.ShowApiPricing'] = false
|
||||
}
|
||||
|
||||
describe('useNodeBadge', () => {
|
||||
let mountedApp: VueApp | undefined
|
||||
|
||||
beforeEach(() => {
|
||||
defaultSettings()
|
||||
extensionState.installed = false
|
||||
extensionState.registered = undefined
|
||||
appState.graph.nodes = []
|
||||
nodeDefState.value = null
|
||||
pricingState.revision.value = 0
|
||||
pricingState.config = undefined
|
||||
pricingState.label = '1 credit'
|
||||
setDirtyMock.mockClear()
|
||||
addEventListenerMock.mockClear()
|
||||
registerExtensionMock.mockClear()
|
||||
getCreditsBadgeMock.mockClear()
|
||||
updateSubgraphCreditsMock.mockClear()
|
||||
getNodePricingConfigMock.mockClear()
|
||||
getNodeDisplayPriceMock.mockClear()
|
||||
getRelevantWidgetNamesMock.mockClear()
|
||||
triggerPriceRecalculationMock.mockClear()
|
||||
useComputedWithWidgetWatchMock.mockClear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
mountedApp?.unmount()
|
||||
mountedApp = undefined
|
||||
})
|
||||
|
||||
it('does not register the badge extension twice', async () => {
|
||||
extensionState.installed = true
|
||||
mountedApp = mountBadge()
|
||||
await nextTick()
|
||||
|
||||
expect(registerExtensionMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('adds the configured node identity badge', async () => {
|
||||
settings['Comfy.NodeBadge.NodeSourceBadgeMode'] = NodeBadgeMode.ShowAll
|
||||
settings['Comfy.NodeBadge.NodeIdBadgeMode'] = NodeBadgeMode.ShowAll
|
||||
settings['Comfy.NodeBadge.NodeLifeCycleBadgeMode'] =
|
||||
NodeBadgeMode.HideBuiltIn
|
||||
nodeDefState.value = {
|
||||
isCoreNode: false,
|
||||
nodeLifeCycleBadgeText: 'Beta',
|
||||
nodeSource: { badgeText: 'Pack' }
|
||||
}
|
||||
const node = new LGraphNode('Test')
|
||||
node.id = toNodeId('7')
|
||||
|
||||
mountedApp = mountBadge()
|
||||
await nextTick()
|
||||
callNodeCreated(node)
|
||||
const badge = node.badges[0] as () => LGraphBadge
|
||||
|
||||
expect(node.badgePosition).toBe(BadgePosition.TopRight)
|
||||
expect(badge().text).toBe('#7 Beta Pack')
|
||||
})
|
||||
|
||||
it('hides built-in badge text when the mode excludes core nodes', async () => {
|
||||
settings['Comfy.NodeBadge.NodeSourceBadgeMode'] = NodeBadgeMode.HideBuiltIn
|
||||
settings['Comfy.NodeBadge.NodeIdBadgeMode'] = NodeBadgeMode.ShowAll
|
||||
settings['Comfy.NodeBadge.NodeLifeCycleBadgeMode'] =
|
||||
NodeBadgeMode.HideBuiltIn
|
||||
nodeDefState.value = {
|
||||
isCoreNode: true,
|
||||
nodeLifeCycleBadgeText: 'Core',
|
||||
nodeSource: { badgeText: 'Built-in' }
|
||||
}
|
||||
const node = new LGraphNode('Core')
|
||||
node.id = toNodeId('11')
|
||||
|
||||
mountedApp = mountBadge()
|
||||
await nextTick()
|
||||
callNodeCreated(node)
|
||||
const badge = node.badges[0] as () => LGraphBadge
|
||||
|
||||
expect(badge().text).toBe('#11')
|
||||
})
|
||||
|
||||
it('adds dynamic API pricing badges and refreshes relevant input changes', async () => {
|
||||
settings['Comfy.NodeBadge.ShowApiPricing'] = true
|
||||
pricingState.config = {
|
||||
depends_on: {
|
||||
widgets: ['seed'],
|
||||
inputs: ['image'],
|
||||
input_groups: ['lora']
|
||||
}
|
||||
}
|
||||
const originalOnConnectionsChange = vi.fn()
|
||||
const node = new ApiNode('API')
|
||||
node.onConnectionsChange = originalOnConnectionsChange
|
||||
|
||||
mountedApp = mountBadge()
|
||||
await nextTick()
|
||||
callNodeCreated(node)
|
||||
|
||||
expect(useComputedWithWidgetWatchMock).toHaveBeenCalledWith(node, {
|
||||
widgetNames: ['seed'],
|
||||
triggerCanvasRedraw: true
|
||||
})
|
||||
expect(getCreditsBadgeMock).toHaveBeenCalledWith('1 credit')
|
||||
|
||||
const priceBadge = node.badges[1] as () => { text: string }
|
||||
expect(priceBadge().text).toBe('1 credit')
|
||||
pricingState.label = '2 credits'
|
||||
expect(priceBadge().text).toBe('2 credits')
|
||||
|
||||
node.onConnectionsChange?.(1, 0, true, undefined, inputSlot('image'))
|
||||
node.onConnectionsChange?.(1, 0, true, undefined, inputSlot('lora.0'))
|
||||
node.onConnectionsChange?.(1, 0, true, undefined, inputSlot('clip'))
|
||||
node.onConnectionsChange?.(1, 0, true, undefined, inputSlot(''))
|
||||
|
||||
expect(originalOnConnectionsChange).toHaveBeenCalledTimes(4)
|
||||
expect(triggerPriceRecalculationMock).toHaveBeenCalledTimes(2)
|
||||
expect(triggerPriceRecalculationMock).toHaveBeenCalledWith(node)
|
||||
})
|
||||
|
||||
it('updates subgraph credit badges from registered extension hooks', async () => {
|
||||
const nodes = [new LGraphNode('one'), new LGraphNode('two')]
|
||||
appState.graph.nodes = nodes
|
||||
|
||||
mountedApp = mountBadge()
|
||||
await nextTick()
|
||||
await registeredExtension().init?.(comfyApp())
|
||||
await registeredExtension().afterConfigureGraph?.([], comfyApp())
|
||||
|
||||
const setGraphHandler = addEventListenerMock.mock.calls.find(
|
||||
([event]) => event === 'litegraph:set-graph'
|
||||
)?.[1]
|
||||
const convertedHandler = addEventListenerMock.mock.calls.find(
|
||||
([event]) => event === 'subgraph-converted'
|
||||
)?.[1]
|
||||
setGraphHandler?.()
|
||||
convertedHandler?.({ detail: { subgraphNode: nodes[0] } })
|
||||
|
||||
expect(updateSubgraphCreditsMock).toHaveBeenCalledWith(nodes[0])
|
||||
expect(updateSubgraphCreditsMock).toHaveBeenCalledWith(nodes[1])
|
||||
})
|
||||
})
|
||||
@@ -503,7 +503,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
}) => {
|
||||
trackRunButton(metadata)
|
||||
if (!isActiveSubscription.value) {
|
||||
showSubscriptionDialog({ reason: 'subscribe_to_run' })
|
||||
showSubscriptionDialog()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -526,7 +526,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
}) => {
|
||||
trackRunButton(metadata)
|
||||
if (!isActiveSubscription.value) {
|
||||
showSubscriptionDialog({ reason: 'subscribe_to_run' })
|
||||
showSubscriptionDialog()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -548,7 +548,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
}) => {
|
||||
trackRunButton(metadata)
|
||||
if (!isActiveSubscription.value) {
|
||||
showSubscriptionDialog({ reason: 'subscribe_to_run' })
|
||||
showSubscriptionDialog()
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
102
src/composables/useTreeExpansion.test.ts
Normal file
102
src/composables/useTreeExpansion.test.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { ref } from 'vue'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { useTreeExpansion } from '@/composables/useTreeExpansion'
|
||||
import type { TreeNode } from '@/types/treeExplorerTypes'
|
||||
|
||||
function node(over: Partial<TreeNode>): TreeNode {
|
||||
return over as TreeNode
|
||||
}
|
||||
|
||||
// root ─┬─ a ── a1 (leaf)
|
||||
// └─ b (leaf)
|
||||
function sampleTree() {
|
||||
const a1 = node({ key: 'a1', leaf: true })
|
||||
const a = node({ key: 'a', leaf: false, children: [a1] })
|
||||
const b = node({ key: 'b', leaf: true })
|
||||
const root = node({ key: 'root', leaf: false, children: [a, b] })
|
||||
return { root, a, a1, b }
|
||||
}
|
||||
|
||||
describe('useTreeExpansion', () => {
|
||||
it('toggleNode adds then removes a node key', () => {
|
||||
const expandedKeys = ref<Record<string, boolean>>({})
|
||||
const { toggleNode } = useTreeExpansion(expandedKeys)
|
||||
const n = node({ key: 'x' })
|
||||
|
||||
toggleNode(n)
|
||||
expect(expandedKeys.value).toEqual({ x: true })
|
||||
|
||||
toggleNode(n)
|
||||
expect(expandedKeys.value).toEqual({})
|
||||
})
|
||||
|
||||
it('toggleNode ignores nodes without a string key', () => {
|
||||
const expandedKeys = ref<Record<string, boolean>>({})
|
||||
const { toggleNode } = useTreeExpansion(expandedKeys)
|
||||
|
||||
toggleNode(node({ key: undefined }))
|
||||
toggleNode(node({ key: 42 as unknown as string }))
|
||||
|
||||
expect(expandedKeys.value).toEqual({})
|
||||
})
|
||||
|
||||
it('expandNode expands the node and all non-leaf descendants only', () => {
|
||||
const expandedKeys = ref<Record<string, boolean>>({})
|
||||
const { expandNode } = useTreeExpansion(expandedKeys)
|
||||
const { root } = sampleTree()
|
||||
|
||||
expandNode(root)
|
||||
|
||||
// root and a are folders; a1 and b are leaves and must be skipped
|
||||
expect(expandedKeys.value).toEqual({ root: true, a: true })
|
||||
})
|
||||
|
||||
it('expandNode does nothing for a leaf node', () => {
|
||||
const expandedKeys = ref<Record<string, boolean>>({})
|
||||
const { expandNode } = useTreeExpansion(expandedKeys)
|
||||
|
||||
expandNode(node({ key: 'leaf', leaf: true }))
|
||||
|
||||
expect(expandedKeys.value).toEqual({})
|
||||
})
|
||||
|
||||
it('collapseNode removes the node and its non-leaf descendants', () => {
|
||||
const expandedKeys = ref<Record<string, boolean>>({
|
||||
root: true,
|
||||
a: true,
|
||||
stray: true
|
||||
})
|
||||
const { collapseNode } = useTreeExpansion(expandedKeys)
|
||||
const { root } = sampleTree()
|
||||
|
||||
collapseNode(root)
|
||||
|
||||
expect(expandedKeys.value).toEqual({ stray: true })
|
||||
})
|
||||
|
||||
it('toggleNodeRecursive expands when collapsed and collapses when expanded', () => {
|
||||
const expandedKeys = ref<Record<string, boolean>>({})
|
||||
const { toggleNodeRecursive } = useTreeExpansion(expandedKeys)
|
||||
const { root } = sampleTree()
|
||||
|
||||
toggleNodeRecursive(root)
|
||||
expect(expandedKeys.value).toEqual({ root: true, a: true })
|
||||
|
||||
toggleNodeRecursive(root)
|
||||
expect(expandedKeys.value).toEqual({})
|
||||
})
|
||||
|
||||
it('toggleNodeOnEvent toggles recursively with ctrl and singly without', () => {
|
||||
const expandedKeys = ref<Record<string, boolean>>({})
|
||||
const { toggleNodeOnEvent } = useTreeExpansion(expandedKeys)
|
||||
const { root } = sampleTree()
|
||||
|
||||
toggleNodeOnEvent(new KeyboardEvent('keydown', { ctrlKey: true }), root)
|
||||
expect(expandedKeys.value).toEqual({ root: true, a: true })
|
||||
|
||||
// Plain toggle removes only the node's own key, leaving descendants
|
||||
toggleNodeOnEvent(new MouseEvent('click'), root)
|
||||
expect(expandedKeys.value).toEqual({ a: true })
|
||||
})
|
||||
})
|
||||
@@ -1,105 +0,0 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import { LGraph, LGraphCanvas, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { createMockCanvasRenderingContext2D } from '@/utils/__tests__/litegraphTestUtils'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
|
||||
import { useViewErrorsInGraph } from './useViewErrorsInGraph'
|
||||
|
||||
const apiMock = vi.hoisted(() => ({
|
||||
getSettings: vi.fn(),
|
||||
storeSetting: vi.fn(),
|
||||
storeSettings: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: apiMock
|
||||
}))
|
||||
|
||||
const appMock = vi.hoisted(() => ({
|
||||
ui: {
|
||||
settings: {
|
||||
dispatchChange: vi.fn()
|
||||
}
|
||||
},
|
||||
rootGraph: {
|
||||
events: new EventTarget(),
|
||||
nodes: []
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: appMock
|
||||
}))
|
||||
|
||||
function createSelectedCanvas() {
|
||||
const graph = new LGraph()
|
||||
const canvasElement = document.createElement('canvas')
|
||||
canvasElement.width = 800
|
||||
canvasElement.height = 600
|
||||
canvasElement.getContext = vi
|
||||
.fn()
|
||||
.mockReturnValue(createMockCanvasRenderingContext2D())
|
||||
|
||||
const canvas = new LGraphCanvas(canvasElement, graph, {
|
||||
skip_events: true,
|
||||
skip_render: true
|
||||
})
|
||||
const node = new LGraphNode('Selected Node')
|
||||
graph.add(node)
|
||||
canvas.selectedItems.add(node)
|
||||
node.selected = true
|
||||
|
||||
return { canvas, node }
|
||||
}
|
||||
|
||||
describe('useViewErrorsInGraph', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
setActivePinia(createPinia())
|
||||
apiMock.getSettings.mockResolvedValue({})
|
||||
apiMock.storeSetting.mockResolvedValue(undefined)
|
||||
apiMock.storeSettings.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
it('opens graph errors and clears app-mode error UI state', () => {
|
||||
const canvasStore = useCanvasStore()
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const { canvas, node } = createSelectedCanvas()
|
||||
workflowStore.activeWorkflow = {
|
||||
activeMode: 'app'
|
||||
} as typeof workflowStore.activeWorkflow
|
||||
canvasStore.canvas = canvas
|
||||
canvasStore.selectedItems = [node]
|
||||
executionErrorStore.showErrorOverlay()
|
||||
|
||||
useViewErrorsInGraph().viewErrorsInGraph()
|
||||
|
||||
expect(node.selected).toBe(false)
|
||||
expect(canvasStore.linearMode).toBe(false)
|
||||
expect(canvasStore.selectedItems).toEqual([])
|
||||
expect(rightSidePanelStore.activeTab).toBe('errors')
|
||||
expect(rightSidePanelStore.isOpen).toBe(true)
|
||||
expect(executionErrorStore.isErrorOverlayOpen).toBe(false)
|
||||
})
|
||||
|
||||
it('opens graph errors when the canvas is not initialized', () => {
|
||||
const canvasStore = useCanvasStore()
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
canvasStore.canvas = null
|
||||
executionErrorStore.showErrorOverlay()
|
||||
|
||||
expect(() => useViewErrorsInGraph().viewErrorsInGraph()).not.toThrow()
|
||||
|
||||
expect(rightSidePanelStore.activeTab).toBe('errors')
|
||||
expect(rightSidePanelStore.isOpen).toBe(true)
|
||||
expect(executionErrorStore.isErrorOverlayOpen).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,22 +0,0 @@
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
|
||||
export function useViewErrorsInGraph() {
|
||||
const canvasStore = useCanvasStore()
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
|
||||
function viewErrorsInGraph() {
|
||||
canvasStore.linearMode = false
|
||||
if (canvasStore.canvas) {
|
||||
canvasStore.canvas.deselectAll()
|
||||
canvasStore.updateSelectedItems()
|
||||
}
|
||||
|
||||
rightSidePanelStore.openPanel('errors')
|
||||
executionErrorStore.dismissErrorOverlay()
|
||||
}
|
||||
|
||||
return { viewErrorsInGraph }
|
||||
}
|
||||
@@ -1,16 +1,49 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { SerialisedLLinkArray } from '@/lib/litegraph/src/LLink'
|
||||
import { LGraphNode, LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type { ComfyNode } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
|
||||
import type { ComfyApp } from '@/scripts/app'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import type { ComfyExtension } from '@/types/comfy'
|
||||
|
||||
import type { GroupNodeWorkflowData } from './groupNode'
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
registerExtension: vi.fn()
|
||||
const appMock = vi.hoisted(() => ({
|
||||
canvas: {
|
||||
emitAfterChange: vi.fn(),
|
||||
emitBeforeChange: vi.fn(),
|
||||
selected_nodes: {}
|
||||
},
|
||||
registerExtension: vi.fn(),
|
||||
registerNodeDef: vi.fn(),
|
||||
rootGraph: {
|
||||
convertToSubgraph: vi.fn(),
|
||||
extra: {},
|
||||
getNodeById: vi.fn(),
|
||||
links: {},
|
||||
nodes: [],
|
||||
remove: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
const widgetStoreMock = vi.hoisted(() => ({
|
||||
inputIsWidget: vi.fn((spec: unknown[]) =>
|
||||
['BOOLEAN', 'COMBO', 'FLOAT', 'INT', 'STRING'].includes(String(spec[0]))
|
||||
)
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: appMock
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/widgetStore', () => ({
|
||||
useWidgetStore: () => widgetStoreMock
|
||||
}))
|
||||
|
||||
import { GroupNodeConfig, replaceLegacySeparators } from './groupNode'
|
||||
|
||||
function makeNode(type: string): ComfyNode {
|
||||
@@ -26,6 +59,42 @@ function makeNode(type: string): ComfyNode {
|
||||
}
|
||||
}
|
||||
|
||||
function makeNodeDef(overrides: Partial<ComfyNodeDef> = {}): ComfyNodeDef {
|
||||
return {
|
||||
name: 'TestNode',
|
||||
display_name: 'Test Node',
|
||||
description: '',
|
||||
category: 'test',
|
||||
input: { required: {}, optional: {} },
|
||||
output: [],
|
||||
output_name: [],
|
||||
output_is_list: [],
|
||||
output_node: false,
|
||||
python_module: 'test',
|
||||
...overrides
|
||||
} as ComfyNodeDef
|
||||
}
|
||||
|
||||
function extension(): ComfyExtension {
|
||||
const groupExtension = appMock.registerExtension.mock.calls.find(
|
||||
([registered]) => registered.name === 'Comfy.GroupNode'
|
||||
)?.[0]
|
||||
if (!groupExtension) throw new Error('GroupNode extension was not registered')
|
||||
return groupExtension as ComfyExtension
|
||||
}
|
||||
|
||||
function addCustomNodeDefs(defs: Record<string, ComfyNodeDef>) {
|
||||
extension().addCustomNodeDefs?.(defs, appMock as unknown as ComfyApp)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
appMock.registerNodeDef.mockReset()
|
||||
widgetStoreMock.inputIsWidget.mockClear()
|
||||
LiteGraph.registered_node_types = {}
|
||||
addCustomNodeDefs({})
|
||||
})
|
||||
|
||||
describe('replaceLegacySeparators', () => {
|
||||
it('rewrites the legacy "workflow/" prefix to "workflow>"', () => {
|
||||
const nodes = [makeNode('workflow/My Group')]
|
||||
@@ -104,4 +173,398 @@ describe('GroupNodeConfig.getLinks', () => {
|
||||
const config = configFrom([], [[0, 1, 'IMAGE']])
|
||||
expect(config.externalFrom[0][1]).toBe('IMAGE')
|
||||
})
|
||||
|
||||
it('ignores external links without a type and accumulates multiple slots', () => {
|
||||
const config = configFrom(
|
||||
[],
|
||||
[
|
||||
[0, 1, null as unknown as string],
|
||||
[0, 2, 'LATENT'],
|
||||
[0, 3, 'IMAGE']
|
||||
]
|
||||
)
|
||||
|
||||
expect(config.externalFrom[0]).toEqual({ 2: 'LATENT', 3: 'IMAGE' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('GroupNodeConfig.getNodeDef', () => {
|
||||
const imageNodeDef = makeNodeDef({
|
||||
name: 'ImageNode',
|
||||
input: {
|
||||
required: {
|
||||
image: ['IMAGE', {}],
|
||||
mode: [['fast', 'slow'], {}]
|
||||
},
|
||||
optional: {
|
||||
strength: ['FLOAT', { default: 1 }]
|
||||
}
|
||||
},
|
||||
output: ['IMAGE'],
|
||||
output_name: ['image'],
|
||||
output_is_list: [false]
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
addCustomNodeDefs({ ImageNode: imageNodeDef })
|
||||
})
|
||||
|
||||
it('returns registered definitions for normal node types', () => {
|
||||
const config = new GroupNodeConfig('group', {
|
||||
nodes: [{ index: 0, type: 'ImageNode' }],
|
||||
links: [],
|
||||
external: []
|
||||
})
|
||||
|
||||
expect(config.getNodeDef({ index: 0, type: 'ImageNode' })).toBe(
|
||||
imageNodeDef
|
||||
)
|
||||
})
|
||||
|
||||
it('returns undefined for nodes without an index or a known type', () => {
|
||||
const config = new GroupNodeConfig('group', {
|
||||
nodes: [{ type: 'UnknownNode' }],
|
||||
links: [],
|
||||
external: []
|
||||
})
|
||||
|
||||
expect(config.getNodeDef({ type: 'UnknownNode' })).toBeUndefined()
|
||||
})
|
||||
|
||||
it('skips unlinked primitive nodes', () => {
|
||||
const config = new GroupNodeConfig('group', {
|
||||
nodes: [{ index: 0, type: 'PrimitiveNode' }],
|
||||
links: [],
|
||||
external: []
|
||||
})
|
||||
|
||||
expect(
|
||||
config.getNodeDef({ index: 0, type: 'PrimitiveNode' })
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
it('derives primitive node type from the outgoing link type', () => {
|
||||
const config = new GroupNodeConfig('group', {
|
||||
nodes: [
|
||||
{ index: 0, type: 'PrimitiveNode' },
|
||||
{ index: 1, type: 'ImageNode' }
|
||||
],
|
||||
links: [[0, 0, 1, 0, 1, 'IMAGE'] as SerialisedLLinkArray],
|
||||
external: []
|
||||
})
|
||||
|
||||
expect(
|
||||
config.getNodeDef({ index: 0, type: 'PrimitiveNode' })
|
||||
).toMatchObject({
|
||||
input: { required: { value: ['IMAGE', {}] } },
|
||||
output: ['IMAGE']
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to null when primitive combo target spec is not primitive', () => {
|
||||
const config = new GroupNodeConfig('group', {
|
||||
nodes: [
|
||||
{
|
||||
index: 0,
|
||||
type: 'PrimitiveNode',
|
||||
outputs: [{ name: 'mode', widget: { name: 'mode' } }]
|
||||
},
|
||||
{ index: 1, type: 'ImageNode' }
|
||||
],
|
||||
links: [[0, 0, 1, 0, 1, 'COMBO'] as SerialisedLLinkArray],
|
||||
external: []
|
||||
})
|
||||
|
||||
expect(config.getNodeDef(config.nodeData.nodes[0])).toMatchObject({
|
||||
input: { required: { value: [null, {}] } },
|
||||
output: [null]
|
||||
})
|
||||
})
|
||||
|
||||
it('returns null for reroutes used only inside the group', () => {
|
||||
const config = new GroupNodeConfig('group', {
|
||||
nodes: [
|
||||
{ index: 0, type: 'ImageNode' },
|
||||
{ index: 1, type: 'Reroute' },
|
||||
{ index: 2, type: 'ImageNode' }
|
||||
],
|
||||
links: [
|
||||
[0, 0, 1, 0, 1, 'IMAGE'],
|
||||
[1, 0, 2, 0, 2, 'IMAGE']
|
||||
] as SerialisedLLinkArray[],
|
||||
external: []
|
||||
})
|
||||
|
||||
expect(config.getNodeDef({ index: 1, type: 'Reroute' })).toBeNull()
|
||||
})
|
||||
|
||||
it('derives reroute type from outgoing target inputs', () => {
|
||||
const config = new GroupNodeConfig('group', {
|
||||
nodes: [
|
||||
{ index: 0, type: 'Reroute' },
|
||||
{
|
||||
index: 1,
|
||||
type: 'ImageNode',
|
||||
inputs: [{ name: 'image', type: 'IMAGE' }]
|
||||
}
|
||||
],
|
||||
links: [[0, 0, 1, 0, 1, 'IMAGE'] as SerialisedLLinkArray],
|
||||
external: [[0, 0, 'IMAGE']]
|
||||
})
|
||||
|
||||
expect(config.getNodeDef({ index: 0, type: 'Reroute' })).toMatchObject({
|
||||
input: { required: { IMAGE: ['IMAGE', { forceInput: true }] } },
|
||||
output: ['IMAGE']
|
||||
})
|
||||
})
|
||||
|
||||
it('derives reroute type from incoming output metadata', () => {
|
||||
const config = new GroupNodeConfig('group', {
|
||||
nodes: [
|
||||
{ index: 0, type: 'ImageNode', outputs: [{ type: 'LATENT' }] },
|
||||
{ index: 1, type: 'Reroute' }
|
||||
],
|
||||
links: [[0, 0, 1, 0, 1, 'LATENT'] as SerialisedLLinkArray],
|
||||
external: [[1, 0, 'LATENT']]
|
||||
})
|
||||
|
||||
expect(config.getNodeDef({ index: 1, type: 'Reroute' })).toMatchObject({
|
||||
input: { required: { LATENT: ['LATENT', { forceInput: true }] } },
|
||||
output: ['LATENT']
|
||||
})
|
||||
})
|
||||
|
||||
it('derives pipe reroute type from external metadata when links omit it', () => {
|
||||
const config = new GroupNodeConfig('group', {
|
||||
nodes: [{ index: 0, type: 'Reroute' }],
|
||||
links: [],
|
||||
external: [[0, 0, 'MASK']]
|
||||
})
|
||||
|
||||
expect(config.getNodeDef({ index: 0, type: 'Reroute' })).toMatchObject({
|
||||
input: { required: { MASK: ['MASK', { forceInput: true }] } },
|
||||
output: ['MASK']
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('GroupNodeConfig input and output mapping', () => {
|
||||
function configWithNode(node: GroupNodeWorkflowData['nodes'][number]) {
|
||||
const config = new GroupNodeConfig('group', {
|
||||
nodes: [node],
|
||||
links: [],
|
||||
external: [],
|
||||
config: {
|
||||
0: {
|
||||
input: {
|
||||
hidden: { visible: false },
|
||||
renamed: { name: 'Custom Name' }
|
||||
},
|
||||
output: {
|
||||
1: { name: 'Custom Output' },
|
||||
2: { visible: false }
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
config.nodeDef = makeNodeDef({
|
||||
input: { required: {} },
|
||||
output: [],
|
||||
output_name: [],
|
||||
output_is_list: []
|
||||
})
|
||||
return config
|
||||
}
|
||||
|
||||
it('renames duplicate inputs and adds seed control metadata', () => {
|
||||
const config = configWithNode({
|
||||
index: 0,
|
||||
type: 'Sampler',
|
||||
title: 'Sampler A',
|
||||
inputs: [{ name: 'seed', label: 'Seed Label' }]
|
||||
})
|
||||
const seenInputs = { seed: 1, 'Sampler A seed': 1 }
|
||||
const result = config.getInputConfig(
|
||||
{ index: 0, type: 'Sampler', title: 'Sampler A' },
|
||||
'seed',
|
||||
seenInputs,
|
||||
['INT', {}]
|
||||
)
|
||||
|
||||
expect(result.name).toBe('Sampler A 1 seed')
|
||||
expect(result.config).toEqual([
|
||||
'INT',
|
||||
{ control_after_generate: 'Sampler A control_after_generate' }
|
||||
])
|
||||
})
|
||||
|
||||
it('maps image upload widget aliases through converted widget names', () => {
|
||||
const config = configWithNode({ index: 0, type: 'LoadImage' })
|
||||
config.oldToNewWidgetMap[0] = { customImage: 'Uploaded Image' }
|
||||
|
||||
expect(
|
||||
config.getInputConfig({ index: 0, type: 'LoadImage' }, 'renamed', {}, [
|
||||
'IMAGEUPLOAD',
|
||||
{ widget: 'customImage' }
|
||||
])
|
||||
).toMatchObject({
|
||||
name: 'Custom Name',
|
||||
config: ['IMAGEUPLOAD', { widget: 'Uploaded Image' }]
|
||||
})
|
||||
})
|
||||
|
||||
it('splits widget inputs, socket inputs, and converted widget slots', () => {
|
||||
const config = configWithNode({
|
||||
index: 0,
|
||||
type: 'MixedNode',
|
||||
inputs: [{ name: 'mode', widget: { name: 'mode' } }]
|
||||
})
|
||||
|
||||
const result = config.processWidgetInputs(
|
||||
{
|
||||
mode: ['COMBO', {}],
|
||||
image: ['IMAGE', {}]
|
||||
},
|
||||
{
|
||||
index: 0,
|
||||
type: 'MixedNode',
|
||||
inputs: [{ name: 'mode', widget: { name: 'mode' } }]
|
||||
},
|
||||
['mode', 'image'],
|
||||
{}
|
||||
)
|
||||
|
||||
expect(result.slots).toEqual(['image'])
|
||||
expect(result.converted.get(0)).toBe('mode')
|
||||
expect(config.oldToNewWidgetMap[0].mode).toBeNull()
|
||||
})
|
||||
|
||||
it('adds visible unlinked input slots and skips hidden configured inputs', () => {
|
||||
const config = configWithNode({
|
||||
index: 0,
|
||||
type: 'InputNode'
|
||||
})
|
||||
const inputMap: Record<number, number> = {}
|
||||
config.processInputSlots(
|
||||
{
|
||||
image: ['IMAGE', {}],
|
||||
hidden: ['LATENT', {}]
|
||||
},
|
||||
{ index: 0, type: 'InputNode' },
|
||||
['image', 'hidden'],
|
||||
{},
|
||||
inputMap,
|
||||
{}
|
||||
)
|
||||
|
||||
expect(config.nodeDef?.input?.required).toEqual({ image: ['IMAGE', {}] })
|
||||
expect(inputMap).toEqual({ 0: 0 })
|
||||
})
|
||||
|
||||
it('adds output metadata, hides linked/internal outputs, and dedupes labels', () => {
|
||||
const config = configWithNode({
|
||||
index: 0,
|
||||
type: 'OutputNode',
|
||||
title: 'Output A',
|
||||
outputs: [{ name: 'image', label: 'Rendered' }]
|
||||
})
|
||||
config.linksFrom[0] = {
|
||||
0: [[0, 0, 1, 0, 1, 'IMAGE'] as SerialisedLLinkArray]
|
||||
}
|
||||
config.processNodeOutputs(
|
||||
{ index: 0, type: 'OutputNode', title: 'Output A' },
|
||||
{ Rendered: 1 },
|
||||
{
|
||||
input: { required: {} },
|
||||
output: ['IMAGE', 'LATENT', 'MASK'],
|
||||
output_name: ['image', 'latent', 'mask'],
|
||||
output_is_list: [false, true, false]
|
||||
}
|
||||
)
|
||||
|
||||
expect(config.outputVisibility).toEqual([false, true, false])
|
||||
expect(config.nodeDef?.output).toEqual(['LATENT'])
|
||||
expect(config.nodeDef?.output_is_list).toEqual([true])
|
||||
expect(config.nodeDef?.output_name).toEqual(['Custom Output'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('GroupNodeConfig.registerFromWorkflow', () => {
|
||||
it('adds missing type actions and skips registration for incomplete groups', async () => {
|
||||
const groupNodes: Record<string, GroupNodeWorkflowData> = {
|
||||
Broken: {
|
||||
nodes: [{ index: 0, type: 'MissingNode' }],
|
||||
links: [],
|
||||
external: []
|
||||
}
|
||||
}
|
||||
const missingNodeTypes: Parameters<
|
||||
typeof GroupNodeConfig.registerFromWorkflow
|
||||
>[1] = []
|
||||
|
||||
await GroupNodeConfig.registerFromWorkflow(groupNodes, missingNodeTypes)
|
||||
|
||||
expect(appMock.registerNodeDef).not.toHaveBeenCalled()
|
||||
expect(missingNodeTypes).toHaveLength(2)
|
||||
expect(missingNodeTypes[0]).toMatchObject({
|
||||
type: 'MissingNode',
|
||||
hint: " (In group node 'workflow>Broken')"
|
||||
})
|
||||
|
||||
const action = missingNodeTypes[1]
|
||||
if (typeof action === 'string') {
|
||||
throw new Error('Expected a missing-node action entry, not a string')
|
||||
}
|
||||
|
||||
const target = document.createElement('button')
|
||||
const { callback } = action.action as {
|
||||
callback: (event: MouseEvent) => void
|
||||
}
|
||||
const event = new MouseEvent('click')
|
||||
Object.defineProperty(event, 'target', { value: target })
|
||||
callback(event)
|
||||
expect(groupNodes.Broken).toBeUndefined()
|
||||
expect(target.textContent).toBe('Removed')
|
||||
expect(target.style.pointerEvents).toBe('none')
|
||||
})
|
||||
|
||||
it('registers complete group node types and stores their generated node defs', async () => {
|
||||
addCustomNodeDefs({
|
||||
ImageNode: makeNodeDef({
|
||||
name: 'ImageNode',
|
||||
input: { required: { image: ['IMAGE', {}] } },
|
||||
output: ['IMAGE'],
|
||||
output_name: ['image'],
|
||||
output_is_list: [false]
|
||||
})
|
||||
})
|
||||
LiteGraph.registered_node_types.ImageNode = class extends LGraphNode {}
|
||||
|
||||
await GroupNodeConfig.registerFromWorkflow(
|
||||
{
|
||||
Complete: {
|
||||
nodes: [{ index: 0, type: 'ImageNode' }],
|
||||
links: [],
|
||||
external: [[0, 0, 'IMAGE']]
|
||||
}
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
expect(appMock.registerNodeDef).toHaveBeenCalledWith(
|
||||
'workflow>Complete',
|
||||
expect.objectContaining({
|
||||
category: 'group nodes>workflow',
|
||||
display_name: 'Complete',
|
||||
name: 'workflow>Complete'
|
||||
})
|
||||
)
|
||||
expect(useNodeDefStore().nodeDefsByName['workflow>Complete']).toEqual(
|
||||
expect.objectContaining({
|
||||
category: 'group nodes>workflow',
|
||||
display_name: 'Complete',
|
||||
name: 'workflow>Complete'
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -25,6 +25,6 @@ function handleClose() {
|
||||
}
|
||||
|
||||
function handleSubscribe() {
|
||||
showSubscriptionDialog({ reason: 'upload_model_upgrade' })
|
||||
showSubscriptionDialog()
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -140,10 +140,7 @@ describe('CloudSubscriptionRedirectView', () => {
|
||||
expect(mockPerformSubscriptionCheckout).toHaveBeenCalledWith(
|
||||
'creator',
|
||||
'monthly',
|
||||
{
|
||||
openInNewTab: false,
|
||||
paymentIntentSource: 'deep_link'
|
||||
}
|
||||
false
|
||||
)
|
||||
|
||||
// Shows loading affordances
|
||||
@@ -172,10 +169,7 @@ describe('CloudSubscriptionRedirectView', () => {
|
||||
expect(mockPerformSubscriptionCheckout).toHaveBeenCalledWith(
|
||||
'creator',
|
||||
'monthly',
|
||||
{
|
||||
openInNewTab: false,
|
||||
paymentIntentSource: 'deep_link'
|
||||
}
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
@@ -186,8 +180,7 @@ describe('CloudSubscriptionRedirectView', () => {
|
||||
expect(screen.getByText('Subscribe to Team Plan')).toBeInTheDocument()
|
||||
expect(mockPerformTeamSubscriptionCheckout).toHaveBeenCalledWith(
|
||||
'team_700',
|
||||
'yearly',
|
||||
{ paymentIntentSource: 'deep_link' }
|
||||
'yearly'
|
||||
)
|
||||
// Team never goes through the personal checkout path
|
||||
expect(mockPerformSubscriptionCheckout).not.toHaveBeenCalled()
|
||||
|
||||
@@ -94,9 +94,7 @@ const runRedirect = wrapWithErrorHandlingAsync(async () => {
|
||||
return
|
||||
}
|
||||
isTeamCheckout.value = true
|
||||
await performTeamSubscriptionCheckout(stopId, billingCycle, {
|
||||
paymentIntentSource: 'deep_link'
|
||||
})
|
||||
await performTeamSubscriptionCheckout(stopId, billingCycle)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -114,10 +112,7 @@ const runRedirect = wrapWithErrorHandlingAsync(async () => {
|
||||
if (isActiveSubscription.value) {
|
||||
await accessBillingPortal(undefined, false)
|
||||
} else {
|
||||
await performSubscriptionCheckout(tierKeyParam, billingCycle, {
|
||||
openInNewTab: false,
|
||||
paymentIntentSource: 'deep_link'
|
||||
})
|
||||
await performSubscriptionCheckout(tierKeyParam, billingCycle, false)
|
||||
}
|
||||
}, reportError)
|
||||
|
||||
|
||||
@@ -351,12 +351,12 @@ const handleRefresh = wrapWithErrorHandlingAsync(async () => {
|
||||
})
|
||||
|
||||
function handleAddCredits() {
|
||||
telemetry?.trackAddApiCreditButtonClicked({ source: 'credits_panel' })
|
||||
telemetry?.trackAddApiCreditButtonClicked()
|
||||
void dialogService.showTopUpCreditsDialog()
|
||||
}
|
||||
|
||||
function handleUpgradeToAddCredits() {
|
||||
showPricingTable({ reason: 'upgrade_to_add_credits' })
|
||||
showPricingTable()
|
||||
}
|
||||
|
||||
async function handleWindowFocus() {
|
||||
|
||||
@@ -5,8 +5,6 @@ import { render, screen } from '@testing-library/vue'
|
||||
|
||||
import enMessages from '@/locales/en/main.json' with { type: 'json' }
|
||||
|
||||
import type { PaymentIntentSource } from '@/platform/telemetry/types'
|
||||
|
||||
import FreeTierDialogContent from './FreeTierDialogContent.vue'
|
||||
|
||||
const mockRenewalDate = vi.hoisted(() => ({ value: null as string | null }))
|
||||
@@ -17,7 +15,7 @@ vi.mock('@/composables/billing/useBillingContext', () => ({
|
||||
}))
|
||||
}))
|
||||
|
||||
function renderComponent(props?: { reason?: PaymentIntentSource }) {
|
||||
function renderComponent() {
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
@@ -25,7 +23,6 @@ function renderComponent(props?: { reason?: PaymentIntentSource }) {
|
||||
})
|
||||
|
||||
return render(FreeTierDialogContent, {
|
||||
props,
|
||||
global: {
|
||||
plugins: [i18n]
|
||||
}
|
||||
@@ -46,18 +43,4 @@ describe('FreeTierDialogContent', () => {
|
||||
renderComponent()
|
||||
expect(screen.queryByText(/credits refresh on/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps the generic copy for intent reasons outside the credits variants', () => {
|
||||
mockRenewalDate.value = '2026-07-15T10:00:00Z'
|
||||
renderComponent({ reason: 'subscribe_to_run' })
|
||||
expect(
|
||||
screen.getByText('Your credits refresh on Jul 15, 2026.')
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('swaps to the out-of-credits copy without the refresh line', () => {
|
||||
mockRenewalDate.value = '2026-07-15T10:00:00Z'
|
||||
renderComponent({ reason: 'out_of_credits' })
|
||||
expect(screen.queryByText(/credits refresh on/)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -52,7 +52,7 @@
|
||||
</p>
|
||||
|
||||
<p
|
||||
v-if="!isCreditsBlockedVariant"
|
||||
v-if="!reason || reason === 'subscription_required'"
|
||||
class="m-0 text-sm text-text-secondary"
|
||||
>
|
||||
{{
|
||||
@@ -65,7 +65,10 @@
|
||||
</p>
|
||||
|
||||
<p
|
||||
v-if="!isCreditsBlockedVariant && formattedRenewalDate"
|
||||
v-if="
|
||||
(!reason || reason === 'subscription_required') &&
|
||||
formattedRenewalDate
|
||||
"
|
||||
class="m-0 text-sm text-text-secondary"
|
||||
>
|
||||
{{
|
||||
@@ -85,7 +88,7 @@
|
||||
@click="$emit('upgrade')"
|
||||
>
|
||||
{{
|
||||
isCreditsBlockedVariant
|
||||
reason === 'out_of_credits' || reason === 'top_up_blocked'
|
||||
? $t('subscription.freeTier.upgradeCta')
|
||||
: $t('subscription.freeTier.subscribeCta')
|
||||
}}
|
||||
@@ -100,12 +103,12 @@ import { computed } from 'vue'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import type { PaymentIntentSource } from '@/platform/telemetry/types'
|
||||
import type { SubscriptionDialogReason } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
|
||||
import SubscriptionBenefits from '@/platform/cloud/subscription/components/SubscriptionBenefits.vue'
|
||||
import { getTierCredits } from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
|
||||
const { reason } = defineProps<{
|
||||
reason?: PaymentIntentSource
|
||||
defineProps<{
|
||||
reason?: SubscriptionDialogReason
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
@@ -126,10 +129,4 @@ const formattedRenewalDate = computed(() => {
|
||||
})
|
||||
|
||||
const freeTierCredits = computed(() => getTierCredits('free'))
|
||||
|
||||
// Only these two variants replace the generic free-tier copy; any other
|
||||
// intent reason (subscribe_to_run, deep_link, ...) keeps the default pitch.
|
||||
const isCreditsBlockedVariant = computed(
|
||||
() => reason === 'out_of_credits' || reason === 'top_up_blocked'
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -261,7 +261,6 @@ describe('PricingTable', () => {
|
||||
tier: 'creator',
|
||||
cycle: 'yearly',
|
||||
checkout_type: 'change',
|
||||
checkout_attempt_id: expect.any(String),
|
||||
previous_tier: 'standard'
|
||||
})
|
||||
expect(mockAccessBillingPortal).toHaveBeenCalledWith('creator-yearly')
|
||||
@@ -342,7 +341,6 @@ describe('PricingTable', () => {
|
||||
expect(
|
||||
window.localStorage.getItem(PENDING_SUBSCRIPTION_CHECKOUT_STORAGE_KEY)
|
||||
).toBeNull()
|
||||
expect(mockTrackBeginCheckout).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should use the latest userId value when it changes after mount', async () => {
|
||||
@@ -368,7 +366,6 @@ describe('PricingTable', () => {
|
||||
tier: 'creator',
|
||||
cycle: 'yearly',
|
||||
checkout_type: 'change',
|
||||
checkout_attempt_id: expect.any(String),
|
||||
previous_tier: 'standard'
|
||||
})
|
||||
})
|
||||
|
||||
@@ -277,19 +277,13 @@ import type {
|
||||
TierKey,
|
||||
TierPricing
|
||||
} from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import {
|
||||
recordPendingSubscriptionCheckoutAttempt,
|
||||
withPendingCheckoutAttemptId
|
||||
} from '@/platform/cloud/subscription/utils/subscriptionCheckoutTracker'
|
||||
import { recordPendingSubscriptionCheckoutAttempt } from '@/platform/cloud/subscription/utils/subscriptionCheckoutTracker'
|
||||
import { performSubscriptionCheckout } from '@/platform/cloud/subscription/utils/subscriptionCheckoutUtil'
|
||||
import { isPlanDowngrade } from '@/platform/cloud/subscription/utils/subscriptionTierRank'
|
||||
import type { BillingCycle } from '@/platform/cloud/subscription/utils/subscriptionTierRank'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import type {
|
||||
CheckoutAttributionMetadata,
|
||||
PaymentIntentSource
|
||||
} from '@/platform/telemetry/types'
|
||||
import type { CheckoutAttributionMetadata } from '@/platform/telemetry/types'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
|
||||
type CheckoutTierKey = Exclude<TierKey, 'free' | 'founder'>
|
||||
@@ -327,10 +321,6 @@ interface PricingTierConfig {
|
||||
isPopular?: boolean
|
||||
}
|
||||
|
||||
const { reason } = defineProps<{
|
||||
reason?: PaymentIntentSource
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
chooseTeamWorkspace: []
|
||||
}>()
|
||||
@@ -473,17 +463,16 @@ const handleSubscribe = wrapWithErrorHandlingAsync(
|
||||
} as const
|
||||
const previousPlan = currentPlanDescriptor.value
|
||||
const checkoutAttribution = await getCheckoutAttributionForCloud()
|
||||
const beginCheckoutMetadata = userId.value
|
||||
? {
|
||||
user_id: userId.value,
|
||||
tier: targetPlan.tierKey,
|
||||
cycle: targetPlan.billingCycle,
|
||||
checkout_type: 'change' as const,
|
||||
...(reason ? { payment_intent_source: reason } : {}),
|
||||
...checkoutAttribution,
|
||||
...(previousPlan ? { previous_tier: previousPlan.tierKey } : {})
|
||||
}
|
||||
: null
|
||||
if (userId.value) {
|
||||
telemetry?.trackBeginCheckout({
|
||||
user_id: userId.value,
|
||||
tier: targetPlan.tierKey,
|
||||
cycle: targetPlan.billingCycle,
|
||||
checkout_type: 'change',
|
||||
...checkoutAttribution,
|
||||
...(previousPlan ? { previous_tier: previousPlan.tierKey } : {})
|
||||
})
|
||||
}
|
||||
// Pass the target tier to create a deep link to subscription update confirmation
|
||||
const checkoutTier = getCheckoutTier(
|
||||
targetPlan.tierKey,
|
||||
@@ -498,39 +487,29 @@ const handleSubscribe = wrapWithErrorHandlingAsync(
|
||||
|
||||
if (downgrade) {
|
||||
// TODO(COMFY-StripeProration): Remove once backend checkout creation mirrors portal proration ("change at billing end")
|
||||
const didOpenPortal = await accessBillingPortal()
|
||||
if (didOpenPortal && beginCheckoutMetadata) {
|
||||
telemetry?.trackBeginCheckout(beginCheckoutMetadata)
|
||||
}
|
||||
await accessBillingPortal()
|
||||
} else {
|
||||
const didOpenPortal = await accessBillingPortal(checkoutTier)
|
||||
if (!didOpenPortal) {
|
||||
return
|
||||
}
|
||||
|
||||
const pendingAttempt = recordPendingSubscriptionCheckoutAttempt({
|
||||
recordPendingSubscriptionCheckoutAttempt({
|
||||
tier: targetPlan.tierKey,
|
||||
cycle: targetPlan.billingCycle,
|
||||
checkout_type: 'change',
|
||||
payment_intent_source: reason,
|
||||
...(previousPlan ? { previous_tier: previousPlan.tierKey } : {}),
|
||||
...(previousPlan
|
||||
? { previous_cycle: previousPlan.billingCycle }
|
||||
: {})
|
||||
})
|
||||
if (beginCheckoutMetadata) {
|
||||
telemetry?.trackBeginCheckout(
|
||||
withPendingCheckoutAttemptId(
|
||||
beginCheckoutMetadata,
|
||||
pendingAttempt
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
await performSubscriptionCheckout(tierKey, currentBillingCycle.value, {
|
||||
paymentIntentSource: reason
|
||||
})
|
||||
await performSubscriptionCheckout(
|
||||
tierKey,
|
||||
currentBillingCycle.value,
|
||||
true
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
|
||||
@@ -56,7 +56,7 @@ const handleSubscribe = () => {
|
||||
current_tier: tier.value?.toLowerCase()
|
||||
})
|
||||
isAwaitingStripeSubscription.value = true
|
||||
showSubscriptionDialog({ reason: 'subscribe_now_button' })
|
||||
showSubscriptionDialog()
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
|
||||
@@ -54,6 +54,6 @@ function handleSubscribeToRun() {
|
||||
trackRunButton({ subscribe_to_run: true })
|
||||
}
|
||||
|
||||
showSubscriptionDialog({ reason: 'subscribe_to_run' })
|
||||
showSubscriptionDialog()
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -48,9 +48,7 @@
|
||||
v-if="isActiveSubscription"
|
||||
variant="primary"
|
||||
class="rounded-lg px-4 py-2 text-sm font-normal text-text-primary"
|
||||
@click="
|
||||
showSubscriptionDialog({ reason: 'settings_billing_panel' })
|
||||
"
|
||||
@click="showSubscriptionDialog"
|
||||
>
|
||||
{{ $t('subscription.upgradePlan') }}
|
||||
</Button>
|
||||
|
||||
@@ -33,11 +33,7 @@
|
||||
</i18n-t>
|
||||
</div>
|
||||
|
||||
<PricingTable
|
||||
:reason
|
||||
class="flex-1"
|
||||
@choose-team-workspace="handleChooseTeam"
|
||||
/>
|
||||
<PricingTable class="flex-1" @choose-team-workspace="handleChooseTeam" />
|
||||
|
||||
<!-- Contact and Enterprise Links -->
|
||||
<div class="flex flex-col items-center gap-2">
|
||||
@@ -161,11 +157,11 @@ import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import type { PaymentIntentSource } from '@/platform/telemetry/types'
|
||||
import type { SubscriptionDialogReason } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
|
||||
|
||||
const { onClose, reason, onChooseTeam } = defineProps<{
|
||||
onClose: () => void
|
||||
reason?: PaymentIntentSource
|
||||
reason?: SubscriptionDialogReason
|
||||
onChooseTeam?: () => void
|
||||
}>()
|
||||
|
||||
|
||||
@@ -24,9 +24,7 @@ export function useAccountPreconditionDialog() {
|
||||
)
|
||||
return
|
||||
case 'subscription':
|
||||
void dialogService.showSubscriptionRequiredDialog({
|
||||
reason: 'subscription_required'
|
||||
})
|
||||
void dialogService.showSubscriptionRequiredDialog()
|
||||
return
|
||||
case 'credits':
|
||||
void dialogService.showTopUpCreditsDialog({
|
||||
|
||||
@@ -55,6 +55,12 @@ vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({
|
||||
})
|
||||
}))
|
||||
|
||||
const mockTrackSubscription = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: () => ({ trackSubscription: mockTrackSubscription })
|
||||
}))
|
||||
|
||||
describe('usePricingTableUrlLoader', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@@ -90,6 +96,9 @@ describe('usePricingTableUrlLoader', () => {
|
||||
reason: 'deep_link',
|
||||
planMode: undefined
|
||||
})
|
||||
expect(mockTrackSubscription).toHaveBeenCalledWith('modal_opened', {
|
||||
reason: 'deep_link'
|
||||
})
|
||||
expect(mockRouterReplace).toHaveBeenCalledWith({ query: {} })
|
||||
})
|
||||
|
||||
@@ -141,6 +150,7 @@ describe('usePricingTableUrlLoader', () => {
|
||||
await loadPricingTableFromUrl()
|
||||
|
||||
expect(mockShowPricingTable).not.toHaveBeenCalled()
|
||||
expect(mockTrackSubscription).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('denies, strips, and clears together when the user is not eligible', async () => {
|
||||
@@ -151,6 +161,7 @@ describe('usePricingTableUrlLoader', () => {
|
||||
await loadPricingTableFromUrl()
|
||||
|
||||
expect(mockShowPricingTable).not.toHaveBeenCalled()
|
||||
expect(mockTrackSubscription).not.toHaveBeenCalled()
|
||||
expect(mockRouterReplace).toHaveBeenCalledWith({
|
||||
query: { other: 'param' }
|
||||
})
|
||||
@@ -219,6 +230,7 @@ describe('usePricingTableUrlLoader', () => {
|
||||
)
|
||||
|
||||
expect(mockShowPricingTable).not.toHaveBeenCalled()
|
||||
expect(mockTrackSubscription).not.toHaveBeenCalled()
|
||||
expect(mockRouterReplace).toHaveBeenCalledWith({ query: {} })
|
||||
expect(preservedQueryMocks.clearPreservedQuery).toHaveBeenCalledWith(
|
||||
'pricing'
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
mergePreservedQueryIntoQuery
|
||||
} from '@/platform/navigation/preservedQueryManager'
|
||||
import { PRESERVED_QUERY_NAMESPACES } from '@/platform/navigation/preservedQueryNamespaces'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
|
||||
@@ -61,6 +62,7 @@ export function usePricingTableUrlLoader() {
|
||||
const planMode =
|
||||
param === 'team' || param === 'personal' ? param : undefined
|
||||
|
||||
useTelemetry()?.trackSubscription('modal_opened', { reason: 'deep_link' })
|
||||
subscriptionDialog.showPricingTable({ reason: 'deep_link', planMode })
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ import { t } from '@/i18n'
|
||||
import { fetchWithUnifiedRemint } from '@/platform/auth/unified/remintRetry'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import type { SubscriptionDialogOptions } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
|
||||
import type { SubscriptionDialogReason } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
|
||||
import type { CheckoutAttributionMetadata } from '@/platform/telemetry/types'
|
||||
import { AuthStoreError, useAuthStore } from '@/stores/authStore'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
@@ -237,7 +237,14 @@ function useSubscriptionInternal() {
|
||||
})
|
||||
}, reportError)
|
||||
|
||||
const showSubscriptionDialog = (options?: SubscriptionDialogOptions) => {
|
||||
const showSubscriptionDialog = (options?: {
|
||||
reason?: SubscriptionDialogReason
|
||||
}) => {
|
||||
useTelemetry()?.trackSubscription('modal_opened', {
|
||||
current_tier: subscriptionTier.value?.toLowerCase(),
|
||||
reason: options?.reason
|
||||
})
|
||||
|
||||
void showSubscriptionRequiredDialog(options)
|
||||
}
|
||||
|
||||
@@ -270,7 +277,7 @@ function useSubscriptionInternal() {
|
||||
await fetchSubscriptionStatus()
|
||||
|
||||
if (!isSubscribedOrIsNotCloud.value) {
|
||||
showSubscriptionDialog({ reason: 'subscription_required' })
|
||||
showSubscriptionDialog()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -39,23 +39,15 @@ vi.mock('@/stores/commandStore', () => ({
|
||||
}))
|
||||
|
||||
// useTelemetry() returns null in OSS, a dispatcher in cloud — toggle via mockIsCloud.
|
||||
const {
|
||||
mockIsCloud,
|
||||
mockTrackHelpResourceClicked,
|
||||
mockTrackAddApiCreditButtonClicked
|
||||
} = vi.hoisted(() => ({
|
||||
const { mockIsCloud, mockTrackHelpResourceClicked } = vi.hoisted(() => ({
|
||||
mockIsCloud: { value: true },
|
||||
mockTrackHelpResourceClicked: vi.fn(),
|
||||
mockTrackAddApiCreditButtonClicked: vi.fn()
|
||||
mockTrackHelpResourceClicked: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: () =>
|
||||
mockIsCloud.value
|
||||
? {
|
||||
trackHelpResourceClicked: mockTrackHelpResourceClicked,
|
||||
trackAddApiCreditButtonClicked: mockTrackAddApiCreditButtonClicked
|
||||
}
|
||||
? { trackHelpResourceClicked: mockTrackHelpResourceClicked }
|
||||
: null
|
||||
}))
|
||||
|
||||
@@ -77,9 +69,6 @@ describe('useSubscriptionActions', () => {
|
||||
const { handleAddApiCredits } = useSubscriptionActions()
|
||||
handleAddApiCredits()
|
||||
expect(mockShowTopUpCreditsDialog).toHaveBeenCalledOnce()
|
||||
expect(mockTrackAddApiCreditButtonClicked).toHaveBeenCalledWith({
|
||||
source: 'settings_billing_panel'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -21,9 +21,6 @@ export function useSubscriptionActions() {
|
||||
})
|
||||
|
||||
const handleAddApiCredits = () => {
|
||||
telemetry?.trackAddApiCreditButtonClicked({
|
||||
source: 'settings_billing_panel'
|
||||
})
|
||||
void dialogService.showTopUpCreditsDialog()
|
||||
}
|
||||
|
||||
|
||||
@@ -5,10 +5,8 @@ import { useSubscriptionDialog } from './useSubscriptionDialog'
|
||||
const mockCloseDialog = vi.fn()
|
||||
const mockShowLayoutDialog = vi.fn()
|
||||
const mockShowTeamWorkspacesDialog = vi.fn()
|
||||
const mockTrackSubscription = vi.hoisted(() => vi.fn())
|
||||
const mockIsInPersonalWorkspace = vi.hoisted(() => ({ value: true }))
|
||||
const mockIsFreeTier = vi.hoisted(() => ({ value: false }))
|
||||
const mockTier = vi.hoisted(() => ({ value: 'FREE' as string | null }))
|
||||
const mockTeamWorkspacesEnabled = vi.hoisted(() => ({ value: false }))
|
||||
const mockIsCloud = vi.hoisted(() => ({ value: true }))
|
||||
const mockIsLegacyTeamPlan = vi.hoisted(() => ({ value: false }))
|
||||
@@ -62,15 +60,10 @@ vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({
|
||||
vi.mock('@/composables/billing/useBillingContext', () => ({
|
||||
useBillingContext: () => ({
|
||||
isFreeTier: mockIsFreeTier,
|
||||
isLegacyTeamPlan: mockIsLegacyTeamPlan,
|
||||
tier: mockTier
|
||||
isLegacyTeamPlan: mockIsLegacyTeamPlan
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: () => ({ trackSubscription: mockTrackSubscription })
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workspace/composables/useWorkspaceUI', () => ({
|
||||
useWorkspaceUI: () => ({
|
||||
permissions: {
|
||||
@@ -87,7 +80,6 @@ describe('useSubscriptionDialog', () => {
|
||||
mockIsCloud.value = true
|
||||
mockIsInPersonalWorkspace.value = true
|
||||
mockIsFreeTier.value = false
|
||||
mockTier.value = 'FREE'
|
||||
mockTeamWorkspacesEnabled.value = false
|
||||
mockIsLegacyTeamPlan.value = false
|
||||
mockCanManageSubscription.value = true
|
||||
@@ -206,51 +198,6 @@ describe('useSubscriptionDialog', () => {
|
||||
const props = mockShowLayoutDialog.mock.calls[0][0].props
|
||||
expect(props.initialPlanMode).toBe('team')
|
||||
})
|
||||
|
||||
it('tracks modal_opened with the caller reason and current tier', () => {
|
||||
mockTier.value = 'STANDARD'
|
||||
const { showPricingTable } = useSubscriptionDialog()
|
||||
|
||||
showPricingTable({ reason: 'upgrade_to_add_credits' })
|
||||
|
||||
expect(mockTrackSubscription).toHaveBeenCalledWith('modal_opened', {
|
||||
current_tier: 'standard',
|
||||
reason: 'upgrade_to_add_credits'
|
||||
})
|
||||
})
|
||||
|
||||
it('tracks modal_opened on the workspace (unified) path too', () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
const { showPricingTable } = useSubscriptionDialog()
|
||||
|
||||
showPricingTable({ reason: 'subscribe_to_run' })
|
||||
|
||||
expect(mockTrackSubscription).toHaveBeenCalledWith(
|
||||
'modal_opened',
|
||||
expect.objectContaining({ reason: 'subscribe_to_run' })
|
||||
)
|
||||
})
|
||||
|
||||
it('does not track modal_opened for the inactive member dialog', () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockIsInPersonalWorkspace.value = false
|
||||
mockCanManageSubscription.value = false
|
||||
const { showPricingTable } = useSubscriptionDialog()
|
||||
|
||||
showPricingTable({ reason: 'subscribe_to_run' })
|
||||
|
||||
expect(mockShowLayoutDialog).toHaveBeenCalledTimes(1)
|
||||
expect(mockTrackSubscription).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not track on non-cloud', () => {
|
||||
mockIsCloud.value = false
|
||||
const { showPricingTable } = useSubscriptionDialog()
|
||||
|
||||
showPricingTable({ reason: 'subscribe_to_run' })
|
||||
|
||||
expect(mockTrackSubscription).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('show', () => {
|
||||
@@ -288,20 +235,6 @@ describe('useSubscriptionDialog', () => {
|
||||
expect.objectContaining({ key: 'subscription-required' })
|
||||
)
|
||||
})
|
||||
|
||||
it('tracks modal_opened with the reason for the free-tier dialog', () => {
|
||||
mockIsFreeTier.value = true
|
||||
mockIsInPersonalWorkspace.value = true
|
||||
const { show } = useSubscriptionDialog()
|
||||
|
||||
show({ reason: 'out_of_credits' })
|
||||
|
||||
expect(mockTrackSubscription).toHaveBeenCalledTimes(1)
|
||||
expect(mockTrackSubscription).toHaveBeenCalledWith(
|
||||
'modal_opened',
|
||||
expect.objectContaining({ reason: 'out_of_credits' })
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('startTeamWorkspaceUpgradeFlow', () => {
|
||||
|
||||
@@ -4,8 +4,6 @@ import { useDialogStore } from '@/stores/dialogStore'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import type { PaymentIntentSource } from '@/platform/telemetry/types'
|
||||
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
|
||||
@@ -13,8 +11,14 @@ const DIALOG_KEY = 'subscription-required'
|
||||
const FREE_TIER_DIALOG_KEY = 'free-tier-info'
|
||||
const RESUME_PRICING_KEY = 'comfy:resume-team-pricing'
|
||||
|
||||
export interface SubscriptionDialogOptions {
|
||||
reason?: PaymentIntentSource
|
||||
export type SubscriptionDialogReason =
|
||||
| 'subscription_required'
|
||||
| 'out_of_credits'
|
||||
| 'top_up_blocked'
|
||||
| 'deep_link'
|
||||
|
||||
interface SubscriptionDialogOptions {
|
||||
reason?: SubscriptionDialogReason
|
||||
/**
|
||||
* Forces the unified pricing dialog to open on a specific plan tab,
|
||||
* overriding the workspace-derived default (e.g. an "Upgrade to Team" CTA
|
||||
@@ -34,17 +38,6 @@ export const useSubscriptionDialog = () => {
|
||||
dialogStore.closeDialog({ key: FREE_TIER_DIALOG_KEY })
|
||||
}
|
||||
|
||||
// Fired here — the choke point every paywall/pricing dialog variant passes
|
||||
// through — so both the legacy and workspace billing paths emit it.
|
||||
function trackModalOpened(reason?: PaymentIntentSource) {
|
||||
// Resolved lazily to avoid the useBillingContext import cycle (see below).
|
||||
const { tier } = useBillingContext()
|
||||
useTelemetry()?.trackSubscription('modal_opened', {
|
||||
current_tier: tier.value?.toLowerCase(),
|
||||
reason
|
||||
})
|
||||
}
|
||||
|
||||
function showPricingTable(options?: SubscriptionDialogOptions) {
|
||||
if (!isCloud) return
|
||||
|
||||
@@ -78,8 +71,6 @@ export const useSubscriptionDialog = () => {
|
||||
return
|
||||
}
|
||||
|
||||
trackModalOpened(options?.reason)
|
||||
|
||||
// Shared dialog shell styling for both variants.
|
||||
const dialogComponentProps = {
|
||||
style: 'width: min(1328px, 95vw); max-height: 958px;',
|
||||
@@ -176,8 +167,6 @@ export const useSubscriptionDialog = () => {
|
||||
// (not at composable setup) to avoid the useBillingContext import cycle.
|
||||
const { isFreeTier } = useBillingContext()
|
||||
if (isFreeTier.value && workspaceStore.isInPersonalWorkspace) {
|
||||
trackModalOpened(options?.reason)
|
||||
|
||||
const component = defineAsyncComponent(
|
||||
() =>
|
||||
import('@/platform/cloud/subscription/components/FreeTierDialogContent.vue')
|
||||
@@ -247,7 +236,7 @@ export const useSubscriptionDialog = () => {
|
||||
sessionStorage.removeItem(RESUME_PRICING_KEY)
|
||||
|
||||
if (!workspaceStore.isInPersonalWorkspace) {
|
||||
showPricingTable({ reason: 'team_upgrade_resume' })
|
||||
showPricingTable()
|
||||
}
|
||||
} catch {
|
||||
// sessionStorage may be unavailable
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
clearPendingSubscriptionCheckoutAttempt,
|
||||
consumePendingSubscriptionCheckoutSuccess,
|
||||
recordPendingSubscriptionCheckoutAttempt
|
||||
} from './subscriptionCheckoutTracker'
|
||||
|
||||
const activeProStatus = {
|
||||
is_active: true,
|
||||
subscription_tier: 'PRO',
|
||||
subscription_duration: 'MONTHLY'
|
||||
} as const
|
||||
|
||||
describe('subscriptionCheckoutTracker', () => {
|
||||
beforeEach(() => {
|
||||
clearPendingSubscriptionCheckoutAttempt()
|
||||
})
|
||||
|
||||
it('round-trips payment_intent_source from attempt to success metadata', () => {
|
||||
recordPendingSubscriptionCheckoutAttempt({
|
||||
tier: 'pro',
|
||||
cycle: 'monthly',
|
||||
checkout_type: 'new',
|
||||
payment_intent_source: 'subscribe_to_run'
|
||||
})
|
||||
|
||||
const metadata = consumePendingSubscriptionCheckoutSuccess(activeProStatus)
|
||||
|
||||
expect(metadata).toMatchObject({
|
||||
tier: 'pro',
|
||||
checkout_type: 'new',
|
||||
payment_intent_source: 'subscribe_to_run'
|
||||
})
|
||||
})
|
||||
|
||||
it('omits payment_intent_source when the attempt had none', () => {
|
||||
recordPendingSubscriptionCheckoutAttempt({
|
||||
tier: 'pro',
|
||||
cycle: 'monthly',
|
||||
checkout_type: 'new'
|
||||
})
|
||||
|
||||
const metadata = consumePendingSubscriptionCheckoutSuccess(activeProStatus)
|
||||
|
||||
expect(metadata).not.toBeNull()
|
||||
expect(metadata).not.toHaveProperty('payment_intent_source')
|
||||
})
|
||||
})
|
||||
@@ -7,12 +7,7 @@ import type {
|
||||
TierKey
|
||||
} from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import type { BillingCycle } from '@/platform/cloud/subscription/utils/subscriptionTierRank'
|
||||
import type {
|
||||
BeginCheckoutMetadata,
|
||||
PaymentIntentSource,
|
||||
SubscriptionCheckoutType,
|
||||
SubscriptionSuccessMetadata
|
||||
} from '@/platform/telemetry/types'
|
||||
import type { SubscriptionSuccessMetadata } from '@/platform/telemetry/types'
|
||||
|
||||
const PENDING_SUBSCRIPTION_CHECKOUT_MAX_AGE_MS = 6 * 60 * 60 * 1000
|
||||
const VALID_TIER_KEYS = new Set<TierKey>([
|
||||
@@ -28,6 +23,7 @@ export const PENDING_SUBSCRIPTION_CHECKOUT_STORAGE_KEY =
|
||||
export const PENDING_SUBSCRIPTION_CHECKOUT_EVENT =
|
||||
'comfy:subscription-checkout-attempt-changed'
|
||||
|
||||
type CheckoutType = 'new' | 'change'
|
||||
type SubscriptionDuration = 'MONTHLY' | 'ANNUAL'
|
||||
|
||||
interface SubscriptionStatusSnapshot {
|
||||
@@ -36,24 +32,22 @@ interface SubscriptionStatusSnapshot {
|
||||
subscription_duration?: SubscriptionDuration | null
|
||||
}
|
||||
|
||||
export interface PendingSubscriptionCheckoutAttempt {
|
||||
interface PendingSubscriptionCheckoutAttempt {
|
||||
attempt_id: string
|
||||
started_at_ms: number
|
||||
tier: TierKey
|
||||
cycle: BillingCycle
|
||||
checkout_type: SubscriptionCheckoutType
|
||||
checkout_type: CheckoutType
|
||||
previous_tier?: TierKey
|
||||
previous_cycle?: BillingCycle
|
||||
payment_intent_source?: PaymentIntentSource
|
||||
}
|
||||
|
||||
interface PendingSubscriptionCheckoutAttemptInput {
|
||||
interface RecordPendingSubscriptionCheckoutAttemptInput {
|
||||
tier: TierKey
|
||||
cycle: BillingCycle
|
||||
checkout_type: SubscriptionCheckoutType
|
||||
checkout_type: CheckoutType
|
||||
previous_tier?: TierKey
|
||||
previous_cycle?: BillingCycle
|
||||
payment_intent_source?: PaymentIntentSource
|
||||
}
|
||||
|
||||
const dispatchPendingCheckoutChangeEvent = () => {
|
||||
@@ -174,9 +168,6 @@ const normalizeAttempt = (
|
||||
...(candidate.previous_cycle === 'monthly' ||
|
||||
candidate.previous_cycle === 'yearly'
|
||||
? { previous_cycle: candidate.previous_cycle }
|
||||
: {}),
|
||||
...(typeof candidate.payment_intent_source === 'string'
|
||||
? { payment_intent_source: candidate.payment_intent_source }
|
||||
: {})
|
||||
}
|
||||
}
|
||||
@@ -233,27 +224,20 @@ const getPendingSubscriptionCheckoutAttempt =
|
||||
export const hasPendingSubscriptionCheckoutAttempt = (): boolean =>
|
||||
getPendingSubscriptionCheckoutAttempt() !== null
|
||||
|
||||
export const createPendingSubscriptionCheckoutAttempt = (
|
||||
input: PendingSubscriptionCheckoutAttemptInput
|
||||
export const recordPendingSubscriptionCheckoutAttempt = (
|
||||
input: RecordPendingSubscriptionCheckoutAttemptInput
|
||||
): PendingSubscriptionCheckoutAttempt => {
|
||||
return {
|
||||
const storage = getStorage()
|
||||
const attempt: PendingSubscriptionCheckoutAttempt = {
|
||||
attempt_id: createAttemptId(),
|
||||
started_at_ms: Date.now(),
|
||||
tier: input.tier,
|
||||
cycle: input.cycle,
|
||||
checkout_type: input.checkout_type,
|
||||
...(input.previous_tier ? { previous_tier: input.previous_tier } : {}),
|
||||
...(input.previous_cycle ? { previous_cycle: input.previous_cycle } : {}),
|
||||
...(input.payment_intent_source
|
||||
? { payment_intent_source: input.payment_intent_source }
|
||||
: {})
|
||||
...(input.previous_cycle ? { previous_cycle: input.previous_cycle } : {})
|
||||
}
|
||||
}
|
||||
|
||||
export const persistPendingSubscriptionCheckoutAttempt = (
|
||||
attempt: PendingSubscriptionCheckoutAttempt
|
||||
): PendingSubscriptionCheckoutAttempt => {
|
||||
const storage = getStorage()
|
||||
if (!storage) {
|
||||
return attempt
|
||||
}
|
||||
@@ -271,21 +255,6 @@ export const persistPendingSubscriptionCheckoutAttempt = (
|
||||
return attempt
|
||||
}
|
||||
|
||||
export const recordPendingSubscriptionCheckoutAttempt = (
|
||||
input: PendingSubscriptionCheckoutAttemptInput
|
||||
): PendingSubscriptionCheckoutAttempt =>
|
||||
persistPendingSubscriptionCheckoutAttempt(
|
||||
createPendingSubscriptionCheckoutAttempt(input)
|
||||
)
|
||||
|
||||
export const withPendingCheckoutAttemptId = (
|
||||
metadata: BeginCheckoutMetadata,
|
||||
attempt: PendingSubscriptionCheckoutAttempt
|
||||
): BeginCheckoutMetadata => ({
|
||||
...metadata,
|
||||
checkout_attempt_id: attempt.attempt_id
|
||||
})
|
||||
|
||||
const didAttemptSucceed = (
|
||||
attempt: PendingSubscriptionCheckoutAttempt,
|
||||
status: SubscriptionStatusSnapshot
|
||||
@@ -318,9 +287,6 @@ export const consumePendingSubscriptionCheckoutSuccess = (
|
||||
cycle: attempt.cycle,
|
||||
checkout_type: attempt.checkout_type,
|
||||
...(attempt.previous_tier ? { previous_tier: attempt.previous_tier } : {}),
|
||||
...(attempt.payment_intent_source
|
||||
? { payment_intent_source: attempt.payment_intent_source }
|
||||
: {}),
|
||||
value,
|
||||
currency: 'USD',
|
||||
ecommerce: {
|
||||
|
||||
@@ -132,14 +132,13 @@ describe('performSubscriptionCheckout', () => {
|
||||
json: async () => ({ checkout_url: checkoutUrl })
|
||||
} as Response)
|
||||
|
||||
await performSubscriptionCheckout('pro', 'yearly')
|
||||
await performSubscriptionCheckout('pro', 'yearly', true)
|
||||
|
||||
expect(mockTelemetry.trackBeginCheckout).toHaveBeenCalledWith({
|
||||
user_id: 'user-123',
|
||||
tier: 'pro',
|
||||
cycle: 'yearly',
|
||||
checkout_type: 'new',
|
||||
checkout_attempt_id: expect.any(String),
|
||||
ga_client_id: 'ga-client-id',
|
||||
ga_session_id: 'ga-session-id',
|
||||
ga_session_number: 'ga-session-number',
|
||||
@@ -151,12 +150,6 @@ describe('performSubscriptionCheckout', () => {
|
||||
gbraid: 'gbraid-456',
|
||||
wbraid: 'wbraid-789'
|
||||
})
|
||||
const beginCheckoutMetadata =
|
||||
mockTelemetry.trackBeginCheckout.mock.calls[0][0]
|
||||
const [, storedAttempt] = mockLocalStorage.setItem.mock.calls[0]
|
||||
expect(beginCheckoutMetadata.checkout_attempt_id).toBe(
|
||||
JSON.parse(storedAttempt).attempt_id
|
||||
)
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'/customers/cloud-subscription-checkout/pro-yearly'
|
||||
@@ -193,7 +186,7 @@ describe('performSubscriptionCheckout', () => {
|
||||
json: async () => ({ checkout_url: checkoutUrl })
|
||||
} as Response)
|
||||
|
||||
await performSubscriptionCheckout('pro', 'monthly')
|
||||
await performSubscriptionCheckout('pro', 'monthly', true)
|
||||
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
'[SubscriptionCheckout] Failed to collect checkout attribution',
|
||||
@@ -210,43 +203,11 @@ describe('performSubscriptionCheckout', () => {
|
||||
user_id: 'user-123',
|
||||
tier: 'pro',
|
||||
cycle: 'monthly',
|
||||
checkout_type: 'new',
|
||||
checkout_attempt_id: expect.any(String)
|
||||
checkout_type: 'new'
|
||||
})
|
||||
expect(openSpy).toHaveBeenCalledWith(checkoutUrl, '_blank')
|
||||
})
|
||||
|
||||
it('carries the payment intent source into begin_checkout and the pending attempt', async () => {
|
||||
const checkoutUrl = 'https://checkout.stripe.com/test'
|
||||
const openSpy = vi
|
||||
.spyOn(window, 'open')
|
||||
.mockImplementation(() => window as unknown as Window)
|
||||
|
||||
vi.mocked(global.fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ checkout_url: checkoutUrl })
|
||||
} as Response)
|
||||
|
||||
await performSubscriptionCheckout('pro', 'monthly', {
|
||||
paymentIntentSource: 'out_of_credits'
|
||||
})
|
||||
|
||||
expect(mockTelemetry.trackBeginCheckout).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ payment_intent_source: 'out_of_credits' })
|
||||
)
|
||||
const beginCheckoutMetadata =
|
||||
mockTelemetry.trackBeginCheckout.mock.calls[0][0]
|
||||
const [, storedAttempt] = mockLocalStorage.setItem.mock.calls[0]
|
||||
const pendingAttempt = JSON.parse(storedAttempt)
|
||||
expect(pendingAttempt).toMatchObject({
|
||||
payment_intent_source: 'out_of_credits'
|
||||
})
|
||||
expect(beginCheckoutMetadata.checkout_attempt_id).toBe(
|
||||
pendingAttempt.attempt_id
|
||||
)
|
||||
openSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('uses the latest userId when it changes after checkout starts', async () => {
|
||||
const checkoutUrl = 'https://checkout.stripe.com/test'
|
||||
const openSpy = vi
|
||||
@@ -261,7 +222,7 @@ describe('performSubscriptionCheckout', () => {
|
||||
json: async () => ({ checkout_url: checkoutUrl })
|
||||
} as Response)
|
||||
|
||||
const checkoutPromise = performSubscriptionCheckout('pro', 'yearly')
|
||||
const checkoutPromise = performSubscriptionCheckout('pro', 'yearly', true)
|
||||
|
||||
mockUserId.value = 'user-late'
|
||||
authHeader.resolve({ Authorization: 'Bearer test-token' })
|
||||
@@ -274,14 +235,13 @@ describe('performSubscriptionCheckout', () => {
|
||||
user_id: 'user-late',
|
||||
tier: 'pro',
|
||||
cycle: 'yearly',
|
||||
checkout_type: 'new',
|
||||
checkout_attempt_id: expect.any(String)
|
||||
checkout_type: 'new'
|
||||
})
|
||||
)
|
||||
expect(openSpy).toHaveBeenCalledWith(checkoutUrl, '_blank')
|
||||
})
|
||||
|
||||
it('does not persist the pending attempt when the checkout popup is blocked', async () => {
|
||||
it('does not persist a pending attempt when the checkout popup is blocked', async () => {
|
||||
const checkoutUrl = 'https://checkout.stripe.com/test'
|
||||
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null)
|
||||
|
||||
@@ -290,18 +250,11 @@ describe('performSubscriptionCheckout', () => {
|
||||
json: async () => ({ checkout_url: checkoutUrl })
|
||||
} as Response)
|
||||
|
||||
await performSubscriptionCheckout('pro', 'monthly')
|
||||
await performSubscriptionCheckout('pro', 'monthly', true)
|
||||
|
||||
expect(openSpy).toHaveBeenCalledWith(checkoutUrl, '_blank')
|
||||
const storedAttempt = window.localStorage.getItem(
|
||||
PENDING_SUBSCRIPTION_CHECKOUT_STORAGE_KEY
|
||||
)
|
||||
expect(storedAttempt).toBeNull()
|
||||
expect(mockLocalStorage.setItem).not.toHaveBeenCalled()
|
||||
expect(mockTelemetry.trackBeginCheckout).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
checkout_attempt_id: expect.any(String)
|
||||
})
|
||||
)
|
||||
expect(
|
||||
window.localStorage.getItem(PENDING_SUBSCRIPTION_CHECKOUT_STORAGE_KEY)
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,19 +4,12 @@ import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { getComfyApiBaseUrl } from '@/config/comfyApi'
|
||||
import { t } from '@/i18n'
|
||||
import { fetchWithUnifiedRemint } from '@/platform/auth/unified/remintRetry'
|
||||
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import {
|
||||
createPendingSubscriptionCheckoutAttempt,
|
||||
persistPendingSubscriptionCheckoutAttempt,
|
||||
withPendingCheckoutAttemptId
|
||||
} from '@/platform/cloud/subscription/utils/subscriptionCheckoutTracker'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import type {
|
||||
CheckoutAttributionMetadata,
|
||||
PaymentIntentSource
|
||||
} from '@/platform/telemetry/types'
|
||||
import { AuthStoreError, useAuthStore } from '@/stores/authStore'
|
||||
import type { CheckoutAttributionMetadata } from '@/platform/telemetry/types'
|
||||
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import { recordPendingSubscriptionCheckoutAttempt } from '@/platform/cloud/subscription/utils/subscriptionCheckoutTracker'
|
||||
import type { BillingCycle } from './subscriptionTierRank'
|
||||
|
||||
type CheckoutTier = TierKey | `${TierKey}-yearly`
|
||||
@@ -38,11 +31,6 @@ const getCheckoutAttributionForCloud =
|
||||
return getCheckoutAttribution()
|
||||
}
|
||||
|
||||
interface PerformSubscriptionCheckoutOptions {
|
||||
openInNewTab?: boolean
|
||||
paymentIntentSource?: PaymentIntentSource
|
||||
}
|
||||
|
||||
/**
|
||||
* Core subscription checkout logic shared between PricingTable and
|
||||
* SubscriptionRedirectView. Handles:
|
||||
@@ -59,12 +47,10 @@ interface PerformSubscriptionCheckoutOptions {
|
||||
export async function performSubscriptionCheckout(
|
||||
tierKey: TierKey,
|
||||
currentBillingCycle: BillingCycle,
|
||||
options: PerformSubscriptionCheckoutOptions = {}
|
||||
openInNewTab: boolean = true
|
||||
): Promise<void> {
|
||||
if (!isCloud) return
|
||||
|
||||
const { openInNewTab = true, paymentIntentSource } = options
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const { userId } = storeToRefs(authStore)
|
||||
const telemetry = useTelemetry()
|
||||
@@ -122,29 +108,14 @@ export async function performSubscriptionCheckout(
|
||||
const data = await response.json()
|
||||
|
||||
if (data.checkout_url) {
|
||||
const pendingAttempt = createPendingSubscriptionCheckoutAttempt({
|
||||
tier: tierKey,
|
||||
cycle: currentBillingCycle,
|
||||
checkout_type: 'new',
|
||||
payment_intent_source: paymentIntentSource
|
||||
})
|
||||
|
||||
if (userId.value) {
|
||||
telemetry?.trackBeginCheckout(
|
||||
withPendingCheckoutAttemptId(
|
||||
{
|
||||
user_id: userId.value,
|
||||
tier: tierKey,
|
||||
cycle: currentBillingCycle,
|
||||
checkout_type: 'new',
|
||||
...(paymentIntentSource
|
||||
? { payment_intent_source: paymentIntentSource }
|
||||
: {}),
|
||||
...checkoutAttribution
|
||||
},
|
||||
pendingAttempt
|
||||
)
|
||||
)
|
||||
telemetry?.trackBeginCheckout({
|
||||
user_id: userId.value,
|
||||
tier: tierKey,
|
||||
cycle: currentBillingCycle,
|
||||
checkout_type: 'new',
|
||||
...checkoutAttribution
|
||||
})
|
||||
}
|
||||
|
||||
if (openInNewTab) {
|
||||
@@ -152,9 +123,18 @@ export async function performSubscriptionCheckout(
|
||||
if (!checkoutWindow) {
|
||||
return
|
||||
}
|
||||
persistPendingSubscriptionCheckoutAttempt(pendingAttempt)
|
||||
|
||||
recordPendingSubscriptionCheckoutAttempt({
|
||||
tier: tierKey,
|
||||
cycle: currentBillingCycle,
|
||||
checkout_type: 'new'
|
||||
})
|
||||
} else {
|
||||
persistPendingSubscriptionCheckoutAttempt(pendingAttempt)
|
||||
recordPendingSubscriptionCheckoutAttempt({
|
||||
tier: tierKey,
|
||||
cycle: currentBillingCycle,
|
||||
checkout_type: 'new'
|
||||
})
|
||||
globalThis.location.href = data.checkout_url
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { computed, reactive } from 'vue'
|
||||
|
||||
const { mockIsCloud, mockSubscribe, mockTrackBeginCheckout, mockUserId } =
|
||||
vi.hoisted(() => ({
|
||||
mockIsCloud: { value: true },
|
||||
mockSubscribe: vi.fn(),
|
||||
mockTrackBeginCheckout: vi.fn(),
|
||||
mockUserId: { value: 'user-1' as string | null }
|
||||
}))
|
||||
const { mockIsCloud, mockSubscribe } = vi.hoisted(() => ({
|
||||
mockIsCloud: { value: true },
|
||||
mockSubscribe: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
get isCloud() {
|
||||
@@ -20,12 +16,6 @@ vi.mock('@/config/comfyApi', () => ({
|
||||
vi.mock('@/platform/workspace/api/workspaceApi', () => ({
|
||||
workspaceApi: { subscribe: mockSubscribe }
|
||||
}))
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: () => ({ trackBeginCheckout: mockTrackBeginCheckout })
|
||||
}))
|
||||
vi.mock('@/stores/authStore', () => ({
|
||||
useAuthStore: () => reactive({ userId: computed(() => mockUserId.value) })
|
||||
}))
|
||||
|
||||
import { performTeamSubscriptionCheckout } from './teamSubscriptionCheckoutUtil'
|
||||
|
||||
@@ -53,9 +43,7 @@ describe('performTeamSubscriptionCheckout', () => {
|
||||
billing_op_id: 'op_1'
|
||||
})
|
||||
|
||||
await performTeamSubscriptionCheckout('team_700', 'yearly', {
|
||||
paymentIntentSource: 'deep_link'
|
||||
})
|
||||
await performTeamSubscriptionCheckout('team_700', 'yearly')
|
||||
|
||||
expect(mockSubscribe).toHaveBeenCalledWith('team_per_credit_annual', {
|
||||
returnUrl: 'https://app.test/payment/success',
|
||||
@@ -63,14 +51,6 @@ describe('performTeamSubscriptionCheckout', () => {
|
||||
teamCreditStopId: 'team_700'
|
||||
})
|
||||
expect(assignedHref).toBe('https://stripe.test/pay')
|
||||
expect(mockTrackBeginCheckout).toHaveBeenCalledWith({
|
||||
user_id: 'user-1',
|
||||
tier: 'team',
|
||||
cycle: 'yearly',
|
||||
checkout_type: 'new',
|
||||
billing_op_id: 'op_1',
|
||||
payment_intent_source: 'deep_link'
|
||||
})
|
||||
})
|
||||
|
||||
it('uses the monthly slug and lands in the app when no Stripe step is needed', async () => {
|
||||
@@ -102,16 +82,6 @@ describe('performTeamSubscriptionCheckout', () => {
|
||||
expect(assignedHref).toBeUndefined()
|
||||
})
|
||||
|
||||
it('does not track begin_checkout when subscribe fails', async () => {
|
||||
mockSubscribe.mockRejectedValueOnce(new Error('subscribe failed'))
|
||||
|
||||
await expect(
|
||||
performTeamSubscriptionCheckout('team_700', 'yearly')
|
||||
).rejects.toThrow('subscribe failed')
|
||||
|
||||
expect(mockTrackBeginCheckout).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does nothing off cloud', async () => {
|
||||
mockIsCloud.value = false
|
||||
|
||||
|
||||
@@ -1,16 +1,10 @@
|
||||
import { getComfyPlatformBaseUrl } from '@/config/comfyApi'
|
||||
import { getTeamPlanSlug } from '@/platform/cloud/subscription/constants/teamPlanCreditStops'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import type { PaymentIntentSource } from '@/platform/telemetry/types'
|
||||
import { workspaceApi } from '@/platform/workspace/api/workspaceApi'
|
||||
import { trackWorkspaceCheckoutStarted } from '@/platform/workspace/utils/workspaceCheckoutTelemetry'
|
||||
|
||||
import type { BillingCycle } from './subscriptionTierRank'
|
||||
|
||||
interface PerformTeamSubscriptionCheckoutOptions {
|
||||
paymentIntentSource?: PaymentIntentSource
|
||||
}
|
||||
|
||||
/**
|
||||
* Direct team-plan checkout for the marketing `/cloud/subscribe?tier=team` deep
|
||||
* link: subscribes to the per-credit Team plan at the chosen slider stop and
|
||||
@@ -28,8 +22,7 @@ interface PerformTeamSubscriptionCheckoutOptions {
|
||||
*/
|
||||
export async function performTeamSubscriptionCheckout(
|
||||
teamCreditStopId: string,
|
||||
billingCycle: BillingCycle,
|
||||
options: PerformTeamSubscriptionCheckoutOptions = {}
|
||||
billingCycle: BillingCycle
|
||||
): Promise<void> {
|
||||
if (!isCloud) return
|
||||
|
||||
@@ -40,14 +33,6 @@ export async function performTeamSubscriptionCheckout(
|
||||
teamCreditStopId
|
||||
})
|
||||
|
||||
trackWorkspaceCheckoutStarted({
|
||||
tier: 'team',
|
||||
cycle: billingCycle,
|
||||
checkoutType: 'new',
|
||||
billingOpId: response.billing_op_id,
|
||||
paymentIntentSource: options.paymentIntentSource
|
||||
})
|
||||
|
||||
if (response.status === 'needs_payment_method') {
|
||||
// A needs_payment_method response without a URL is unusable: surface it to
|
||||
// the caller's error handling rather than silently dropping the user home
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import {
|
||||
getSettingInfo,
|
||||
@@ -11,31 +10,47 @@ import type { SettingTreeNode } from '@/platform/settings/settingStore'
|
||||
|
||||
import { useSettingUI } from './useSettingUI'
|
||||
|
||||
const { auth, billing, dist, featureFlags, vueFlags } = vi.hoisted(() => ({
|
||||
auth: { isLoggedIn: { value: false } },
|
||||
billing: { isActiveSubscription: { value: false } },
|
||||
dist: { isCloud: false, isDesktop: false },
|
||||
featureFlags: { teamWorkspacesEnabled: false, userSecretsEnabled: false },
|
||||
vueFlags: { shouldRenderVueNodes: { value: false } }
|
||||
}))
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({ t: (_: string, fallback: string) => fallback })
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/auth/useCurrentUser', () => ({
|
||||
useCurrentUser: () => ({ isLoggedIn: ref(false) })
|
||||
useCurrentUser: () => ({ isLoggedIn: auth.isLoggedIn })
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/billing/useBillingContext', () => ({
|
||||
useBillingContext: () => ({ isActiveSubscription: ref(false) })
|
||||
useBillingContext: () => ({
|
||||
isActiveSubscription: billing.isActiveSubscription
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useFeatureFlags', () => ({
|
||||
useFeatureFlags: () => ({
|
||||
flags: { teamWorkspacesEnabled: false, userSecretsEnabled: false }
|
||||
flags: featureFlags
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useVueFeatureFlags', () => ({
|
||||
useVueFeatureFlags: () => ({ shouldRenderVueNodes: ref(false) })
|
||||
useVueFeatureFlags: () => ({
|
||||
shouldRenderVueNodes: vueFlags.shouldRenderVueNodes
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
isCloud: false,
|
||||
isDesktop: false
|
||||
get isCloud() {
|
||||
return dist.isCloud
|
||||
},
|
||||
get isDesktop() {
|
||||
return dist.isDesktop
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
@@ -49,6 +64,7 @@ interface MockSettingParams {
|
||||
type: string
|
||||
defaultValue: unknown
|
||||
category?: string[]
|
||||
hideInVueNodes?: boolean
|
||||
}
|
||||
|
||||
describe('useSettingUI', () => {
|
||||
@@ -72,13 +88,23 @@ describe('useSettingUI', () => {
|
||||
defaultValue: 'dark'
|
||||
}
|
||||
}
|
||||
let settingsById: Record<string, MockSettingParams>
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia())
|
||||
vi.clearAllMocks()
|
||||
auth.isLoggedIn.value = false
|
||||
billing.isActiveSubscription.value = false
|
||||
dist.isCloud = false
|
||||
dist.isDesktop = false
|
||||
featureFlags.teamWorkspacesEnabled = false
|
||||
featureFlags.userSecretsEnabled = false
|
||||
vueFlags.shouldRenderVueNodes.value = false
|
||||
Object.assign(window, { __CONFIG__: {} })
|
||||
|
||||
settingsById = mockSettings
|
||||
vi.mocked(useSettingStore).mockReturnValue({
|
||||
settingsById: mockSettings
|
||||
settingsById
|
||||
} as ReturnType<typeof useSettingStore>)
|
||||
|
||||
vi.mocked(getSettingInfo).mockImplementation((setting) => {
|
||||
@@ -107,9 +133,9 @@ describe('useSettingUI', () => {
|
||||
undefined,
|
||||
'Comfy.Locale'
|
||||
)
|
||||
const comfyCategory = findCategory(settingCategories.value, 'Comfy')
|
||||
expect(comfyCategory).toBeDefined()
|
||||
expect(defaultCategory.value).toBe(comfyCategory)
|
||||
expect(defaultCategory.value).toBe(
|
||||
findCategory(settingCategories.value, 'Comfy')
|
||||
)
|
||||
})
|
||||
|
||||
it('resolves different category from scrollToSettingId', () => {
|
||||
@@ -121,7 +147,6 @@ describe('useSettingUI', () => {
|
||||
settingCategories.value,
|
||||
'Appearance'
|
||||
)
|
||||
expect(appearanceCategory).toBeDefined()
|
||||
expect(defaultCategory.value).toBe(appearanceCategory)
|
||||
})
|
||||
|
||||
@@ -137,4 +162,82 @@ describe('useSettingUI', () => {
|
||||
const { defaultCategory } = useSettingUI('about', 'Comfy.Locale')
|
||||
expect(defaultCategory.value.key).toBe('about')
|
||||
})
|
||||
|
||||
it('falls back when defaultPanel is not in the menu', () => {
|
||||
const missingPanel = 'missing' as unknown as Parameters<
|
||||
typeof useSettingUI
|
||||
>[0]
|
||||
const { defaultCategory, settingCategories } = useSettingUI(missingPanel)
|
||||
expect(defaultCategory.value).toBe(settingCategories.value[0])
|
||||
})
|
||||
|
||||
it('moves floating settings into Other and hides Vue-node-only settings', () => {
|
||||
settingsById = {
|
||||
Floating: {
|
||||
id: 'Floating',
|
||||
name: 'Floating',
|
||||
type: 'boolean',
|
||||
defaultValue: false
|
||||
},
|
||||
'Hidden.Setting': {
|
||||
id: 'Hidden.Setting',
|
||||
name: 'Hidden',
|
||||
type: 'hidden',
|
||||
defaultValue: false
|
||||
},
|
||||
'Vue.Hidden': {
|
||||
id: 'Vue.Hidden',
|
||||
name: 'Vue Hidden',
|
||||
type: 'boolean',
|
||||
defaultValue: false,
|
||||
hideInVueNodes: true
|
||||
}
|
||||
}
|
||||
vi.mocked(useSettingStore).mockReturnValue({
|
||||
settingsById
|
||||
} as ReturnType<typeof useSettingStore>)
|
||||
vueFlags.shouldRenderVueNodes.value = true
|
||||
|
||||
const { settingCategories } = useSettingUI()
|
||||
|
||||
expect(settingCategories.value.map((category) => category.label)).toEqual([
|
||||
'Other'
|
||||
])
|
||||
expect(
|
||||
settingCategories.value[0].children?.map((node) => node.key)
|
||||
).toEqual(['root/Floating'])
|
||||
})
|
||||
|
||||
it('adds gated cloud, desktop, workspace, and secrets panels', () => {
|
||||
auth.isLoggedIn.value = true
|
||||
billing.isActiveSubscription.value = true
|
||||
dist.isCloud = true
|
||||
dist.isDesktop = true
|
||||
featureFlags.teamWorkspacesEnabled = true
|
||||
featureFlags.userSecretsEnabled = true
|
||||
Object.assign(window, { __CONFIG__: { subscription_required: true } })
|
||||
|
||||
const { findCategoryByKey, findPanelByKey, navGroups, panels } =
|
||||
useSettingUI()
|
||||
|
||||
expect(panels.value.map((panel) => panel.node.key)).toEqual([
|
||||
'about',
|
||||
'credits',
|
||||
'user',
|
||||
'workspace',
|
||||
'keybinding',
|
||||
'extension',
|
||||
'server-config',
|
||||
'subscription',
|
||||
'secrets'
|
||||
])
|
||||
expect(navGroups.value.map((group) => group.title)).toEqual([
|
||||
'Workspace',
|
||||
'General'
|
||||
])
|
||||
expect(findCategoryByKey('secrets')?.key).toBe('secrets')
|
||||
expect(findCategoryByKey('missing')).toBeNull()
|
||||
expect(findPanelByKey('subscription')?.node.key).toBe('subscription')
|
||||
expect(findPanelByKey('missing')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -30,39 +30,6 @@ describe('TelemetryRegistry', () => {
|
||||
expect(b.trackSearchQuery).toHaveBeenCalledExactlyOnceWith(payload)
|
||||
})
|
||||
|
||||
it('dispatches trackBeginCheckout with intent metadata to every provider', () => {
|
||||
const a: TelemetryProvider = { trackBeginCheckout: vi.fn() }
|
||||
const b: TelemetryProvider = {}
|
||||
const registry = new TelemetryRegistry()
|
||||
registry.registerProvider(a)
|
||||
registry.registerProvider(b)
|
||||
|
||||
const metadata = {
|
||||
user_id: 'user-1',
|
||||
tier: 'pro' as const,
|
||||
cycle: 'monthly' as const,
|
||||
checkout_type: 'new' as const,
|
||||
payment_intent_source: 'subscribe_to_run' as const
|
||||
}
|
||||
registry.trackBeginCheckout(metadata)
|
||||
|
||||
expect(a.trackBeginCheckout).toHaveBeenCalledExactlyOnceWith(metadata)
|
||||
})
|
||||
|
||||
it('dispatches trackAddApiCreditButtonClicked with its source', () => {
|
||||
const provider: TelemetryProvider = {
|
||||
trackAddApiCreditButtonClicked: vi.fn()
|
||||
}
|
||||
const registry = new TelemetryRegistry()
|
||||
registry.registerProvider(provider)
|
||||
|
||||
registry.trackAddApiCreditButtonClicked({ source: 'credits_panel' })
|
||||
|
||||
expect(
|
||||
provider.trackAddApiCreditButtonClicked
|
||||
).toHaveBeenCalledExactlyOnceWith({ source: 'credits_panel' })
|
||||
})
|
||||
|
||||
it('skips providers that do not implement trackSearchQuery', () => {
|
||||
const empty: TelemetryProvider = {}
|
||||
const registry = new TelemetryRegistry()
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { AuditLog } from '@/services/customerEventsService'
|
||||
|
||||
import type {
|
||||
AddCreditsClickMetadata,
|
||||
AuthMetadata,
|
||||
BeginCheckoutMetadata,
|
||||
DefaultViewSetMetadata,
|
||||
@@ -100,10 +99,8 @@ export class TelemetryRegistry implements TelemetryDispatcher {
|
||||
this.dispatch((provider) => provider.trackMonthlySubscriptionCancelled?.())
|
||||
}
|
||||
|
||||
trackAddApiCreditButtonClicked(metadata?: AddCreditsClickMetadata): void {
|
||||
this.dispatch((provider) =>
|
||||
provider.trackAddApiCreditButtonClicked?.(metadata)
|
||||
)
|
||||
trackAddApiCreditButtonClicked(): void {
|
||||
this.dispatch((provider) => provider.trackAddApiCreditButtonClicked?.())
|
||||
}
|
||||
|
||||
trackApiCreditTopupButtonPurchaseClicked(amount: number): void {
|
||||
|
||||
@@ -313,42 +313,6 @@ describe('PostHogTelemetryProvider', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('captures begin_checkout with intent metadata', async () => {
|
||||
const provider = createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
provider.trackBeginCheckout({
|
||||
user_id: 'user-1',
|
||||
tier: 'pro',
|
||||
cycle: 'monthly',
|
||||
checkout_type: 'new',
|
||||
payment_intent_source: 'subscribe_to_run'
|
||||
})
|
||||
|
||||
expect(hoisted.mockCapture).toHaveBeenCalledWith(
|
||||
TelemetryEvents.BEGIN_CHECKOUT,
|
||||
{
|
||||
user_id: 'user-1',
|
||||
tier: 'pro',
|
||||
cycle: 'monthly',
|
||||
checkout_type: 'new',
|
||||
payment_intent_source: 'subscribe_to_run'
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('captures add-credit clicks with their source', async () => {
|
||||
const provider = createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
provider.trackAddApiCreditButtonClicked({ source: 'credits_panel' })
|
||||
|
||||
expect(hoisted.mockCapture).toHaveBeenCalledWith(
|
||||
TelemetryEvents.ADD_API_CREDIT_BUTTON_CLICKED,
|
||||
{ source: 'credits_panel' }
|
||||
)
|
||||
})
|
||||
|
||||
it('captures share attribution events', async () => {
|
||||
const provider = createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
@@ -10,9 +10,7 @@ import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
|
||||
import type { RemoteConfig } from '@/platform/remoteConfig/types'
|
||||
|
||||
import type {
|
||||
AddCreditsClickMetadata,
|
||||
AuthMetadata,
|
||||
BeginCheckoutMetadata,
|
||||
DefaultViewSetMetadata,
|
||||
EnterLinearMetadata,
|
||||
ShareFlowMetadata,
|
||||
@@ -352,12 +350,8 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
|
||||
this.trackEvent(eventName, metadata)
|
||||
}
|
||||
|
||||
trackAddApiCreditButtonClicked(metadata?: AddCreditsClickMetadata): void {
|
||||
this.trackEvent(TelemetryEvents.ADD_API_CREDIT_BUTTON_CLICKED, metadata)
|
||||
}
|
||||
|
||||
trackBeginCheckout(metadata: BeginCheckoutMetadata): void {
|
||||
this.trackEvent(TelemetryEvents.BEGIN_CHECKOUT, metadata)
|
||||
trackAddApiCreditButtonClicked(): void {
|
||||
this.trackEvent(TelemetryEvents.ADD_API_CREDIT_BUTTON_CLICKED)
|
||||
}
|
||||
|
||||
trackMonthlySubscriptionSucceeded(
|
||||
|
||||
@@ -115,17 +115,6 @@ describe('HostTelemetrySink', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('forwards add-credit clicks with their source', () => {
|
||||
new HostTelemetrySink().trackAddApiCreditButtonClicked({
|
||||
source: 'avatar_menu'
|
||||
})
|
||||
|
||||
expect(state.capture).toHaveBeenCalledExactlyOnceWith(
|
||||
TelemetryEvents.ADD_API_CREDIT_BUTTON_CLICKED,
|
||||
{ source: 'avatar_menu' }
|
||||
)
|
||||
})
|
||||
|
||||
it('does nothing when the host bridge is absent', () => {
|
||||
delete window.__comfyDesktop2
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
import type { AuditLog } from '@/services/customerEventsService'
|
||||
|
||||
import type {
|
||||
AddCreditsClickMetadata,
|
||||
AuthMetadata,
|
||||
BeginCheckoutMetadata,
|
||||
DefaultViewSetMetadata,
|
||||
@@ -127,8 +126,8 @@ export class HostTelemetrySink implements TelemetryProvider {
|
||||
this.capture(TelemetryEvents.MONTHLY_SUBSCRIPTION_CANCELLED)
|
||||
}
|
||||
|
||||
trackAddApiCreditButtonClicked(metadata?: AddCreditsClickMetadata): void {
|
||||
this.capture(TelemetryEvents.ADD_API_CREDIT_BUTTON_CLICKED, metadata)
|
||||
trackAddApiCreditButtonClicked(): void {
|
||||
this.capture(TelemetryEvents.ADD_API_CREDIT_BUTTON_CLICKED)
|
||||
}
|
||||
|
||||
trackApiCreditTopupButtonPurchaseClicked(amount: number): void {
|
||||
|
||||
@@ -12,29 +12,12 @@
|
||||
* 3. Check dist/assets/*.js files contain no tracking code
|
||||
*/
|
||||
|
||||
import type { SubscriptionDialogReason } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
|
||||
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import type { BillingCycle } from '@/platform/cloud/subscription/utils/subscriptionTierRank'
|
||||
import type { AuditLog } from '@/services/customerEventsService'
|
||||
import type { AppMode } from '@/utils/appMode'
|
||||
|
||||
export type PaymentIntentSource =
|
||||
| 'subscription_required'
|
||||
| 'out_of_credits'
|
||||
| 'top_up_blocked'
|
||||
| 'deep_link'
|
||||
| 'subscribe_to_run'
|
||||
| 'subscribe_now_button'
|
||||
| 'upgrade_to_add_credits'
|
||||
| 'settings_billing_panel'
|
||||
| 'avatar_menu_plans'
|
||||
| 'team_members_panel'
|
||||
| 'invite_member_upsell'
|
||||
| 'upload_model_upgrade'
|
||||
| 'team_upgrade_resume'
|
||||
|
||||
export type SubscriptionCheckoutType = 'new' | 'change'
|
||||
export type SubscriptionCheckoutTier = TierKey | 'team'
|
||||
|
||||
/**
|
||||
* Authentication metadata for sign-up tracking
|
||||
*/
|
||||
@@ -443,23 +426,16 @@ export interface CheckoutAttributionMetadata {
|
||||
|
||||
export interface SubscriptionMetadata {
|
||||
current_tier?: string
|
||||
reason?: PaymentIntentSource
|
||||
}
|
||||
|
||||
export interface AddCreditsClickMetadata {
|
||||
source: 'credits_panel' | 'avatar_menu' | 'settings_billing_panel'
|
||||
reason?: SubscriptionDialogReason
|
||||
}
|
||||
|
||||
export interface BeginCheckoutMetadata
|
||||
extends Record<string, unknown>, CheckoutAttributionMetadata {
|
||||
user_id: string
|
||||
tier: SubscriptionCheckoutTier
|
||||
tier: TierKey
|
||||
cycle: BillingCycle
|
||||
checkout_type: SubscriptionCheckoutType
|
||||
checkout_attempt_id?: string
|
||||
billing_op_id?: string
|
||||
checkout_type: 'new' | 'change'
|
||||
previous_tier?: TierKey
|
||||
payment_intent_source?: PaymentIntentSource
|
||||
}
|
||||
|
||||
interface EcommerceItemMetadata {
|
||||
@@ -481,9 +457,8 @@ export interface SubscriptionSuccessMetadata extends Record<string, unknown> {
|
||||
checkout_attempt_id: string
|
||||
tier: TierKey
|
||||
cycle: BillingCycle
|
||||
checkout_type: SubscriptionCheckoutType
|
||||
checkout_type: 'new' | 'change'
|
||||
previous_tier?: TierKey
|
||||
payment_intent_source?: PaymentIntentSource
|
||||
value: number
|
||||
currency: string
|
||||
ecommerce: EcommerceMetadata
|
||||
@@ -514,7 +489,7 @@ export interface TelemetryProvider {
|
||||
metadata?: SubscriptionSuccessMetadata
|
||||
): void
|
||||
trackMonthlySubscriptionCancelled?(): void
|
||||
trackAddApiCreditButtonClicked?(metadata?: AddCreditsClickMetadata): void
|
||||
trackAddApiCreditButtonClicked?(): void
|
||||
trackApiCreditTopupButtonPurchaseClicked?(amount: number): void
|
||||
trackApiCreditTopupSucceeded?(): void
|
||||
trackWorkspaceInviteSent?(metadata: WorkspaceInviteMetadata): void
|
||||
|
||||
@@ -321,7 +321,7 @@ const handleOpenWorkspaceSettings = () => {
|
||||
}
|
||||
|
||||
const handleOpenPlansAndPricing = () => {
|
||||
subscriptionDialog.showPricingTable({ reason: 'avatar_menu_plans' })
|
||||
subscriptionDialog.showPricingTable()
|
||||
emit('close')
|
||||
}
|
||||
|
||||
@@ -336,12 +336,13 @@ const handleOpenPlanAndCreditsSettings = () => {
|
||||
}
|
||||
|
||||
const handleUpgradeToAddCredits = () => {
|
||||
subscriptionDialog.showPricingTable({ reason: 'upgrade_to_add_credits' })
|
||||
subscriptionDialog.showPricingTable()
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const handleTopUp = () => {
|
||||
useTelemetry()?.trackAddApiCreditButtonClicked({ source: 'avatar_menu' })
|
||||
// Track purchase credits entry from avatar popover
|
||||
useTelemetry()?.trackAddApiCreditButtonClicked()
|
||||
dialogService.showTopUpCreditsDialog()
|
||||
emit('close')
|
||||
}
|
||||
|
||||
@@ -391,13 +391,12 @@ const showZeroState = computed(
|
||||
)
|
||||
|
||||
function handleSubscribeWorkspace() {
|
||||
showSubscriptionDialog({ reason: 'settings_billing_panel' })
|
||||
showSubscriptionDialog()
|
||||
}
|
||||
|
||||
function handleUpgrade() {
|
||||
if (isFreeTierPlan.value)
|
||||
showPricingTable({ reason: 'settings_billing_panel' })
|
||||
else showSubscriptionDialog({ reason: 'settings_billing_panel' })
|
||||
if (isFreeTierPlan.value) showPricingTable()
|
||||
else showSubscriptionDialog()
|
||||
}
|
||||
|
||||
function handleViewMoreDetails() {
|
||||
|
||||
@@ -113,7 +113,7 @@ import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import type { PaymentIntentSource } from '@/platform/telemetry/types'
|
||||
import type { SubscriptionDialogReason } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
|
||||
import { useSubscriptionCheckout } from '@/platform/workspace/composables/useSubscriptionCheckout'
|
||||
|
||||
import SubscriptionAddPaymentPreviewWorkspace from './SubscriptionAddPaymentPreviewWorkspace.vue'
|
||||
@@ -123,7 +123,7 @@ import UnifiedPricingTable from './UnifiedPricingTable.vue'
|
||||
|
||||
const { onClose, reason, initialPlanMode } = defineProps<{
|
||||
onClose: () => void
|
||||
reason?: PaymentIntentSource
|
||||
reason?: SubscriptionDialogReason
|
||||
initialPlanMode?: 'personal' | 'team'
|
||||
}>()
|
||||
|
||||
@@ -152,7 +152,7 @@ const {
|
||||
handleConfirmTransition,
|
||||
handleTeamSubscribe,
|
||||
handleResubscribe
|
||||
} = useSubscriptionCheckout(emit, reason)
|
||||
} = useSubscriptionCheckout(emit)
|
||||
|
||||
// Backspace mirrors the back arrow on the confirm step, but never while an
|
||||
// editable element is focused (let it delete text there).
|
||||
|
||||
@@ -5,7 +5,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import type { PaymentIntentSource } from '@/platform/telemetry/types'
|
||||
import type { SubscriptionDialogReason } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
|
||||
|
||||
import SubscriptionRequiredDialogContentWorkspace from './SubscriptionRequiredDialogContentWorkspace.vue'
|
||||
|
||||
@@ -17,10 +17,25 @@ const mockHandleResubscribe = vi.fn()
|
||||
const mockHandleSuccessClose = vi.fn()
|
||||
const mockCheckoutStep = ref<'pricing' | 'preview' | 'success'>('pricing')
|
||||
const mockPreviewData = ref<{ transition_type: string } | null>(null)
|
||||
const mockUseSubscriptionCheckout = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@/platform/workspace/composables/useSubscriptionCheckout', () => ({
|
||||
useSubscriptionCheckout: mockUseSubscriptionCheckout
|
||||
useSubscriptionCheckout: () => ({
|
||||
checkoutStep: mockCheckoutStep,
|
||||
isLoadingPreview: ref(false),
|
||||
loadingTier: ref(null),
|
||||
isSubscribing: ref(false),
|
||||
isResubscribing: ref(false),
|
||||
previewData: mockPreviewData,
|
||||
selectedTierKey: ref('standard'),
|
||||
selectedBillingCycle: ref('yearly'),
|
||||
isPolling: ref(false),
|
||||
handleSubscribeClick: mockHandleSubscribeClick,
|
||||
handleBackToPricing: mockHandleBackToPricing,
|
||||
handleAddCreditCard: mockHandleAddCreditCard,
|
||||
handleConfirmTransition: mockHandleConfirmTransition,
|
||||
handleResubscribe: mockHandleResubscribe,
|
||||
handleSuccessClose: mockHandleSuccessClose
|
||||
})
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
@@ -76,7 +91,7 @@ const SuccessStub = {
|
||||
function renderComponent(
|
||||
props: {
|
||||
onClose?: () => void
|
||||
reason?: PaymentIntentSource
|
||||
reason?: SubscriptionDialogReason
|
||||
isPersonal?: boolean
|
||||
} = {}
|
||||
) {
|
||||
@@ -106,23 +121,6 @@ function renderComponent(
|
||||
describe('SubscriptionRequiredDialogContentWorkspace', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseSubscriptionCheckout.mockReturnValue({
|
||||
checkoutStep: mockCheckoutStep,
|
||||
isLoadingPreview: ref(false),
|
||||
loadingTier: ref(null),
|
||||
isSubscribing: ref(false),
|
||||
isResubscribing: ref(false),
|
||||
previewData: mockPreviewData,
|
||||
selectedTierKey: ref('standard'),
|
||||
selectedBillingCycle: ref('yearly'),
|
||||
isPolling: ref(false),
|
||||
handleSubscribeClick: mockHandleSubscribeClick,
|
||||
handleBackToPricing: mockHandleBackToPricing,
|
||||
handleAddCreditCard: mockHandleAddCreditCard,
|
||||
handleConfirmTransition: mockHandleConfirmTransition,
|
||||
handleResubscribe: mockHandleResubscribe,
|
||||
handleSuccessClose: mockHandleSuccessClose
|
||||
})
|
||||
mockCheckoutStep.value = 'pricing'
|
||||
mockPreviewData.value = null
|
||||
})
|
||||
@@ -134,15 +132,6 @@ describe('SubscriptionRequiredDialogContentWorkspace', () => {
|
||||
expect(screen.queryByTestId('transition-preview')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('passes the reason into subscription checkout', () => {
|
||||
renderComponent({ reason: 'out_of_credits' })
|
||||
|
||||
expect(mockUseSubscriptionCheckout).toHaveBeenCalledWith(
|
||||
expect.any(Function),
|
||||
'out_of_credits'
|
||||
)
|
||||
})
|
||||
|
||||
it('shows the team workspace header by default', () => {
|
||||
renderComponent()
|
||||
expect(screen.getByText('Team Workspace')).toBeInTheDocument()
|
||||
|
||||
@@ -116,7 +116,7 @@
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import type { PaymentIntentSource } from '@/platform/telemetry/types'
|
||||
import type { SubscriptionDialogReason } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
|
||||
import { useSubscriptionCheckout } from '@/platform/workspace/composables/useSubscriptionCheckout'
|
||||
|
||||
import PricingTableWorkspace from './PricingTableWorkspace.vue'
|
||||
@@ -130,7 +130,7 @@ const {
|
||||
isPersonal = false
|
||||
} = defineProps<{
|
||||
onClose: () => void
|
||||
reason?: PaymentIntentSource
|
||||
reason?: SubscriptionDialogReason
|
||||
isPersonal?: boolean
|
||||
}>()
|
||||
|
||||
@@ -154,7 +154,7 @@ const {
|
||||
handleConfirmTransition,
|
||||
handleResubscribe,
|
||||
handleSuccessClose
|
||||
} = useSubscriptionCheckout(emit, reason)
|
||||
} = useSubscriptionCheckout(emit)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -61,9 +61,6 @@ function onDismiss() {
|
||||
|
||||
function onUpgrade() {
|
||||
dialogStore.closeDialog({ key: 'invite-member-upsell' })
|
||||
subscriptionDialog.show({
|
||||
planMode: 'team',
|
||||
reason: 'invite_member_upsell'
|
||||
})
|
||||
subscriptionDialog.show({ planMode: 'team' })
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -277,7 +277,7 @@ export function useMembersPanel() {
|
||||
}
|
||||
|
||||
function showTeamPlans() {
|
||||
subscriptionDialog.show({ planMode: 'team', reason: 'team_members_panel' })
|
||||
subscriptionDialog.show({ planMode: 'team' })
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { computed, reactive } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { PaymentIntentSource } from '@/platform/telemetry/types'
|
||||
import type { Plan } from '@/platform/workspace/api/workspaceApi'
|
||||
|
||||
import { findPlanSlug } from './useSubscriptionCheckout'
|
||||
@@ -76,9 +75,7 @@ const {
|
||||
mockPlans,
|
||||
mockResubscribe,
|
||||
mockToastAdd,
|
||||
mockStartOperation,
|
||||
mockTrackBeginCheckout,
|
||||
mockUserId
|
||||
mockStartOperation
|
||||
} = vi.hoisted(() => ({
|
||||
mockSubscribe: vi.fn(),
|
||||
mockPreviewSubscribe: vi.fn(),
|
||||
@@ -87,9 +84,7 @@ const {
|
||||
mockPlans: { value: [] as Plan[] },
|
||||
mockResubscribe: vi.fn(),
|
||||
mockToastAdd: vi.fn(),
|
||||
mockStartOperation: vi.fn(),
|
||||
mockTrackBeginCheckout: vi.fn(),
|
||||
mockUserId: { value: 'user-1' as string | null }
|
||||
mockStartOperation: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/billing/useBillingContext', () => ({
|
||||
@@ -124,14 +119,7 @@ vi.mock('primevue/usetoast', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: () => ({
|
||||
trackMonthlySubscriptionSucceeded: vi.fn(),
|
||||
trackBeginCheckout: mockTrackBeginCheckout
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/authStore', () => ({
|
||||
useAuthStore: () => reactive({ userId: computed(() => mockUserId.value) })
|
||||
useTelemetry: () => ({ trackMonthlySubscriptionSucceeded: vi.fn() })
|
||||
}))
|
||||
|
||||
vi.mock('vue-i18n', async (importOriginal) => {
|
||||
@@ -147,10 +135,10 @@ vi.mock('vue-i18n', async (importOriginal) => {
|
||||
describe('useSubscriptionCheckout', () => {
|
||||
let emit: ReturnType<typeof vi.fn>
|
||||
|
||||
async function setup(paymentIntentSource?: PaymentIntentSource) {
|
||||
async function setup() {
|
||||
const { useSubscriptionCheckout } =
|
||||
await import('./useSubscriptionCheckout')
|
||||
return useSubscriptionCheckout(emit as never, paymentIntentSource)
|
||||
return useSubscriptionCheckout(emit as never)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -158,7 +146,6 @@ describe('useSubscriptionCheckout', () => {
|
||||
vi.clearAllMocks()
|
||||
mockPlans.value = allPlans()
|
||||
mockStartOperation.mockResolvedValue({ status: 'succeeded' })
|
||||
mockUserId.value = 'user-1'
|
||||
emit = vi.fn()
|
||||
})
|
||||
|
||||
@@ -472,13 +459,6 @@ describe('useSubscriptionCheckout', () => {
|
||||
cancelUrl: 'https://platform.comfy.org/payment/failed'
|
||||
})
|
||||
expect(checkout.checkoutStep.value).toBe('success')
|
||||
expect(mockTrackBeginCheckout).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
tier: 'team',
|
||||
checkout_type: 'new',
|
||||
billing_op_id: 'op-team-1'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('uses the annual plan slug for the yearly cycle', async () => {
|
||||
@@ -573,39 +553,6 @@ describe('useSubscriptionCheckout', () => {
|
||||
detail: 'Team payment failed'
|
||||
})
|
||||
)
|
||||
expect(mockTrackBeginCheckout).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps team checkout_type as change when the preview request fails', async () => {
|
||||
const checkout = await setup()
|
||||
mockPreviewSubscribe.mockRejectedValueOnce(new Error('not supported'))
|
||||
await checkout.handleSubscribeTeamClick({
|
||||
stop: {
|
||||
id: 'team_1400',
|
||||
usd: 1400,
|
||||
credits: 295_400,
|
||||
discountedUsd: 1295
|
||||
},
|
||||
billingCycle: 'monthly',
|
||||
isChange: true
|
||||
})
|
||||
mockSubscribe.mockResolvedValueOnce({
|
||||
status: 'subscribed',
|
||||
billing_op_id: 'op-team-change'
|
||||
})
|
||||
mockFetchStatus.mockResolvedValueOnce(undefined)
|
||||
mockFetchBalance.mockResolvedValueOnce(undefined)
|
||||
|
||||
await checkout.handleTeamSubscribe()
|
||||
|
||||
expect(mockTrackBeginCheckout).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
tier: 'team',
|
||||
cycle: 'monthly',
|
||||
checkout_type: 'change',
|
||||
billing_op_id: 'op-team-change'
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -656,47 +603,6 @@ describe('useSubscriptionCheckout', () => {
|
||||
expect(checkout.checkoutStep.value).toBe('success')
|
||||
})
|
||||
|
||||
it('skips begin_checkout when no user id is available', async () => {
|
||||
mockUserId.value = null
|
||||
const checkout = await setup('subscribe_to_run')
|
||||
checkout.selectedTierKey.value = 'standard'
|
||||
checkout.selectedBillingCycle.value = 'yearly'
|
||||
mockSubscribe.mockResolvedValueOnce({
|
||||
status: 'subscribed',
|
||||
billing_op_id: 'op-1'
|
||||
})
|
||||
mockFetchStatus.mockResolvedValueOnce(undefined)
|
||||
mockFetchBalance.mockResolvedValueOnce(undefined)
|
||||
|
||||
await checkout.handleAddCreditCard()
|
||||
|
||||
expect(mockTrackBeginCheckout).not.toHaveBeenCalled()
|
||||
mockUserId.value = 'user-1'
|
||||
})
|
||||
|
||||
it('fires begin_checkout carrying the payment intent source', async () => {
|
||||
const checkout = await setup('subscribe_to_run')
|
||||
checkout.selectedTierKey.value = 'standard'
|
||||
checkout.selectedBillingCycle.value = 'yearly'
|
||||
mockSubscribe.mockResolvedValueOnce({
|
||||
status: 'subscribed',
|
||||
billing_op_id: 'op-1'
|
||||
})
|
||||
mockFetchStatus.mockResolvedValueOnce(undefined)
|
||||
mockFetchBalance.mockResolvedValueOnce(undefined)
|
||||
|
||||
await checkout.handleAddCreditCard()
|
||||
|
||||
expect(mockTrackBeginCheckout).toHaveBeenCalledWith({
|
||||
user_id: 'user-1',
|
||||
tier: 'standard',
|
||||
cycle: 'yearly',
|
||||
checkout_type: 'new',
|
||||
billing_op_id: 'op-1',
|
||||
payment_intent_source: 'subscribe_to_run'
|
||||
})
|
||||
})
|
||||
|
||||
it('opens payment URL when needs_payment_method', async () => {
|
||||
const checkout = await setup()
|
||||
checkout.selectedTierKey.value = 'standard'
|
||||
@@ -814,7 +720,6 @@ describe('useSubscriptionCheckout', () => {
|
||||
detail: 'Payment failed'
|
||||
})
|
||||
)
|
||||
expect(mockTrackBeginCheckout).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -9,26 +9,16 @@ import type { TeamPlanSelection } from '@/platform/cloud/subscription/constants/
|
||||
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import type { BillingCycle } from '@/platform/cloud/subscription/utils/subscriptionTierRank'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import type {
|
||||
PaymentIntentSource,
|
||||
SubscriptionCheckoutType
|
||||
} from '@/platform/telemetry/types'
|
||||
import type {
|
||||
Plan,
|
||||
PreviewSubscribeResponse,
|
||||
SubscribeResponse
|
||||
} from '@/platform/workspace/api/workspaceApi'
|
||||
import { useBillingOperationStore } from '@/platform/workspace/stores/billingOperationStore'
|
||||
import { trackWorkspaceCheckoutStarted } from '@/platform/workspace/utils/workspaceCheckoutTelemetry'
|
||||
|
||||
type CheckoutStep = 'pricing' | 'preview' | 'success'
|
||||
type CheckoutTierKey = Exclude<TierKey, 'free' | 'founder'>
|
||||
|
||||
interface SelectedTeamCheckout {
|
||||
stop: TeamPlanSelection
|
||||
checkoutType: SubscriptionCheckoutType
|
||||
}
|
||||
|
||||
/**
|
||||
* Which screen the `preview` step shows. Only a change prorates: a team change
|
||||
* carries `previewData` (handleSubscribeTeamClick sets it solely for an immediate
|
||||
@@ -55,12 +45,9 @@ export function findPlanSlug(
|
||||
return plan?.slug ?? null
|
||||
}
|
||||
|
||||
export function useSubscriptionCheckout(
|
||||
emit: {
|
||||
(e: 'close', subscribed: boolean): void
|
||||
},
|
||||
paymentIntentSource?: PaymentIntentSource
|
||||
) {
|
||||
export function useSubscriptionCheckout(emit: {
|
||||
(e: 'close', subscribed: boolean): void
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
const toast = useToast()
|
||||
const {
|
||||
@@ -81,16 +68,13 @@ export function useSubscriptionCheckout(
|
||||
const isResubscribing = ref(false)
|
||||
const previewData = ref<PreviewSubscribeResponse | null>(null)
|
||||
const selectedTierKey = ref<CheckoutTierKey | null>(null)
|
||||
const selectedTeamCheckout = ref<SelectedTeamCheckout | null>(null)
|
||||
const selectedTeamStop = ref<TeamPlanSelection | null>(null)
|
||||
const selectedBillingCycle = ref<BillingCycle>('yearly')
|
||||
const isPolling = computed(() => billingOperationStore.hasPendingOperations)
|
||||
const selectedTeamStop = computed(
|
||||
() => selectedTeamCheckout.value?.stop ?? null
|
||||
)
|
||||
const isTeamCheckout = computed(() => selectedTeamCheckout.value !== null)
|
||||
const isTeamCheckout = computed(() => selectedTeamStop.value !== null)
|
||||
|
||||
const previewVariant = computed<PreviewVariant>(() => {
|
||||
if (selectedTeamCheckout.value) {
|
||||
if (selectedTeamStop.value) {
|
||||
return previewData.value ? 'team-change' : 'team-new'
|
||||
}
|
||||
if (previewData.value) {
|
||||
@@ -170,10 +154,7 @@ export function useSubscriptionCheckout(
|
||||
billingCycle: BillingCycle
|
||||
isChange?: boolean
|
||||
}) {
|
||||
selectedTeamCheckout.value = {
|
||||
stop: payload.stop,
|
||||
checkoutType: payload.isChange ? 'change' : 'new'
|
||||
}
|
||||
selectedTeamStop.value = payload.stop
|
||||
selectedBillingCycle.value = payload.billingCycle
|
||||
selectedTierKey.value = null
|
||||
previewData.value = null
|
||||
@@ -201,7 +182,7 @@ export function useSubscriptionCheckout(
|
||||
function handleBackToPricing() {
|
||||
checkoutStep.value = 'pricing'
|
||||
previewData.value = null
|
||||
selectedTeamCheckout.value = null
|
||||
selectedTeamStop.value = null
|
||||
}
|
||||
|
||||
function handleSuccessClose() {
|
||||
@@ -209,34 +190,20 @@ export function useSubscriptionCheckout(
|
||||
}
|
||||
|
||||
async function handleSubscription() {
|
||||
const tierKey = selectedTierKey.value
|
||||
if (!tierKey) return
|
||||
|
||||
const billingCycle = selectedBillingCycle.value
|
||||
const checkoutType =
|
||||
previewData.value &&
|
||||
previewData.value.transition_type !== 'new_subscription'
|
||||
? 'change'
|
||||
: 'new'
|
||||
if (!selectedTierKey.value) return
|
||||
|
||||
isSubscribing.value = true
|
||||
try {
|
||||
const planSlug = getApiPlanSlug(tierKey, billingCycle)
|
||||
const planSlug = getApiPlanSlug(
|
||||
selectedTierKey.value,
|
||||
selectedBillingCycle.value
|
||||
)
|
||||
if (!planSlug) return
|
||||
const response = await subscribe(planSlug, {
|
||||
returnUrl: `${getComfyPlatformBaseUrl()}/payment/success`,
|
||||
cancelUrl: `${getComfyPlatformBaseUrl()}/payment/failed`
|
||||
})
|
||||
|
||||
if (response) {
|
||||
trackWorkspaceCheckoutStarted({
|
||||
tier: tierKey,
|
||||
cycle: billingCycle,
|
||||
checkoutType,
|
||||
billingOpId: response.billing_op_id,
|
||||
paymentIntentSource
|
||||
})
|
||||
}
|
||||
await handleSubscribeResponse(response)
|
||||
} catch (error) {
|
||||
showSubscribeError(error)
|
||||
@@ -302,8 +269,8 @@ export function useSubscriptionCheckout(
|
||||
}
|
||||
|
||||
async function handleTeamSubscription() {
|
||||
const teamCheckout = selectedTeamCheckout.value
|
||||
if (!teamCheckout?.stop.id) {
|
||||
const stop = selectedTeamStop.value
|
||||
if (!stop?.id) {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('subscription.teamPlan.name'),
|
||||
@@ -312,28 +279,16 @@ export function useSubscriptionCheckout(
|
||||
return
|
||||
}
|
||||
|
||||
const { stop, checkoutType } = teamCheckout
|
||||
const billingCycle = selectedBillingCycle.value
|
||||
|
||||
isSubscribing.value = true
|
||||
try {
|
||||
const planSlug = getTeamPlanSlug(billingCycle)
|
||||
const planSlug = getTeamPlanSlug(selectedBillingCycle.value)
|
||||
const response = await subscribe(planSlug, {
|
||||
teamCreditStopId: stop.id,
|
||||
billingCycle,
|
||||
billingCycle: selectedBillingCycle.value,
|
||||
returnUrl: `${getComfyPlatformBaseUrl()}/payment/success`,
|
||||
cancelUrl: `${getComfyPlatformBaseUrl()}/payment/failed`
|
||||
})
|
||||
|
||||
if (response) {
|
||||
trackWorkspaceCheckoutStarted({
|
||||
tier: 'team',
|
||||
cycle: billingCycle,
|
||||
checkoutType,
|
||||
billingOpId: response.billing_op_id,
|
||||
paymentIntentSource
|
||||
})
|
||||
}
|
||||
await handleSubscribeResponse(response)
|
||||
} catch (error) {
|
||||
showSubscribeError(error)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user