Compare commits
18 Commits
codex/crit
...
codex/fix-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e919feb8c4 | ||
|
|
010389903d | ||
|
|
684b0b08b0 | ||
|
|
95b121bed9 | ||
|
|
baeb6df662 | ||
|
|
e58b231664 | ||
|
|
545b48ee5b | ||
|
|
22ea53fb56 | ||
|
|
9fe5dd51b8 | ||
|
|
747f76db76 | ||
|
|
386460afef | ||
|
|
5cf647d183 | ||
|
|
fe1fc8baa6 | ||
|
|
3e4dd59e5f | ||
|
|
e25e0f2e16 | ||
|
|
2ee91c30ee | ||
|
|
854770d305 | ||
|
|
2e4c9c6fdc |
197
.github/workflows/backport-auto-merge.yaml
vendored
Normal file
@@ -0,0 +1,197 @@
|
||||
---
|
||||
name: Backport Auto-Merge
|
||||
|
||||
# Completes the merge of backport PRs once they are approved and their required
|
||||
# checks pass.
|
||||
#
|
||||
# Background: pr-backport.yaml opens each backport PR (labelled `backport`) and
|
||||
# calls `gh pr merge --auto`, which relies on the repo-level "Allow auto-merge"
|
||||
# setting. That setting is off, so `--auto` is a silent no-op and backport PRs
|
||||
# sit unmerged until a human clicks merge. This workflow performs the merge
|
||||
# directly (a plain `gh pr merge --squash`, which does not depend on that
|
||||
# setting) once GitHub itself reports the PR as ready to merge.
|
||||
#
|
||||
# Safety: branch protection on core/** and cloud/** is the hard gate — it
|
||||
# unconditionally requires an approval + the required status checks and cannot
|
||||
# be bypassed, and GitHub's merge API re-enforces it at merge time. This
|
||||
# workflow can only ever complete a merge that already satisfies those rules;
|
||||
# the eligibility check below only avoids pointless merge attempts.
|
||||
#
|
||||
# The merge uses PR_GH_TOKEN (not the default GITHUB_TOKEN) on purpose: a merge
|
||||
# performed by the default token does not emit events that trigger other
|
||||
# workflows, which would silently starve cloud-backport-tag.yaml (it runs on the
|
||||
# backport PR's `pull_request: closed` event to create the release tag).
|
||||
|
||||
on:
|
||||
# Fires when someone approves — if the required checks are already green, the
|
||||
# PR merges immediately.
|
||||
pull_request_review:
|
||||
types: [submitted]
|
||||
# Primary catch for the "approved first, checks went green later" case, plus a
|
||||
# general backstop. A `check_suite`/`workflow_run` trigger would react faster to
|
||||
# checks completing, but GitHub suppresses `check_suite` events for its own
|
||||
# Actions suites (so it wouldn't fire for this repo's CI), and `workflow_run` is
|
||||
# a secrets-bearing "dangerous" trigger we don't want on a public repo for a
|
||||
# non-latency-critical task. Backports wait hours today, so a short sweep is a
|
||||
# large improvement and needs neither.
|
||||
schedule:
|
||||
- cron: '*/15 * * * *'
|
||||
|
||||
# Only constrains the default github.token (used for read-only PR lookups below).
|
||||
# It does NOT constrain PR_GH_TOKEN, whose authority is fixed by its own scopes.
|
||||
permissions:
|
||||
contents: read # read-only; required for gh api / gh pr list to resolve candidates
|
||||
pull-requests: read # read-only; required for gh pr view eligibility checks
|
||||
|
||||
# Serialize runs that act on the same PR (review events keyed by PR number; all
|
||||
# scheduled sweeps share one key). Cross-key overlaps are still possible but
|
||||
# harmless: the merge loop treats an already-merged PR as success (idempotent).
|
||||
concurrency:
|
||||
group: backport-auto-merge-${{ github.event.pull_request.number || 'sweep' }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
merge:
|
||||
name: Merge eligible backport PRs
|
||||
# Skip review events that can't possibly make a PR mergeable — non-approval
|
||||
# reviews, or reviews on non-backport PRs (most reviews in the repo) — before
|
||||
# spending any API call. Schedule sweeps always proceed. The per-PR
|
||||
# eligibility checks in the job still re-verify the label and decision from
|
||||
# live state.
|
||||
if: github.event_name != 'pull_request_review' || (github.event.review.state == 'approved' && contains(github.event.pull_request.labels.*.name, 'backport'))
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read # read-only PR/commit lookups via the default token
|
||||
pull-requests: read # read-only PR metadata via the default token
|
||||
steps:
|
||||
- name: Collect candidate backport PRs
|
||||
id: candidates
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
PR_FROM_REVIEW: ${{ github.event.pull_request.number }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
numbers=""
|
||||
case "$EVENT_NAME" in
|
||||
pull_request_review)
|
||||
numbers="$PR_FROM_REVIEW"
|
||||
;;
|
||||
schedule)
|
||||
# Sweep every open backport PR.
|
||||
numbers=$(gh pr list --repo "$GH_REPO" --state open --label backport \
|
||||
--limit 100 --json number --jq '.[].number')
|
||||
;;
|
||||
esac
|
||||
# De-duplicate and emit space-separated, digit-only tokens.
|
||||
numbers=$(echo "$numbers" | tr ' ' '\n' | grep -E '^[0-9]+$' | sort -u | tr '\n' ' ' || true)
|
||||
echo "numbers=${numbers}" >> "$GITHUB_OUTPUT"
|
||||
echo "Candidate PRs: '${numbers:-<none>}'"
|
||||
|
||||
- name: Merge eligible backport PRs
|
||||
if: steps.candidates.outputs.numbers != ''
|
||||
env:
|
||||
GH_REPO: ${{ github.repository }}
|
||||
# Read with the default token; merge with PR_GH_TOKEN so the merge emits
|
||||
# the events that downstream workflows (cloud-backport-tag.yaml) rely on.
|
||||
READ_TOKEN: ${{ github.token }}
|
||||
MERGE_TOKEN: ${{ secrets.PR_GH_TOKEN }}
|
||||
CANDIDATES: ${{ steps.candidates.outputs.numbers }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
is_merged() {
|
||||
[ "$(GH_TOKEN="$READ_TOKEN" gh pr view "$1" --repo "$GH_REPO" --json merged --jq '.merged' 2>/dev/null || echo false)" = "true" ]
|
||||
}
|
||||
|
||||
for pr in $CANDIDATES; do
|
||||
echo "::group::PR #${pr}"
|
||||
|
||||
info=$(GH_TOKEN="$READ_TOKEN" gh pr view "$pr" --repo "$GH_REPO" \
|
||||
--json number,state,isDraft,labels,baseRefName,reviewDecision,mergeStateStatus 2>/dev/null || echo '')
|
||||
if [ -z "$info" ]; then
|
||||
echo "Could not read PR #${pr} — skipping."; echo "::endgroup::"; continue
|
||||
fi
|
||||
|
||||
state=$(echo "$info" | jq -r '.state')
|
||||
is_draft=$(echo "$info" | jq -r '.isDraft')
|
||||
is_backport=$(echo "$info" | jq -r '[.labels[].name] | any(. == "backport")')
|
||||
base=$(echo "$info" | jq -r '.baseRefName')
|
||||
review=$(echo "$info" | jq -r '.reviewDecision')
|
||||
merge_state=$(echo "$info" | jq -r '.mergeStateStatus')
|
||||
|
||||
# Only ever act on open, non-draft, backport-labelled PRs targeting a
|
||||
# protected release branch.
|
||||
if [ "$state" != "OPEN" ] || [ "$is_draft" != "false" ] || [ "$is_backport" != "true" ]; then
|
||||
echo "Not an actionable backport PR (state=$state draft=$is_draft backport=$is_backport) — skipping."
|
||||
echo "::endgroup::"; continue
|
||||
fi
|
||||
case "$base" in
|
||||
cloud/*|core/*) : ;;
|
||||
*) echo "Base '$base' is not a release branch — skipping."; echo "::endgroup::"; continue ;;
|
||||
esac
|
||||
|
||||
# Ready = approved AND GitHub says it's mergeable with required checks green.
|
||||
# CLEAN = approved, all required checks green, mergeable, no conflict.
|
||||
# UNSTABLE = same, but a NON-required check is pending/failing — GitHub
|
||||
# still allows the merge, so we do too (matches what a human
|
||||
# clicking "Squash and merge" can do; required checks are the
|
||||
# only merge gate per the ruleset). Requiring CLEAN alone would
|
||||
# stick forever behind flaky/slow non-required checks.
|
||||
# Any other state (BLOCKED/DIRTY/BEHIND/UNKNOWN/...) => not ready; re-checked
|
||||
# by a later event or the next sweep.
|
||||
if [ "$review" != "APPROVED" ] || { [ "$merge_state" != "CLEAN" ] && [ "$merge_state" != "UNSTABLE" ]; }; then
|
||||
echo "Not yet ready (reviewDecision=$review mergeStateStatus=$merge_state) — will re-check later."
|
||||
echo "::endgroup::"; continue
|
||||
fi
|
||||
|
||||
echo "PR #${pr} is ready — attempting squash merge."
|
||||
attempt=0
|
||||
max=3
|
||||
merged=false
|
||||
while [ "$attempt" -lt "$max" ]; do
|
||||
attempt=$((attempt + 1))
|
||||
# A concurrent run (or a human) may have merged it already.
|
||||
if is_merged "$pr"; then merged=true; break; fi
|
||||
if out=$(GH_TOKEN="$MERGE_TOKEN" gh pr merge "$pr" --repo "$GH_REPO" --squash 2>&1); then
|
||||
merged=true; break
|
||||
fi
|
||||
echo "Merge attempt ${attempt}/${max} failed: ${out}"
|
||||
# No sleep after the final attempt.
|
||||
[ "$attempt" -lt "$max" ] && sleep $((attempt * 15))
|
||||
done
|
||||
|
||||
# Final reconciliation: a failed merge command may just mean a concurrent
|
||||
# run won the race — don't post a false failure if the PR is in fact merged.
|
||||
if [ "$merged" != "true" ] && is_merged "$pr"; then merged=true; fi
|
||||
|
||||
if [ "$merged" = "true" ]; then
|
||||
echo "PR #${pr} merged."
|
||||
else
|
||||
echo "::warning::PR #${pr} looked ready but did not merge after ${max} attempts."
|
||||
# Avoid spamming a persistently-stuck PR: only re-warn if the last
|
||||
# warning (identified by its marker) is more than an hour old.
|
||||
marker='<!-- backport-auto-merge:merge-failed -->'
|
||||
# `gh api --paginate` emits one JSON array per page; `--jq` would run
|
||||
# per page (missing the true latest across pages), so slurp all pages
|
||||
# into one array first and filter with a separate jq pass.
|
||||
last_warned=$(GH_TOKEN="$READ_TOKEN" gh api "repos/${GH_REPO}/issues/${pr}/comments" --paginate 2>/dev/null \
|
||||
| jq -s "[.[][] | select(.body | contains(\"${marker}\"))] | sort_by(.created_at) | last | .created_at // empty") || last_warned=''
|
||||
stale=true
|
||||
if [ -n "$last_warned" ]; then
|
||||
last_epoch=$(date -d "$last_warned" +%s 2>/dev/null || echo 0)
|
||||
now_epoch=$(date -u +%s)
|
||||
[ $((now_epoch - last_epoch)) -lt 3600 ] && stale=false
|
||||
fi
|
||||
if [ "$stale" = "true" ]; then
|
||||
body=$(printf '%s\n\n%s' \
|
||||
"This backport PR is approved and its required checks are green, but automatic merge failed after ${max} attempts. Please merge manually or investigate (possible branch-protection mismatch)." \
|
||||
"$marker")
|
||||
GH_TOKEN="$MERGE_TOKEN" gh pr comment "$pr" --repo "$GH_REPO" --body "$body" || true
|
||||
else
|
||||
echo "Already warned within the last hour — skipping duplicate comment."
|
||||
fi
|
||||
fi
|
||||
echo "::endgroup::"
|
||||
done
|
||||
42
.github/workflows/cla.yml
vendored
@@ -6,6 +6,7 @@ on:
|
||||
pull_request_target:
|
||||
types: [opened, synchronize, closed]
|
||||
merge_group:
|
||||
types: [checks_requested]
|
||||
|
||||
permissions:
|
||||
actions: write
|
||||
@@ -17,13 +18,45 @@ jobs:
|
||||
cla-assistant:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: CLA already verified before merge queue
|
||||
if: github.event_name == 'merge_group'
|
||||
run: echo "CLA is checked on the pull request before it enters merge queue."
|
||||
|
||||
# The CLA action normally requires every commit author in a PR to sign.
|
||||
# We only want the PR author to sign, so we allowlist all other committers
|
||||
# by computing them from the PR's commits and excluding the PR author.
|
||||
- name: Build author-only allowlist
|
||||
id: allowlist
|
||||
if: >
|
||||
github.event_name == 'pull_request_target' ||
|
||||
(github.event_name == 'issue_comment' && github.event.issue.pull_request && (
|
||||
github.event.comment.body == 'recheck' ||
|
||||
github.event.comment.body == 'I have read and agree to the Contributor License Agreement'
|
||||
))
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }}
|
||||
PR_AUTHOR: ${{ github.event.pull_request.user.login || github.event.issue.user.login }}
|
||||
BASE_ALLOWLIST: action@github.com,actions-user,ampagent,claude,comfy-pr-bot,GitHub Action,github-actions,github-actions[bot],Glary Bot,Glary-Bot,*[bot]
|
||||
run: |
|
||||
others=$(gh api "repos/${{ github.repository }}/pulls/${PR_NUMBER}/commits" --paginate \
|
||||
--jq '.[] | (.author.login // empty), (.committer.login // empty)' \
|
||||
| sort -u | grep -vix "${PR_AUTHOR}" | paste -sd, -)
|
||||
if [ -n "$others" ]; then
|
||||
echo "allowlist=${BASE_ALLOWLIST},${others}" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "allowlist=${BASE_ALLOWLIST}" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: CLA Assistant
|
||||
# Run on PR events, on "recheck" comment, or when someone posts the exact signing phrase.
|
||||
# IMPORTANT: this phrase must match `custom-pr-sign-comment` below.
|
||||
if: >
|
||||
github.event_name == 'pull_request_target' ||
|
||||
github.event.comment.body == 'recheck' ||
|
||||
github.event.comment.body == 'I have read and agree to the Contributor License Agreement'
|
||||
(github.event_name == 'issue_comment' && github.event.issue.pull_request && (
|
||||
github.event.comment.body == 'recheck' ||
|
||||
github.event.comment.body == 'I have read and agree to the Contributor License Agreement'
|
||||
))
|
||||
uses: contributor-assistant/github-action@ca4a40a7d1004f18d9960b404b97e5f30a505a08 # v2.6.1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -39,9 +72,10 @@ jobs:
|
||||
path-to-signatures: signatures/cla.json
|
||||
branch: main
|
||||
|
||||
# Allowlist bots so they don't need to sign (optional, comma-separated).
|
||||
# Only the PR author must sign: bots plus every non-author committer
|
||||
# are allowlisted via the "Build author-only allowlist" step above.
|
||||
# *[bot] is a catch-all for any GitHub App bot account.
|
||||
allowlist: action@github.com,actions-user,ampagent,claude,comfy-pr-bot,GitHub Action,github-actions,Glary Bot,Glary-Bot,*[bot]
|
||||
allowlist: ${{ steps.allowlist.outputs.allowlist }}
|
||||
|
||||
# Custom PR comment messages
|
||||
custom-notsigned-prcomment: |
|
||||
|
||||
4
.github/workflows/pr-cursor-review.yaml
vendored
@@ -29,7 +29,7 @@ jobs:
|
||||
# SHA-pinned per zizmor `unpinned-uses: hash-pin`. Bump this SHA to pick up
|
||||
# upstream changes; keep `workflows_ref` matching so prompts/scripts load
|
||||
# from the same commit as the workflow definition.
|
||||
uses: Comfy-Org/github-workflows/.github/workflows/cursor-review.yml@047ca48febe3a6647608ed2e0c4331b491cb9d6a # github-workflows#9
|
||||
uses: Comfy-Org/github-workflows/.github/workflows/cursor-review.yml@df507e6bae179c567ad3849370f99dae588985dc # github-workflows main (df507e6)
|
||||
with:
|
||||
# Overriding diff_excludes replaces the reusable default wholesale, so
|
||||
# this restates the generated/vendored defaults and adds this repo's heavy
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
:!**/*-snapshots/**
|
||||
:!src/workbench/extensions/manager/types/generatedManagerTypes.ts
|
||||
# Load the prompts/scripts from the same ref as `uses:`.
|
||||
workflows_ref: 047ca48febe3a6647608ed2e0c4331b491cb9d6a
|
||||
workflows_ref: df507e6bae179c567ad3849370f99dae588985dc
|
||||
secrets:
|
||||
CURSOR_API_KEY: ${{ secrets.CURSOR_API_KEY }}
|
||||
# Optional — enables start/complete Slack DMs to the triggerer.
|
||||
|
||||
@@ -40,7 +40,7 @@ test.describe('Cloud page @smoke', () => {
|
||||
}
|
||||
})
|
||||
|
||||
test('AIModelsSection heading and 5 model cards are visible', async ({
|
||||
test('AIModelsSection heading and 6 model cards are visible', async ({
|
||||
page
|
||||
}) => {
|
||||
const heading = page.getByRole('heading', { name: /leading AI models/i })
|
||||
@@ -49,7 +49,7 @@ test.describe('Cloud page @smoke', () => {
|
||||
const section = heading.locator('xpath=ancestor::section')
|
||||
const grid = section.locator('.grid')
|
||||
const modelCards = grid.locator('a[href="https://comfy.org/workflows"]')
|
||||
await expect(modelCards).toHaveCount(5)
|
||||
await expect(modelCards).toHaveCount(6)
|
||||
})
|
||||
|
||||
test('AIModelsSection CTA links to workflows', async ({ page }) => {
|
||||
|
||||
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 87 KiB After Width: | Height: | Size: 88 KiB |
|
Before Width: | Height: | Size: 87 KiB After Width: | Height: | Size: 88 KiB |
|
Before Width: | Height: | Size: 51 KiB After Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 92 KiB After Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 95 KiB After Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 6.5 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 938 B |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 1.2 KiB |
10
apps/website/public/icons/ai-models/openai.svg
Normal file
@@ -0,0 +1,10 @@
|
||||
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_1483_15836)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M196.373 184.704V136.491C196.373 132.437 197.909 129.387 201.451 127.36L298.368 71.552C311.573 63.936 327.296 60.3947 343.531 60.3947C404.416 60.3947 442.987 107.584 442.987 157.803C442.987 161.365 442.987 165.419 442.475 169.472L341.995 110.613C339.266 108.876 336.099 107.954 332.864 107.954C329.629 107.954 326.462 108.876 323.733 110.613L196.373 184.704ZM422.699 372.437V257.28C422.699 250.176 419.648 245.12 413.547 241.557L286.187 167.467L327.787 143.616C329.294 142.624 331.059 142.095 332.864 142.095C334.669 142.095 336.434 142.624 337.941 143.616L434.859 199.445C462.784 215.659 481.557 250.176 481.557 283.669C481.557 322.24 458.731 357.76 422.677 372.48L422.699 372.437ZM166.443 270.997L124.843 246.635C121.28 244.608 119.744 241.557 119.744 237.504V125.845C119.744 71.552 161.344 30.4427 217.685 30.4427C239.019 30.4427 258.795 37.5467 275.541 50.24L175.573 108.096C169.493 111.637 166.443 116.715 166.443 123.819V270.976V270.997ZM256 322.731L196.373 289.237V218.197L256 184.704L315.627 218.197V289.237L256 322.731ZM294.315 476.971C272.981 476.971 253.205 469.888 236.459 457.195L336.427 399.339C342.507 395.797 345.557 390.72 345.557 383.616V236.459L387.669 260.821C391.232 262.848 392.747 265.899 392.747 269.952V381.589C392.747 435.883 350.635 476.971 294.315 476.971ZM174.059 363.84L77.12 308.011C49.216 291.776 30.4427 257.28 30.4427 223.787C30.3769 204.756 35.9917 186.138 46.5684 170.317C57.1451 154.495 72.2025 142.19 89.8133 134.976V250.667C89.8133 257.771 92.864 262.848 98.944 266.411L225.813 339.989L184.213 363.84C182.707 364.835 180.941 365.365 179.136 365.365C177.331 365.365 175.565 364.835 174.059 363.84ZM168.469 447.04C111.125 447.04 69.0133 403.925 69.0133 350.635C69.0133 346.581 69.5253 342.528 70.016 338.475L169.984 396.288C176.085 399.851 182.165 399.851 188.245 396.288L315.605 322.731V370.944C315.605 374.997 314.112 378.048 310.549 380.075L213.632 435.883C200.427 443.499 184.704 447.04 168.469 447.04ZM294.315 507.413C323.553 507.416 351.895 497.319 374.547 478.831C397.198 460.343 412.768 434.598 418.624 405.952C475.456 391.232 512 337.92 512 283.648C512 248.128 496.789 213.632 469.376 188.757C471.915 178.091 473.429 167.445 473.429 156.8C473.429 84.2453 414.571 29.9307 346.581 29.9307C332.885 29.9307 319.701 31.9573 306.475 36.544C282.795 13.2354 250.933 0.118797 217.707 1.37049e-07C188.465 -0.00135846 160.121 10.0985 137.469 28.5908C114.817 47.0831 99.2486 72.8325 93.3973 101.483C36.544 116.203 0 169.493 0 223.787C0 259.328 15.2107 293.824 42.624 318.677C40.0853 329.344 38.5707 340.011 38.5707 350.656C38.5707 423.211 97.4293 477.504 165.419 477.504C179.115 477.504 192.299 475.477 205.525 470.912C229.208 494.23 261.08 507.347 294.315 507.456V507.413Z" fill="white"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_1483_15836">
|
||||
<rect width="512" height="512" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.0 KiB |
28
apps/website/src/components/common/CardArrow.vue
Normal file
@@ -0,0 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { ChevronRight } from '@lucide/vue'
|
||||
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
const { hover = 'self', class: className } = defineProps<{
|
||||
hover?: 'self' | 'group'
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'flex size-10 items-center justify-center rounded-2xl bg-white/20 text-white backdrop-blur-sm transition-colors',
|
||||
hover === 'group'
|
||||
? 'group-hover:bg-primary-comfy-yellow group-hover:text-primary-comfy-ink'
|
||||
: 'hover:bg-primary-comfy-yellow hover:text-primary-comfy-ink',
|
||||
className
|
||||
)
|
||||
"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<ChevronRight class="size-5" :stroke-width="2" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -33,7 +33,7 @@ const ctaButtons = [
|
||||
|
||||
<template>
|
||||
<nav
|
||||
class="fixed inset-x-0 top-0 z-50 flex items-center justify-between gap-4 bg-primary-comfy-ink px-6 py-5 lg:gap-4 lg:px-[clamp(0.25rem,4vw,5rem)] lg:py-8"
|
||||
class="sticky top-0 z-50 flex items-center justify-between gap-4 bg-primary-comfy-ink px-6 py-5 lg:gap-4 lg:px-[clamp(0.25rem,4vw,5rem)] lg:py-8"
|
||||
aria-label="Main navigation"
|
||||
>
|
||||
<a
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import Button from '../ui/button/Button.vue'
|
||||
|
||||
const { title, description, cta, href, bg } = defineProps<{
|
||||
title: string
|
||||
description: string
|
||||
@@ -28,11 +30,14 @@ const { title, description, cta, href, bg } = defineProps<{
|
||||
<p class="text-sm text-white/70">
|
||||
{{ description }}
|
||||
</p>
|
||||
<span
|
||||
class="bg-primary-comfy-yellow text-primary-comfy-ink mt-4 inline-block rounded-xl px-4 py-2 text-xs font-bold tracking-wide"
|
||||
<Button
|
||||
as="span"
|
||||
variant="default"
|
||||
size="sm"
|
||||
class="mt-4 h-auto whitespace-normal"
|
||||
>
|
||||
{{ cta }}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</a>
|
||||
</template>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale } from '../../../i18n/translations'
|
||||
|
||||
import { externalLinks } from '../../../config/routes'
|
||||
import { t } from '../../../i18n/translations'
|
||||
import CardArrow from '../../common/CardArrow.vue'
|
||||
import GlassCard from '../../common/GlassCard.vue'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
@@ -27,7 +29,7 @@ const cards = [
|
||||
<template>
|
||||
<section class="max-w-9xl mx-auto px-4 pt-24 lg:px-20 lg:pt-40">
|
||||
<h2
|
||||
class="text-primary-comfy-canvas text-3.5xl/tight mx-auto max-w-3xl text-center font-light lg:text-5xl/tight"
|
||||
class="text-3.5xl/tight mx-auto max-w-3xl text-center font-light text-primary-comfy-canvas lg:text-5xl/tight"
|
||||
>
|
||||
{{ headingParts[0]
|
||||
}}<span class="text-white">{{
|
||||
@@ -37,10 +39,11 @@ const cards = [
|
||||
</h2>
|
||||
|
||||
<GlassCard class="mt-12 grid grid-cols-1 gap-6 lg:mt-20 lg:grid-cols-2">
|
||||
<div
|
||||
<a
|
||||
v-for="card in cards"
|
||||
:key="card.labelKey"
|
||||
class="bg-primary-comfy-ink rounded-4.5xl overflow-hidden"
|
||||
:href="externalLinks.cloud"
|
||||
class="group rounded-4.5xl block overflow-hidden bg-primary-comfy-ink"
|
||||
>
|
||||
<img
|
||||
:src="card.image"
|
||||
@@ -51,23 +54,27 @@ const cards = [
|
||||
/>
|
||||
|
||||
<div class="mt-8 p-6">
|
||||
<p
|
||||
class="text-primary-comfy-yellow text-sm font-bold tracking-widest uppercase"
|
||||
>
|
||||
{{ t(card.labelKey, locale) }}
|
||||
</p>
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<p
|
||||
class="text-primary-comfy-yellow text-sm font-bold tracking-widest uppercase"
|
||||
>
|
||||
{{ t(card.labelKey, locale) }}
|
||||
</p>
|
||||
|
||||
<CardArrow hover="group" class="shrink-0" />
|
||||
</div>
|
||||
|
||||
<h3
|
||||
class="text-primary-comfy-canvas mt-8 text-3xl/tight font-light whitespace-pre-line"
|
||||
class="mt-8 text-3xl/tight font-light whitespace-pre-line text-primary-comfy-canvas"
|
||||
>
|
||||
{{ t(card.titleKey, locale) }}
|
||||
</h3>
|
||||
|
||||
<p class="text-primary-comfy-canvas mt-8 text-base/normal">
|
||||
<p class="mt-8 text-base/normal text-primary-comfy-canvas">
|
||||
{{ t(card.descriptionKey, locale) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</GlassCard>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -17,18 +17,18 @@ const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
>
|
||||
<div class="max-w-2xl">
|
||||
<h2
|
||||
class="text-primary-comfy-ink text-2xl/tight font-medium lg:text-3xl/tight"
|
||||
class="text-2xl/tight font-medium text-primary-comfy-ink lg:text-3xl/tight"
|
||||
>
|
||||
{{ t('cloud.pricing.title', locale) }}
|
||||
</h2>
|
||||
|
||||
<p class="text-primary-comfy-ink mt-4 text-base">
|
||||
<p class="mt-4 text-base text-primary-comfy-ink">
|
||||
{{ t('cloud.pricing.description', locale) }}
|
||||
</p>
|
||||
|
||||
<p
|
||||
v-if="SHOW_FREE_TIER"
|
||||
class="text-primary-comfy-ink mt-4 text-base font-bold"
|
||||
class="mt-4 text-base font-bold text-primary-comfy-ink"
|
||||
>
|
||||
{{ t('cloud.pricing.tagline', locale) }}
|
||||
</p>
|
||||
@@ -36,7 +36,7 @@ const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
<a
|
||||
:href="getRoutes(locale).cloudPricing"
|
||||
class="bg-primary-comfy-ink text-primary-comfy-yellow shrink-0 rounded-2xl px-6 py-3 text-center text-sm font-semibold transition-opacity hover:opacity-90"
|
||||
class="text-primary-comfy-yellow shrink-0 rounded-2xl bg-primary-comfy-ink px-6 py-3 text-center text-sm font-semibold transition-opacity hover:opacity-90"
|
||||
>
|
||||
{{ t('cloud.pricing.cta', locale) }}
|
||||
</a>
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { Locale } from '../../../i18n/translations'
|
||||
import { externalLinks } from '../../../config/routes'
|
||||
import { t } from '../../../i18n/translations'
|
||||
import BrandButton from '../../common/BrandButton.vue'
|
||||
import CardArrow from '../../common/CardArrow.vue'
|
||||
|
||||
type ModelCard = {
|
||||
titleKey:
|
||||
@@ -14,11 +15,10 @@ type ModelCard = {
|
||||
| 'cloud.aiModels.card.seedance20'
|
||||
| 'cloud.aiModels.card.qwenImageEdit'
|
||||
| 'cloud.aiModels.card.wan22TextToVideo'
|
||||
| 'cloud.aiModels.card.gptImage2'
|
||||
imageSrc: string
|
||||
badgeIcon: string
|
||||
badgeClass: string
|
||||
layoutClass: string
|
||||
objectPosition?: string
|
||||
}
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
@@ -32,48 +32,45 @@ const modelCards: ModelCard[] = [
|
||||
imageSrc:
|
||||
'https://media.comfy.org/website/cloud/ai-models/seedance-20.webm',
|
||||
badgeIcon: '/icons/ai-models/bytedance.svg',
|
||||
badgeClass: `${badgeBase} rounded-2xl`,
|
||||
layoutClass: 'lg:col-span-6 lg:aspect-[16/7]'
|
||||
badgeClass: `${badgeBase} rounded-2xl`
|
||||
},
|
||||
{
|
||||
titleKey: 'cloud.aiModels.card.nanoBananaPro',
|
||||
imageSrc:
|
||||
'https://media.comfy.org/website/cloud/ai-models/nano-banana-pro.webp',
|
||||
badgeIcon: '/icons/ai-models/gemini.svg',
|
||||
badgeClass: `${badgeBase} rounded-2xl`,
|
||||
layoutClass: 'lg:col-span-6 lg:aspect-[16/7]',
|
||||
objectPosition: 'center 20%'
|
||||
badgeClass: `${badgeBase} rounded-2xl`
|
||||
},
|
||||
{
|
||||
titleKey: 'cloud.aiModels.card.grokImagine',
|
||||
imageSrc: 'https://media.comfy.org/website/cloud/ai-models/grok-video.webm',
|
||||
badgeIcon: '/icons/ai-models/grok.svg',
|
||||
badgeClass: `${badgeBase} rounded-2xl`,
|
||||
layoutClass: 'lg:col-span-4 lg:aspect-[4/3]'
|
||||
badgeClass: `${badgeBase} rounded-2xl`
|
||||
},
|
||||
{
|
||||
titleKey: 'cloud.aiModels.card.qwenImageEdit',
|
||||
imageSrc:
|
||||
'https://media.comfy.org/website/cloud/ai-models/qwen-image-edit.webp',
|
||||
badgeIcon: '/icons/ai-models/qwen.svg',
|
||||
badgeClass: `${badgeBase} rounded-2xl`,
|
||||
layoutClass: 'lg:col-span-4 lg:aspect-[4/3]'
|
||||
badgeClass: `${badgeBase} rounded-2xl`
|
||||
},
|
||||
{
|
||||
titleKey: 'cloud.aiModels.card.wan22TextToVideo',
|
||||
imageSrc: 'https://media.comfy.org/website/cloud/ai-models/wan-22.webm',
|
||||
badgeIcon: '/icons/ai-models/wan.svg',
|
||||
badgeClass: `${badgeBase} rounded-2xl`,
|
||||
layoutClass: 'lg:col-span-4 lg:aspect-[4/3]'
|
||||
badgeClass: `${badgeBase} rounded-2xl`
|
||||
},
|
||||
{
|
||||
titleKey: 'cloud.aiModels.card.gptImage2',
|
||||
imageSrc:
|
||||
'https://media.comfy.org/website/cloud/ai-models/gpt-image-2.webm',
|
||||
badgeIcon: '/icons/ai-models/openai.svg',
|
||||
badgeClass: `${badgeBase} rounded-2xl`
|
||||
}
|
||||
]
|
||||
|
||||
function getCardClass(layoutClass: string): string {
|
||||
return cn(
|
||||
layoutClass,
|
||||
'group relative h-72 cursor-pointer overflow-hidden rounded-4xl bg-black/40 lg:h-auto'
|
||||
)
|
||||
}
|
||||
const cardClass =
|
||||
'group relative h-72 cursor-pointer overflow-hidden rounded-3xl bg-black/40 lg:col-span-4 lg:aspect-square lg:h-auto'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -100,23 +97,18 @@ function getCardClass(layoutClass: string): string {
|
||||
</p>
|
||||
|
||||
<div class="mt-16 w-full lg:mt-24">
|
||||
<div class="rounded-4xl border border-white/12 p-2 lg:p-1.5">
|
||||
<div class="rounded-4xl bg-white/8 p-2 lg:p-1.5">
|
||||
<div class="grid grid-cols-1 gap-2 lg:grid-cols-12">
|
||||
<a
|
||||
v-for="card in modelCards"
|
||||
:key="card.titleKey"
|
||||
:href="externalLinks.workflows"
|
||||
:class="getCardClass(card.layoutClass)"
|
||||
:class="cardClass"
|
||||
>
|
||||
<video
|
||||
v-if="card.imageSrc.endsWith('.webm')"
|
||||
:src="card.imageSrc"
|
||||
:aria-label="t(card.titleKey, locale)"
|
||||
:style="
|
||||
card.objectPosition
|
||||
? { objectPosition: card.objectPosition }
|
||||
: undefined
|
||||
"
|
||||
class="size-full object-cover transition-transform duration-300 group-hover:scale-105"
|
||||
autoplay
|
||||
loop
|
||||
@@ -134,11 +126,6 @@ function getCardClass(layoutClass: string): string {
|
||||
v-else
|
||||
:src="card.imageSrc"
|
||||
:alt="t(card.titleKey, locale)"
|
||||
:style="
|
||||
card.objectPosition
|
||||
? { objectPosition: card.objectPosition }
|
||||
: undefined
|
||||
"
|
||||
class="size-full object-cover transition-transform duration-300 group-hover:scale-105"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
@@ -168,10 +155,14 @@ function getCardClass(layoutClass: string): string {
|
||||
</div>
|
||||
|
||||
<p
|
||||
class="text-primary-warm-white absolute inset-x-6 bottom-6 text-2xl/tight font-light whitespace-pre-line drop-shadow-[0_2px_8px_rgba(0,0,0,0.9)] lg:top-6 lg:right-auto lg:bottom-auto lg:text-3xl"
|
||||
class="text-primary-warm-white absolute right-20 bottom-6 left-6 text-2xl/tight font-light whitespace-pre-line drop-shadow-[0_2px_8px_rgba(0,0,0,0.9)] lg:top-6 lg:right-auto lg:bottom-auto lg:text-3xl"
|
||||
>
|
||||
{{ t(card.titleKey, locale) }}
|
||||
</p>
|
||||
|
||||
<CardArrow
|
||||
class="absolute right-5 bottom-5 lg:right-6 lg:bottom-6"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
38
apps/website/src/components/ui/icon-button/IconButton.vue
Normal file
@@ -0,0 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
import type { PrimitiveProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import type { IconButtonVariants } from '.'
|
||||
import { Primitive } from 'reka-ui'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { iconButtonVariants } from '.'
|
||||
|
||||
interface Props extends PrimitiveProps {
|
||||
variant?: IconButtonVariants['variant']
|
||||
size?: IconButtonVariants['size']
|
||||
class?: HTMLAttributes['class']
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
const {
|
||||
as = 'button',
|
||||
asChild,
|
||||
variant,
|
||||
size,
|
||||
class: className,
|
||||
disabled
|
||||
} = defineProps<Props>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Primitive
|
||||
data-slot="icon-button"
|
||||
:data-variant="variant"
|
||||
:data-size="size"
|
||||
:as
|
||||
:as-child
|
||||
:disabled
|
||||
:class="cn(iconButtonVariants({ variant, size }), className)"
|
||||
>
|
||||
<slot />
|
||||
</Primitive>
|
||||
</template>
|
||||
28
apps/website/src/components/ui/icon-button/index.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { VariantProps } from 'class-variance-authority'
|
||||
import { cva } from 'class-variance-authority'
|
||||
|
||||
export const iconButtonVariants = cva(
|
||||
[
|
||||
'focus-visible:border-primary-comfy-yellow focus-visible:ring-primary-comfy-yellow/50 inline-flex shrink-0 cursor-pointer items-center justify-center rounded-2xl transition-all duration-200 outline-none focus-visible:ring-3 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0'
|
||||
],
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
ghost:
|
||||
'text-primary-warm-white hover:text-primary-comfy-yellow bg-transparent',
|
||||
outline:
|
||||
'text-primary-comfy-yellow hover:bg-primary-comfy-yellow border-primary-comfy-yellow border-2 bg-primary-comfy-ink hover:text-primary-comfy-ink'
|
||||
},
|
||||
size: {
|
||||
sm: 'size-8',
|
||||
default: 'size-10',
|
||||
lg: 'size-14'
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'ghost',
|
||||
size: 'default'
|
||||
}
|
||||
}
|
||||
)
|
||||
export type IconButtonVariants = VariantProps<typeof iconButtonVariants>
|
||||
75
apps/website/src/composables/useBannerDismissal.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { onMounted, ref } from 'vue'
|
||||
|
||||
import { BANNER_DISMISS_ATTR, BANNER_STORAGE_KEY } from '../utils/banner'
|
||||
|
||||
type ClosedBanners = Record<string, boolean>
|
||||
|
||||
function readClosedBanners(): ClosedBanners {
|
||||
try {
|
||||
const raw = localStorage.getItem(BANNER_STORAGE_KEY)
|
||||
return raw ? (JSON.parse(raw) as ClosedBanners) : {}
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
function writeClosedBanners(value: ClosedBanners): void {
|
||||
try {
|
||||
localStorage.setItem(BANNER_STORAGE_KEY, JSON.stringify(value))
|
||||
} catch {
|
||||
// Storage unavailable (private mode / quota) — dismissal just won't persist.
|
||||
}
|
||||
}
|
||||
|
||||
/** The stable part of a version key (everything before `_v<hash>`). */
|
||||
function versionPrefix(version: string): string {
|
||||
const idx = version.lastIndexOf('_v')
|
||||
return idx === -1 ? version : version.slice(0, idx)
|
||||
}
|
||||
|
||||
/**
|
||||
* Client-side dismissal persisted in localStorage, keyed by a content-aware
|
||||
* `version`. The banner renders visible in the static HTML (so non-dismissers
|
||||
* see no pop-in); an inline pre-hydration script hides an already-dismissed
|
||||
* banner before paint, and this composable then removes it from the DOM on mount.
|
||||
*/
|
||||
export function useBannerDismissal(version: string) {
|
||||
const isVisible = ref(true)
|
||||
|
||||
onMounted(() => {
|
||||
const stored = readClosedBanners()
|
||||
const prefix = versionPrefix(version)
|
||||
|
||||
// Prune stale versions of THIS banner+locale; keep other banners/locales
|
||||
// and the current version.
|
||||
const cleaned: ClosedBanners = Object.create(null) as ClosedBanners
|
||||
let pruned = false
|
||||
for (const key of Object.keys(stored)) {
|
||||
if (versionPrefix(key) !== prefix || key === version) {
|
||||
cleaned[key] = stored[key]
|
||||
} else {
|
||||
pruned = true
|
||||
}
|
||||
}
|
||||
if (pruned) writeClosedBanners(cleaned)
|
||||
|
||||
isVisible.value = !cleaned[version]
|
||||
})
|
||||
|
||||
function close(): void {
|
||||
isVisible.value = false
|
||||
const stored = readClosedBanners()
|
||||
stored[version] = true
|
||||
writeClosedBanners(stored)
|
||||
}
|
||||
|
||||
// Call once the close transition has finished. Sets the pre-paint hide signal
|
||||
// so the banner doesn't flash back in on a ClientRouter (view-transition)
|
||||
// navigation — where the inline <head> script does not re-run but <html>
|
||||
// persists. Deferred to after the animation so the leave transition can play.
|
||||
function persistHidden(): void {
|
||||
document.documentElement.setAttribute(BANNER_DISMISS_ATTR, '')
|
||||
}
|
||||
|
||||
return { isVisible, close, persistHidden }
|
||||
}
|
||||
84
apps/website/src/config/banner.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import type { ButtonVariants } from '../components/ui/button'
|
||||
import type { Locale, TranslationKey } from '../i18n/translations'
|
||||
|
||||
import { t } from '../i18n/translations'
|
||||
import { resolveRel } from '../utils/cta'
|
||||
|
||||
// The banner "CMS": a single typed config resolved through i18n at build time.
|
||||
// `isActive` is the master on/off switch (supersedes the old SHOW_ANNOUNCEMENT_BANNER).
|
||||
// NOTE: on this static site, `startsAt`/`endsAt` are evaluated at BUILD time — the
|
||||
// window gates on the last deploy, not the visitor's exact clock.
|
||||
|
||||
interface BannerLinkConfig {
|
||||
readonly href: string
|
||||
readonly titleKey: TranslationKey
|
||||
readonly target?: boolean
|
||||
readonly buttonVariant?: NonNullable<ButtonVariants['variant']>
|
||||
}
|
||||
|
||||
export interface BannerConfig {
|
||||
readonly id: string
|
||||
readonly isActive: boolean
|
||||
readonly startsAt?: string
|
||||
readonly endsAt?: string
|
||||
/** Empty/undefined = all locales. */
|
||||
readonly targetLocales?: readonly Locale[]
|
||||
/** v1 only supports 'sitewide'. */
|
||||
readonly targetSections?: readonly string[]
|
||||
readonly titleKey: TranslationKey
|
||||
readonly descriptionKey?: TranslationKey
|
||||
readonly link?: BannerLinkConfig
|
||||
}
|
||||
|
||||
interface BannerLinkData {
|
||||
readonly href: string
|
||||
readonly title: string
|
||||
readonly target?: '_blank'
|
||||
readonly rel?: string
|
||||
readonly buttonVariant?: NonNullable<ButtonVariants['variant']>
|
||||
}
|
||||
|
||||
export interface BannerData {
|
||||
readonly id: string
|
||||
readonly title: string
|
||||
readonly description?: string
|
||||
readonly link?: BannerLinkData
|
||||
}
|
||||
|
||||
export const bannerConfig: BannerConfig = {
|
||||
id: 'announcement',
|
||||
isActive: true,
|
||||
targetSections: ['sitewide'],
|
||||
titleKey: 'launches.banner.text',
|
||||
link: {
|
||||
href: '/mcp',
|
||||
titleKey: 'launches.banner.cta',
|
||||
buttonVariant: 'underlineLink'
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve a config's i18n keys into display strings for the given locale. */
|
||||
export function getBannerData(
|
||||
config: BannerConfig,
|
||||
locale: Locale
|
||||
): BannerData {
|
||||
const { link } = config
|
||||
const target = link?.target ? '_blank' : undefined
|
||||
|
||||
return {
|
||||
id: config.id,
|
||||
title: t(config.titleKey, locale),
|
||||
description: config.descriptionKey
|
||||
? t(config.descriptionKey, locale)
|
||||
: undefined,
|
||||
link: link
|
||||
? {
|
||||
href: link.href,
|
||||
title: t(link.titleKey, locale),
|
||||
target,
|
||||
rel: resolveRel({ target: target ?? '_self' }),
|
||||
buttonVariant: link.buttonVariant
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
}
|
||||
@@ -932,9 +932,9 @@ const translations = {
|
||||
'zh-CN': '所有模型。\n商业许可保证。'
|
||||
},
|
||||
'cloud.reason.2.description': {
|
||||
en: 'Run open-source models like Wan 2.2, Flux, LTX and Qwen alongside partner models like Nano Banana, Seedance, Seedream, Grok, Kling, Hunyuan 3D and more. Every model on Comfy Cloud is cleared for commercial use. No license ambiguity. All through one credit balance.',
|
||||
en: 'Run open-source models like Wan 2.2, Flux, LTX and Qwen alongside partner models like Nano Banana, Seedance, Seedream, Grok, Kling, Hunyuan 3D, GPT Image 2 and more. Every model on Comfy Cloud is cleared for commercial use. No license ambiguity. All through one credit balance.',
|
||||
'zh-CN':
|
||||
'运行 Wan 2.2、Flux、LTX 和 Qwen 等开源模型,以及 Nano Banana、Seedance、Seedream、Grok、Kling、Hunyuan 3D 等合作伙伴模型。Comfy Cloud 上的每个模型都已获得商业使用许可。无许可证歧义。通过统一的积分余额使用。'
|
||||
'运行 Wan 2.2、Flux、LTX 和 Qwen 等开源模型,以及 Nano Banana、Seedance、Seedream、Grok、Kling、Hunyuan 3D、GPT Image 2 等合作伙伴模型。Comfy Cloud 上的每个模型都已获得商业使用许可。无许可证歧义。通过统一的积分余额使用。'
|
||||
},
|
||||
'cloud.reason.2.badge.onlyOn': {
|
||||
en: 'ONLY ON',
|
||||
@@ -996,6 +996,10 @@ const translations = {
|
||||
en: 'Wan 2.2',
|
||||
'zh-CN': 'Wan 2.2'
|
||||
},
|
||||
'cloud.aiModels.card.gptImage2': {
|
||||
en: 'GPT Image 2',
|
||||
'zh-CN': 'GPT Image 2'
|
||||
},
|
||||
'cloud.aiModels.ctaDesktop': {
|
||||
en: 'EXPLORE WORKFLOWS WITH THE LATEST MODELS',
|
||||
'zh-CN': '探索最新模型工作流'
|
||||
@@ -3979,12 +3983,12 @@ const translations = {
|
||||
// Launches page (/launches) — subscribe banner
|
||||
// zh-CN strings pending native review (see apps/website/.scratch/drops-page/PRD.md)
|
||||
'launches.banner.text': {
|
||||
en: 'Join the live stream. Get answers in real time.',
|
||||
'zh-CN': '加入直播,实时获得解答。'
|
||||
en: 'Now turn your agent into a creative technologist.',
|
||||
'zh-CN': '现在,让你的智能体成为创意技术专家。'
|
||||
},
|
||||
'launches.banner.cta': {
|
||||
en: 'Join livestream',
|
||||
'zh-CN': '加入直播'
|
||||
en: 'Start Comfy MCP',
|
||||
'zh-CN': '启动 Comfy MCP'
|
||||
},
|
||||
|
||||
// Launches page (/launches) — closing CTA
|
||||
|
||||
@@ -5,6 +5,14 @@ import '../styles/global.css'
|
||||
import type { Locale } from '../i18n/translations'
|
||||
import SiteFooter from '../components/common/SiteFooter.vue'
|
||||
import HeaderMain from '../components/common/HeaderMain/HeaderMain.vue'
|
||||
import AnnouncementBanner from '../templates/drops/AnnouncementBanner.vue'
|
||||
import { bannerConfig, getBannerData } from '../config/banner'
|
||||
import {
|
||||
BANNER_DISMISS_ATTR,
|
||||
BANNER_STORAGE_KEY,
|
||||
createBannerVersion,
|
||||
evaluateBannerVisibility
|
||||
} from '../utils/banner'
|
||||
import { escapeJsonLd } from '../utils/escapeJsonLd'
|
||||
import { fetchGitHubStars, formatStarCount } from '../utils/github'
|
||||
|
||||
@@ -34,6 +42,15 @@ const locale: Locale = rawLocale === 'zh-CN' ? 'zh-CN' : 'en'
|
||||
const rawStars = await fetchGitHubStars('Comfy-Org', 'ComfyUI')
|
||||
const githubStars = rawStars ? formatStarCount(rawStars) : ''
|
||||
|
||||
// Announcement banner — build-time visibility gate + content-hash version key.
|
||||
const bannerData = getBannerData(bannerConfig, locale)
|
||||
const bannerVisible = evaluateBannerVisibility(bannerConfig, {
|
||||
currentLocale: locale,
|
||||
currentSection: 'sitewide',
|
||||
now: new Date(),
|
||||
})
|
||||
const bannerVersion = createBannerVersion(bannerData, locale)
|
||||
|
||||
const gtmId = 'GTM-NP9JM6K7'
|
||||
const gtmEnabled = import.meta.env.PROD
|
||||
|
||||
@@ -124,6 +141,25 @@ const websiteJsonLd = {
|
||||
|
||||
<ClientRouter />
|
||||
<slot name="head" />
|
||||
|
||||
<!-- Hide an already-dismissed announcement banner before first paint (no flash/shift). -->
|
||||
{bannerVisible && (
|
||||
<script
|
||||
is:inline
|
||||
define:vars={{
|
||||
bannerVersion,
|
||||
storageKey: BANNER_STORAGE_KEY,
|
||||
dismissAttr: BANNER_DISMISS_ATTR
|
||||
}}
|
||||
>
|
||||
try {
|
||||
const dismissed = JSON.parse(localStorage.getItem(storageKey) || '{}')
|
||||
if (dismissed[bannerVersion]) {
|
||||
document.documentElement.setAttribute(dismissAttr, '')
|
||||
}
|
||||
} catch (e) {}
|
||||
</script>
|
||||
)}
|
||||
</head>
|
||||
<body class="bg-primary-comfy-ink text-white font-formula antialiased overflow-x-clip">
|
||||
{gtmEnabled && (
|
||||
@@ -137,8 +173,16 @@ const websiteJsonLd = {
|
||||
</noscript>
|
||||
)}
|
||||
|
||||
{bannerVisible && (
|
||||
<AnnouncementBanner
|
||||
data={bannerData}
|
||||
version={bannerVersion}
|
||||
locale={locale}
|
||||
client:load
|
||||
/>
|
||||
)}
|
||||
<HeaderMain locale={locale} github-stars={githubStars} client:load />
|
||||
<main class="mt-20 lg:mt-32">
|
||||
<main>
|
||||
<slot />
|
||||
</main>
|
||||
<SiteFooter locale={locale} client:load />
|
||||
|
||||
@@ -3,7 +3,6 @@ import BaseLayout from '../layouts/BaseLayout.astro'
|
||||
import CtaSection from '../templates/drops/CtaSection.vue'
|
||||
import DropsSection from '../templates/drops/DropsSection.vue'
|
||||
import HeroSection from '../templates/drops/HeroSection.vue'
|
||||
import SubscribeBanner from '../templates/drops/SubscribeBanner.vue'
|
||||
import { t } from '../i18n/translations'
|
||||
|
||||
const locale = 'en' as const
|
||||
@@ -13,7 +12,6 @@ const locale = 'en' as const
|
||||
title={t('launches.page.title', locale)}
|
||||
description={t('launches.page.description', locale)}
|
||||
>
|
||||
<SubscribeBanner locale={locale} client:load />
|
||||
<HeroSection locale={locale} client:load />
|
||||
<DropsSection locale={locale} />
|
||||
<CtaSection locale={locale} />
|
||||
|
||||
@@ -3,7 +3,6 @@ import BaseLayout from '../../layouts/BaseLayout.astro'
|
||||
import CtaSection from '../../templates/drops/CtaSection.vue'
|
||||
import DropsSection from '../../templates/drops/DropsSection.vue'
|
||||
import HeroSection from '../../templates/drops/HeroSection.vue'
|
||||
import SubscribeBanner from '../../templates/drops/SubscribeBanner.vue'
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
const locale = 'zh-CN' as const
|
||||
@@ -13,7 +12,6 @@ const locale = 'zh-CN' as const
|
||||
title={t('launches.page.title', locale)}
|
||||
description={t('launches.page.description', locale)}
|
||||
>
|
||||
<SubscribeBanner locale={locale} client:load />
|
||||
<HeroSection locale={locale} client:load />
|
||||
<DropsSection locale={locale} />
|
||||
<CtaSection locale={locale} />
|
||||
|
||||
@@ -70,6 +70,7 @@
|
||||
--color-secondary-mauve: #4d3762;
|
||||
--color-destructive: #f44336;
|
||||
--color-primary-comfy-plum: #49378b;
|
||||
--color-secondary-deep-plum: #2b2040;
|
||||
--color-secondary-cool-gray: #3c3c3c;
|
||||
--color-illustration-forest: #20464c;
|
||||
--color-transparency-white-t4: rgb(255 255 255 / 0.04);
|
||||
@@ -93,6 +94,14 @@
|
||||
initial-value: 0deg;
|
||||
}
|
||||
|
||||
/* Pre-hydration hide for a dismissed announcement banner (set by an inline
|
||||
script in BaseLayout head) — prevents any flash before Vue hydrates.
|
||||
The [data-banner-dismissed] literal is BANNER_DISMISS_ATTR in utils/banner.ts;
|
||||
keep them in sync. */
|
||||
[data-banner-dismissed] [data-slot='announcement-banner'] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@keyframes border-angle-spin {
|
||||
to {
|
||||
--border-angle: 360deg;
|
||||
@@ -248,7 +257,7 @@
|
||||
@utility ppformula-text-center {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
top: 0.19em;
|
||||
top: 0.1em;
|
||||
}
|
||||
|
||||
/* Hide native play-button overlay iOS Safari shows when autoplay is blocked
|
||||
|
||||
107
apps/website/src/templates/drops/AnnouncementBanner.vue
Normal file
@@ -0,0 +1,107 @@
|
||||
<script setup lang="ts">
|
||||
import { ArrowRight, X } from '@lucide/vue'
|
||||
|
||||
import type { BannerData } from '../../config/banner'
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import { t } from '../../i18n/translations'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import IconButton from '@/components/ui/icon-button/IconButton.vue'
|
||||
import { useBannerDismissal } from '../../composables/useBannerDismissal'
|
||||
|
||||
const {
|
||||
data,
|
||||
version,
|
||||
locale = 'en'
|
||||
} = defineProps<{
|
||||
data: BannerData
|
||||
version: string
|
||||
locale?: Locale
|
||||
}>()
|
||||
|
||||
const { isVisible, close, persistHidden } = useBannerDismissal(version)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Transition name="banner-collapse" @after-leave="persistHidden">
|
||||
<div v-if="isVisible" class="banner-collapse grid">
|
||||
<div class="min-h-0 overflow-hidden">
|
||||
<div
|
||||
data-slot="announcement-banner"
|
||||
class="after:bg-transparency-white-t4 relative flex items-center gap-x-6 px-6 py-4 after:pointer-events-none after:absolute after:inset-x-0 after:bottom-0 after:h-px sm:px-3.5 sm:before:flex-1"
|
||||
style="
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--color-primary-comfy-plum) 0%,
|
||||
var(--color-secondary-deep-plum) 53.85%,
|
||||
var(--color-secondary-mauve) 100%
|
||||
);
|
||||
"
|
||||
>
|
||||
<div class="flex flex-wrap items-center gap-x-8 gap-y-2">
|
||||
<p
|
||||
class="text-primary-warm-white ppformula-text-center text-sm md:text-base/6"
|
||||
>
|
||||
{{ data.title }}
|
||||
<span v-if="data.description" class="text-primary-warm-white/80">
|
||||
{{ data.description }}
|
||||
</span>
|
||||
</p>
|
||||
<Button
|
||||
v-if="data.link"
|
||||
as="a"
|
||||
:href="data.link.href"
|
||||
:target="data.link.target"
|
||||
:rel="data.link.rel"
|
||||
:variant="data.link.buttonVariant ?? 'underlineLink'"
|
||||
size="sm"
|
||||
>
|
||||
{{ data.link.title }}
|
||||
<template #append>
|
||||
<ArrowRight class="size-4" />
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
<div class="flex flex-1 justify-end">
|
||||
<IconButton
|
||||
type="button"
|
||||
:aria-label="t('nav.close', locale)"
|
||||
@click="close"
|
||||
>
|
||||
<X class="size-5" aria-hidden="true" />
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Collapse the banner's height (grid 1fr → 0fr) so page content below slides
|
||||
up smoothly, with a fade. Enter is defined for symmetry; in practice only the
|
||||
leave (dismiss) runs, since the banner renders present in the static HTML. */
|
||||
.banner-collapse {
|
||||
grid-template-rows: 1fr;
|
||||
}
|
||||
|
||||
.banner-collapse-enter-active,
|
||||
.banner-collapse-leave-active {
|
||||
transition:
|
||||
grid-template-rows 300ms ease,
|
||||
opacity 250ms ease;
|
||||
}
|
||||
|
||||
.banner-collapse-enter-from,
|
||||
.banner-collapse-leave-to {
|
||||
grid-template-rows: 0fr;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.banner-collapse-enter-active,
|
||||
.banner-collapse-leave-active {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,61 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { useTimeoutFn } from '@vueuse/core'
|
||||
import { onMounted, ref } from 'vue'
|
||||
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import { t } from '../../i18n/translations'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { resolveRel } from '../../utils/cta'
|
||||
import { livestream } from './livestream'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const signUpHref = `https://www.youtube.com/watch?v=${livestream.youtubeVideoId}`
|
||||
const signUpRel = resolveRel({ target: '_blank' })
|
||||
|
||||
// Hide once the livestream window closes — both for visitors arriving after
|
||||
// the event and for visitors whose tab is open when it ends.
|
||||
const endMs = new Date(livestream.endDateTime).getTime()
|
||||
const visible = ref(true)
|
||||
|
||||
// useTimeoutFn auto-clears on unmount. Arm it client-side only so SSR never
|
||||
// schedules a long-lived server timer.
|
||||
const { start } = useTimeoutFn(
|
||||
() => {
|
||||
visible.value = false
|
||||
},
|
||||
() => Math.max(0, endMs - Date.now()),
|
||||
{ immediate: false }
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
if (endMs - Date.now() <= 0) {
|
||||
visible.value = false
|
||||
} else {
|
||||
start()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="visible" class="px-4">
|
||||
<div
|
||||
class="bg-primary-comfy-plum max-w-8xl rounded-5xl text-primary-warm-white mx-auto flex w-full flex-col items-center justify-center gap-2 px-6 py-5 text-center text-sm sm:flex-row sm:gap-4"
|
||||
>
|
||||
<p class="ppformula-text-center">
|
||||
{{ t('launches.banner.text', locale) }}
|
||||
</p>
|
||||
<Button
|
||||
:href="signUpHref"
|
||||
as="a"
|
||||
variant="underlineLink"
|
||||
size="sm"
|
||||
target="_blank"
|
||||
:rel="signUpRel"
|
||||
>
|
||||
{{ t('launches.banner.cta', locale) }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
109
apps/website/src/utils/banner.test.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { EvaluableBanner } from './banner'
|
||||
|
||||
import { createBannerVersion, evaluateBannerVisibility } from './banner'
|
||||
|
||||
const base: EvaluableBanner = {
|
||||
isActive: true,
|
||||
targetSections: ['sitewide']
|
||||
}
|
||||
|
||||
const ctx = {
|
||||
currentLocale: 'en',
|
||||
currentSection: 'sitewide',
|
||||
now: new Date('2026-07-06T00:00:00Z')
|
||||
}
|
||||
|
||||
describe('evaluateBannerVisibility', () => {
|
||||
it('shows an active, untargeted, sitewide banner', () => {
|
||||
expect(evaluateBannerVisibility(base, ctx)).toBe(true)
|
||||
})
|
||||
|
||||
it('hides when inactive', () => {
|
||||
expect(evaluateBannerVisibility({ ...base, isActive: false }, ctx)).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
it('hides before startsAt and shows within the window', () => {
|
||||
expect(
|
||||
evaluateBannerVisibility(
|
||||
{ ...base, startsAt: '2026-07-10T00:00:00Z' },
|
||||
ctx
|
||||
)
|
||||
).toBe(false)
|
||||
expect(
|
||||
evaluateBannerVisibility(
|
||||
{ ...base, startsAt: '2026-07-01T00:00:00Z' },
|
||||
ctx
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('hides after endsAt', () => {
|
||||
expect(
|
||||
evaluateBannerVisibility({ ...base, endsAt: '2026-07-01T00:00:00Z' }, ctx)
|
||||
).toBe(false)
|
||||
expect(
|
||||
evaluateBannerVisibility({ ...base, endsAt: '2026-07-10T00:00:00Z' }, ctx)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('treats an empty targetLocales as "all locales"', () => {
|
||||
expect(evaluateBannerVisibility({ ...base, targetLocales: [] }, ctx)).toBe(
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
it('hides when targetLocales excludes the current locale', () => {
|
||||
expect(
|
||||
evaluateBannerVisibility({ ...base, targetLocales: ['zh-CN'] }, ctx)
|
||||
).toBe(false)
|
||||
expect(
|
||||
evaluateBannerVisibility({ ...base, targetLocales: ['en', 'zh-CN'] }, ctx)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('hides when targetSections does not include the current section', () => {
|
||||
expect(
|
||||
evaluateBannerVisibility({ ...base, targetSections: ['checkout'] }, ctx)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('hides when targetSections is absent (nothing to match)', () => {
|
||||
expect(evaluateBannerVisibility({ isActive: true }, ctx)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('createBannerVersion', () => {
|
||||
const content = {
|
||||
id: 'announcement',
|
||||
title: 'Join the live stream',
|
||||
link: { href: 'https://x', title: 'Join' }
|
||||
}
|
||||
|
||||
it('is deterministic for identical content', () => {
|
||||
expect(createBannerVersion(content, 'en')).toBe(
|
||||
createBannerVersion(content, 'en')
|
||||
)
|
||||
})
|
||||
|
||||
it('encodes the banner id and locale in the key', () => {
|
||||
expect(createBannerVersion(content, 'en')).toMatch(
|
||||
/^announcement_en_v-?\d+$/
|
||||
)
|
||||
})
|
||||
|
||||
it('changes when the copy changes', () => {
|
||||
expect(createBannerVersion(content, 'en')).not.toBe(
|
||||
createBannerVersion({ ...content, title: 'New copy' }, 'en')
|
||||
)
|
||||
})
|
||||
|
||||
it('differs per locale so one locale edit does not re-show another', () => {
|
||||
expect(createBannerVersion(content, 'en')).not.toBe(
|
||||
createBannerVersion(content, 'zh-CN')
|
||||
)
|
||||
})
|
||||
})
|
||||
87
apps/website/src/utils/banner.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
// Pure, framework-agnostic banner logic — no Vue/Astro/config imports so it stays
|
||||
// trivially unit-testable. Locale/section are plain strings on purpose.
|
||||
|
||||
// Shared dismissal storage contract. The pre-hydration script in BaseLayout.astro,
|
||||
// the useBannerDismissal composable, and the CSS selector in global.css must all
|
||||
// agree on these literals — keep them here as the single source of truth.
|
||||
export const BANNER_STORAGE_KEY = 'closedBanners'
|
||||
export const BANNER_DISMISS_ATTR = 'data-banner-dismissed'
|
||||
|
||||
export interface BannerVisibilityContext {
|
||||
currentLocale: string
|
||||
currentSection: string
|
||||
now: Date
|
||||
}
|
||||
|
||||
export interface EvaluableBanner {
|
||||
isActive: boolean
|
||||
startsAt?: string
|
||||
endsAt?: string
|
||||
targetLocales?: readonly string[]
|
||||
targetSections?: readonly string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Server/build-time visibility gate. Returns false on the FIRST failing check,
|
||||
* in order: active flag → start window → end window → locale targeting →
|
||||
* section targeting. An empty/absent `targetLocales` means "all locales".
|
||||
*/
|
||||
export function evaluateBannerVisibility(
|
||||
banner: EvaluableBanner,
|
||||
ctx: BannerVisibilityContext
|
||||
): boolean {
|
||||
if (!banner.isActive) return false
|
||||
if (
|
||||
banner.startsAt &&
|
||||
ctx.now.getTime() < new Date(banner.startsAt).getTime()
|
||||
)
|
||||
return false
|
||||
if (banner.endsAt && ctx.now.getTime() > new Date(banner.endsAt).getTime())
|
||||
return false
|
||||
|
||||
const targetLocales = banner.targetLocales ?? []
|
||||
if (targetLocales.length > 0 && !targetLocales.includes(ctx.currentLocale))
|
||||
return false
|
||||
|
||||
const targetSections = banner.targetSections ?? []
|
||||
if (!targetSections.includes(ctx.currentSection)) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
interface BannerLinkContent {
|
||||
href: string
|
||||
title: string
|
||||
target?: string
|
||||
rel?: string
|
||||
buttonVariant?: string
|
||||
}
|
||||
|
||||
export interface BannerVersionContent {
|
||||
id: string
|
||||
title: string
|
||||
description?: string
|
||||
link?: BannerLinkContent
|
||||
}
|
||||
|
||||
/**
|
||||
* Content-aware version key. Editing the copy changes the hash, so a previously
|
||||
* dismissed banner re-appears. Keyed per-locale so a zh-CN edit doesn't re-show
|
||||
* the banner for en visitors. Format: `${content.id}_${locale}_v${hash}`.
|
||||
*/
|
||||
export function createBannerVersion(
|
||||
content: BannerVersionContent,
|
||||
locale: string
|
||||
): string {
|
||||
const contentString = JSON.stringify({
|
||||
locale,
|
||||
title: content.title,
|
||||
description: content.description,
|
||||
link: content.link
|
||||
})
|
||||
let hash = 0
|
||||
for (const char of contentString) {
|
||||
hash = Math.imul(hash, 31) + char.charCodeAt(0)
|
||||
}
|
||||
return `${content.id}_${locale}_v${hash}`
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"last_node_id": 1,
|
||||
"last_link_id": 0,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "LoadVideo",
|
||||
"pos": [50, 120],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "VIDEO",
|
||||
"type": "VIDEO",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "LoadVideo"
|
||||
},
|
||||
"widgets_values": ["video/cloud-video-hash.mp4 [output]", "image"]
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"offset": [0, 0],
|
||||
"scale": 1
|
||||
}
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -1,6 +1,11 @@
|
||||
import { expect, mergeTests } from '@playwright/test'
|
||||
import type { Page, Route } from '@playwright/test'
|
||||
import type { Asset, ListAssetsResponse } from '@comfyorg/ingest-types'
|
||||
import type {
|
||||
Asset,
|
||||
GetAllSettingsResponse,
|
||||
GetSettingByIdResponse,
|
||||
ListAssetsResponse
|
||||
} from '@comfyorg/ingest-types'
|
||||
|
||||
import {
|
||||
assetRequestIncludesTag,
|
||||
@@ -8,6 +13,7 @@ import {
|
||||
} from '@e2e/fixtures/assetApiFixture'
|
||||
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import type { WorkspaceStore } from '@e2e/types/globals'
|
||||
import {
|
||||
routeObjectInfoFromSetupApi,
|
||||
setComboInputOptions
|
||||
@@ -23,10 +29,11 @@ import type { RawJobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||
const ossTest = mergeTests(comfyPageFixture, jobsRouteFixture)
|
||||
const outputHash =
|
||||
'147257c95a3e957e0deee73a077cfec89da2d906dd086ca70a2b0c897a9591d6e.png'
|
||||
const outputVideoHash = 'cloud-video-hash.mp4'
|
||||
const plainVideoFileName = 'plain_video.mp4'
|
||||
const graphDropPosition = { x: 500, y: 300 }
|
||||
const missingMediaUploadObservationMs = 1_000
|
||||
const missingMediaUploadPollMs = 100
|
||||
const missingMediaObservationMs = 1_000
|
||||
const missingMediaPollMs = 100
|
||||
const emptyMediaLoaderNodes = [
|
||||
{
|
||||
nodeType: 'LoadImage',
|
||||
@@ -60,6 +67,18 @@ const cloudOutputAsset: Asset & { hash?: string } = {
|
||||
last_access_time: '2026-05-01T00:00:00Z'
|
||||
}
|
||||
|
||||
const cloudOutputVideoAsset: Asset & { hash?: string } = {
|
||||
id: 'test-output-video-hash-001',
|
||||
name: 'ComfyUI_00001_.mp4',
|
||||
hash: outputVideoHash,
|
||||
size: 4_194_304,
|
||||
mime_type: 'video/mp4',
|
||||
tags: ['output'],
|
||||
created_at: '2026-05-01T00:00:00Z',
|
||||
updated_at: '2026-05-01T00:00:00Z',
|
||||
last_access_time: '2026-05-01T00:00:00Z'
|
||||
}
|
||||
|
||||
const cloudUploadedVideoAsset: Asset & { hash?: string } = {
|
||||
id: 'test-uploaded-video-001',
|
||||
name: plainVideoFileName,
|
||||
@@ -92,10 +111,21 @@ interface CloudUploadAssetState {
|
||||
|
||||
async function routeCloudBootstrapApis(page: Page) {
|
||||
await page.route('**/api/settings**', async (route) => {
|
||||
const completedSurveySetting: GetSettingByIdResponse = {
|
||||
value: { usage: 'personal' }
|
||||
}
|
||||
const allSettings: GetAllSettingsResponse = {}
|
||||
const body = route
|
||||
.request()
|
||||
.url()
|
||||
.includes('/api/settings/onboarding_survey')
|
||||
? completedSurveySetting
|
||||
: allSettings
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({})
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
})
|
||||
await page.route('**/api/userdata**', async (route) => {
|
||||
@@ -121,7 +151,10 @@ async function routeCloudBootstrapApis(page: Page) {
|
||||
})
|
||||
}
|
||||
|
||||
const cloudOutputTest = createCloudAssetsFixture([cloudOutputAsset]).extend({
|
||||
const cloudOutputTest = createCloudAssetsFixture([
|
||||
cloudOutputAsset,
|
||||
cloudOutputVideoAsset
|
||||
]).extend({
|
||||
page: async ({ page }, use) => {
|
||||
await routeCloudBootstrapApis(page)
|
||||
const unrouteObjectInfo = await routeObjectInfoFromSetupApi(page)
|
||||
@@ -225,6 +258,33 @@ function getErrorOverlay(comfyPage: ComfyPage) {
|
||||
return comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
|
||||
}
|
||||
|
||||
function isOutputAssetsRequest(url: string) {
|
||||
return url.includes('/api/assets') && assetRequestIncludesTag(url, 'output')
|
||||
}
|
||||
|
||||
async function waitForOutputAssetsResponse(comfyPage: ComfyPage) {
|
||||
await comfyPage.page.waitForResponse(
|
||||
(response) =>
|
||||
response.status() === 200 && isOutputAssetsRequest(response.url())
|
||||
)
|
||||
}
|
||||
|
||||
async function getCachedMissingMediaWarningNames(
|
||||
comfyPage: ComfyPage
|
||||
): Promise<string[] | null> {
|
||||
return await comfyPage.page.evaluate(() => {
|
||||
const workflow = (window.app!.extensionManager as WorkspaceStore).workflow
|
||||
.activeWorkflow
|
||||
if (!workflow) return null
|
||||
|
||||
return (
|
||||
workflow.pendingWarnings?.missingMediaCandidates?.map(
|
||||
(candidate) => candidate.name
|
||||
) ?? []
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
async function expectNoErrorsTab(comfyPage: ComfyPage) {
|
||||
await expect(getErrorOverlay(comfyPage)).toBeHidden()
|
||||
|
||||
@@ -327,25 +387,31 @@ async function expectLoadVideoUploading(comfyPage: ComfyPage) {
|
||||
.toBe(true)
|
||||
}
|
||||
|
||||
async function expectNoMissingMediaDuringUpload(comfyPage: ComfyPage) {
|
||||
async function expectNoMissingMediaForObservationWindow(comfyPage: ComfyPage) {
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
let sawErrorOverlay = false
|
||||
let sawCachedMissingMedia = false
|
||||
const startedAt = Date.now()
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const cachedMissingMedia =
|
||||
await getCachedMissingMediaWarningNames(comfyPage)
|
||||
sawCachedMissingMedia =
|
||||
sawCachedMissingMedia || !!cachedMissingMedia?.length
|
||||
sawErrorOverlay =
|
||||
sawErrorOverlay || (await getErrorOverlay(comfyPage).isVisible())
|
||||
return (
|
||||
!sawErrorOverlay &&
|
||||
Date.now() - startedAt >= missingMediaUploadObservationMs
|
||||
!sawCachedMissingMedia &&
|
||||
Date.now() - startedAt >= missingMediaObservationMs
|
||||
)
|
||||
},
|
||||
{
|
||||
timeout: missingMediaUploadObservationMs + missingMediaUploadPollMs * 5,
|
||||
intervals: [missingMediaUploadPollMs]
|
||||
timeout: missingMediaObservationMs + missingMediaPollMs * 5,
|
||||
intervals: [missingMediaPollMs]
|
||||
}
|
||||
)
|
||||
.toBe(true)
|
||||
@@ -424,7 +490,7 @@ ossTest.describe(
|
||||
})
|
||||
|
||||
await expectLoadVideoUploading(comfyPage)
|
||||
await expectNoMissingMediaDuringUpload(comfyPage)
|
||||
await expectNoMissingMediaForObservationWindow(comfyPage)
|
||||
|
||||
await delayedUpload.finishUpload()
|
||||
await expect(getErrorOverlay(comfyPage)).toBeHidden()
|
||||
@@ -482,18 +548,30 @@ cloudOutputTest.describe(
|
||||
|
||||
cloudOutputTest(
|
||||
'resolves compact annotated output media from output assets',
|
||||
async ({ cloudAssetRequests, comfyPage }) => {
|
||||
async ({ comfyPage }) => {
|
||||
const outputAssetsResponse = waitForOutputAssetsResponse(comfyPage)
|
||||
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'missing/missing_media_cloud_output_annotation'
|
||||
)
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
cloudAssetRequests.some((url) =>
|
||||
assetRequestIncludesTag(url, 'output')
|
||||
)
|
||||
)
|
||||
.toBe(true)
|
||||
await outputAssetsResponse
|
||||
await expectNoMissingMediaForObservationWindow(comfyPage)
|
||||
await expectNoErrorsTab(comfyPage)
|
||||
}
|
||||
)
|
||||
|
||||
cloudOutputTest(
|
||||
'resolves subfoldered output video media from flat output asset hashes',
|
||||
async ({ comfyPage }) => {
|
||||
const outputAssetsResponse = waitForOutputAssetsResponse(comfyPage)
|
||||
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'missing/missing_media_cloud_output_video_subfolder'
|
||||
)
|
||||
|
||||
await outputAssetsResponse
|
||||
await expectNoMissingMediaForObservationWindow(comfyPage)
|
||||
await expectNoErrorsTab(comfyPage)
|
||||
}
|
||||
)
|
||||
@@ -529,7 +607,7 @@ cloudUploadRaceTest.describe(
|
||||
})
|
||||
|
||||
await expectLoadVideoUploading(comfyPage)
|
||||
await expectNoMissingMediaDuringUpload(comfyPage)
|
||||
await expectNoMissingMediaForObservationWindow(comfyPage)
|
||||
|
||||
markUploadedCloudAssetAvailable()
|
||||
await delayedUpload.finishUpload()
|
||||
|
||||
@@ -12,6 +12,11 @@ export type {
|
||||
AddAssetTagsErrors,
|
||||
AddAssetTagsResponse,
|
||||
AddAssetTagsResponses,
|
||||
AdminDeleteHubWorkflowData,
|
||||
AdminDeleteHubWorkflowError,
|
||||
AdminDeleteHubWorkflowErrors,
|
||||
AdminDeleteHubWorkflowResponse,
|
||||
AdminDeleteHubWorkflowResponses,
|
||||
Asset,
|
||||
AssetCreated,
|
||||
AssetCreatedWritable,
|
||||
@@ -42,6 +47,11 @@ export type {
|
||||
CancelJobErrors,
|
||||
CancelJobResponse,
|
||||
CancelJobResponses,
|
||||
CancelJobsData,
|
||||
CancelJobsError,
|
||||
CancelJobsErrors,
|
||||
CancelJobsResponse,
|
||||
CancelJobsResponses,
|
||||
CancelSubscriptionData,
|
||||
CancelSubscriptionError,
|
||||
CancelSubscriptionErrors,
|
||||
@@ -84,6 +94,11 @@ export type {
|
||||
CreateDeletionRequestErrors,
|
||||
CreateDeletionRequestResponse,
|
||||
CreateDeletionRequestResponses,
|
||||
CreateDesktopLoginCodeData,
|
||||
CreateDesktopLoginCodeError,
|
||||
CreateDesktopLoginCodeErrors,
|
||||
CreateDesktopLoginCodeResponse,
|
||||
CreateDesktopLoginCodeResponses,
|
||||
CreateHubAssetUploadUrlData,
|
||||
CreateHubAssetUploadUrlError,
|
||||
CreateHubAssetUploadUrlErrors,
|
||||
@@ -186,12 +201,31 @@ export type {
|
||||
DeleteWorkspaceResponses,
|
||||
DeletionRequest,
|
||||
DeletionStatus,
|
||||
DesktopLoginCodeCreateRequest,
|
||||
DesktopLoginCodeCreateResponse,
|
||||
DesktopLoginCodeExchangeRequest,
|
||||
DesktopLoginCodeExchangeResponse,
|
||||
DesktopLoginCodeRedeemRequest,
|
||||
DesktopLoginCodeRedeemResponse,
|
||||
DownloadExportData,
|
||||
DownloadExportError,
|
||||
DownloadExportErrors,
|
||||
DownloadExportResponse,
|
||||
DownloadExportResponses,
|
||||
EnsureWorkspaceBillingLegacySnapshot,
|
||||
EnsureWorkspaceBillingProvisionedData,
|
||||
EnsureWorkspaceBillingProvisionedError,
|
||||
EnsureWorkspaceBillingProvisionedErrors,
|
||||
EnsureWorkspaceBillingProvisionedRequest,
|
||||
EnsureWorkspaceBillingProvisionedResponse,
|
||||
EnsureWorkspaceBillingProvisionedResponse2,
|
||||
EnsureWorkspaceBillingProvisionedResponses,
|
||||
ErrorResponse,
|
||||
ExchangeDesktopLoginCodeData,
|
||||
ExchangeDesktopLoginCodeError,
|
||||
ExchangeDesktopLoginCodeErrors,
|
||||
ExchangeDesktopLoginCodeResponse,
|
||||
ExchangeDesktopLoginCodeResponses,
|
||||
ExchangeTokenData,
|
||||
ExchangeTokenError,
|
||||
ExchangeTokenErrors,
|
||||
@@ -230,6 +264,11 @@ export type {
|
||||
GetAssetByIdErrors,
|
||||
GetAssetByIdResponse,
|
||||
GetAssetByIdResponses,
|
||||
GetAssetContentData,
|
||||
GetAssetContentError,
|
||||
GetAssetContentErrors,
|
||||
GetAssetContentResponse,
|
||||
GetAssetContentResponses,
|
||||
GetAssetSeedStatusData,
|
||||
GetAssetSeedStatusResponse,
|
||||
GetAssetSeedStatusResponses,
|
||||
@@ -303,6 +342,11 @@ export type {
|
||||
GetHistoryData,
|
||||
GetHistoryError,
|
||||
GetHistoryErrors,
|
||||
GetHistoryEventsData,
|
||||
GetHistoryEventsError,
|
||||
GetHistoryEventsErrors,
|
||||
GetHistoryEventsResponse,
|
||||
GetHistoryEventsResponses,
|
||||
GetHistoryForPromptData,
|
||||
GetHistoryForPromptError,
|
||||
GetHistoryForPromptErrors,
|
||||
@@ -345,8 +389,6 @@ export type {
|
||||
GetJwksData,
|
||||
GetJwksResponse,
|
||||
GetJwksResponses,
|
||||
GetLegacyAssetContentData,
|
||||
GetLegacyAssetContentErrors,
|
||||
GetLegacyHistoryByIdData,
|
||||
GetLegacyHistoryByIdErrors,
|
||||
GetLegacyHistoryData,
|
||||
@@ -556,6 +598,7 @@ export type {
|
||||
HistoryDetailEntry,
|
||||
HistoryDetailResponse,
|
||||
HistoryEntry,
|
||||
HistoryEventRequest,
|
||||
HistoryManageRequest,
|
||||
HistoryResponse,
|
||||
HubAssetUploadUrlRequest,
|
||||
@@ -589,6 +632,8 @@ export type {
|
||||
JobCancelResponse,
|
||||
JobDetailResponse,
|
||||
JobEntry,
|
||||
JobsCancelRequest,
|
||||
JobsCancelResponse,
|
||||
JobsListResponse,
|
||||
JobStatusResponse,
|
||||
JwkKey,
|
||||
@@ -627,7 +672,19 @@ export type {
|
||||
ListJobsErrors,
|
||||
ListJobsResponse,
|
||||
ListJobsResponses,
|
||||
ListLinkedFirebaseUidsData,
|
||||
ListLinkedFirebaseUidsError,
|
||||
ListLinkedFirebaseUidsErrors,
|
||||
ListLinkedFirebaseUidsRequest,
|
||||
ListLinkedFirebaseUidsResponse,
|
||||
ListLinkedFirebaseUidsResponse2,
|
||||
ListLinkedFirebaseUidsResponses,
|
||||
ListMembersResponse,
|
||||
ListSecretProvidersData,
|
||||
ListSecretProvidersError,
|
||||
ListSecretProvidersErrors,
|
||||
ListSecretProvidersResponse,
|
||||
ListSecretProvidersResponses,
|
||||
ListSecretsData,
|
||||
ListSecretsError,
|
||||
ListSecretsErrors,
|
||||
@@ -775,6 +832,17 @@ export type {
|
||||
QueueInfo,
|
||||
QueueManageRequest,
|
||||
QueueManageResponse,
|
||||
RedeemDesktopLoginCodeData,
|
||||
RedeemDesktopLoginCodeError,
|
||||
RedeemDesktopLoginCodeErrors,
|
||||
RedeemDesktopLoginCodeResponse,
|
||||
RedeemDesktopLoginCodeResponses,
|
||||
ReleaseDeletionHoldData,
|
||||
ReleaseDeletionHoldError,
|
||||
ReleaseDeletionHoldErrors,
|
||||
ReleaseDeletionHoldResponse,
|
||||
ReleaseDeletionHoldResponses,
|
||||
ReleaseHoldResponse,
|
||||
RemoveAssetTagsData,
|
||||
RemoveAssetTagsError,
|
||||
RemoveAssetTagsErrors,
|
||||
@@ -785,6 +853,11 @@ export type {
|
||||
RemoveWorkspaceMemberErrors,
|
||||
RemoveWorkspaceMemberResponse,
|
||||
RemoveWorkspaceMemberResponses,
|
||||
ReportHistoryEventData,
|
||||
ReportHistoryEventError,
|
||||
ReportHistoryEventErrors,
|
||||
ReportHistoryEventResponse,
|
||||
ReportHistoryEventResponses,
|
||||
ReportPartnerUsageData,
|
||||
ReportPartnerUsageError,
|
||||
ReportPartnerUsageErrors,
|
||||
@@ -808,6 +881,8 @@ export type {
|
||||
RevokeWorkspaceInviteResponse,
|
||||
RevokeWorkspaceInviteResponses,
|
||||
SecretListResponse,
|
||||
SecretProvider,
|
||||
SecretProvidersResponse,
|
||||
SecretResponse,
|
||||
SeedAssetsData,
|
||||
SeedAssetsResponse,
|
||||
@@ -819,6 +894,8 @@ export type {
|
||||
SetReviewStatusResponse,
|
||||
SetReviewStatusResponse2,
|
||||
SetReviewStatusResponses,
|
||||
ShortLinkRedirectData,
|
||||
ShortLinkRedirectErrors,
|
||||
SubmitFeedbackData,
|
||||
SubmitFeedbackError,
|
||||
SubmitFeedbackErrors,
|
||||
@@ -848,6 +925,10 @@ export type {
|
||||
TaskEntry,
|
||||
TaskResponse,
|
||||
TasksListResponse,
|
||||
TeamCreditStop,
|
||||
TeamCreditStopPrice,
|
||||
TeamCreditStops,
|
||||
TeamCreditStopSummary,
|
||||
UpdateAssetData,
|
||||
UpdateAssetError,
|
||||
UpdateAssetErrors,
|
||||
@@ -865,6 +946,7 @@ export type {
|
||||
UpdateHubWorkflowRequest,
|
||||
UpdateHubWorkflowResponse,
|
||||
UpdateHubWorkflowResponses,
|
||||
UpdateMemberRoleRequest,
|
||||
UpdateMultipleSettingsData,
|
||||
UpdateMultipleSettingsError,
|
||||
UpdateMultipleSettingsErrors,
|
||||
@@ -895,6 +977,11 @@ export type {
|
||||
UpdateWorkspaceData,
|
||||
UpdateWorkspaceError,
|
||||
UpdateWorkspaceErrors,
|
||||
UpdateWorkspaceMemberRoleData,
|
||||
UpdateWorkspaceMemberRoleError,
|
||||
UpdateWorkspaceMemberRoleErrors,
|
||||
UpdateWorkspaceMemberRoleResponse,
|
||||
UpdateWorkspaceMemberRoleResponses,
|
||||
UpdateWorkspaceRequest,
|
||||
UpdateWorkspaceResponse,
|
||||
UpdateWorkspaceResponses,
|
||||
|
||||
1031
packages/ingest-types/src/types.gen.ts
generated
452
packages/ingest-types/src/zod.gen.ts
generated
@@ -465,6 +465,20 @@ export const zCreateWorkflowRequest = z.object({
|
||||
forked_from_workflow_version_id: z.string().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Request body for forwarding a comfy-api audit/history event. Identify the target workspace by either user_id (cloud resolves the user's personal workspace via the converged identity, BE-1047) or an explicit workspace_id. At least one must be provided; workspace_id wins when both are set.
|
||||
*/
|
||||
export const zHistoryEventRequest = z.object({
|
||||
user_id: z.string().optional(),
|
||||
workspace_id: z.string().optional(),
|
||||
event_type: z.string().min(1),
|
||||
event_id: z.string().min(1),
|
||||
params: z.record(z.unknown()).optional(),
|
||||
auth_method: z.enum(['api_key', 'bearer_token']).optional(),
|
||||
customer_ref: z.string().optional(),
|
||||
timestamp: z.string().datetime().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Response after recording partner usage data.
|
||||
*/
|
||||
@@ -540,11 +554,11 @@ export const zPaymentPortalRequest = z.object({
|
||||
})
|
||||
|
||||
/**
|
||||
* Response after successfully resubscribing to a billing plan.
|
||||
* Response after accepting a resubscribe request.
|
||||
*/
|
||||
export const zResubscribeResponse = z.object({
|
||||
billing_op_id: z.string(),
|
||||
status: z.enum(['active']),
|
||||
status: z.enum(['active', 'pending']),
|
||||
message: z.string().optional()
|
||||
})
|
||||
|
||||
@@ -585,6 +599,8 @@ export const zSubscribeResponse = z.object({
|
||||
*/
|
||||
export const zSubscribeRequest = z.object({
|
||||
plan_slug: z.string(),
|
||||
team_credit_stop_id: z.string().optional(),
|
||||
billing_cycle: z.enum(['monthly', 'yearly']).optional(),
|
||||
idempotency_key: z.string().optional(),
|
||||
return_url: z.string().optional(),
|
||||
cancel_url: z.string().optional()
|
||||
@@ -626,7 +642,8 @@ export const zSubscriptionTier = z.enum([
|
||||
'STANDARD',
|
||||
'CREATOR',
|
||||
'PRO',
|
||||
'FOUNDERS_EDITION'
|
||||
'FOUNDERS_EDITION',
|
||||
'TEAM'
|
||||
])
|
||||
|
||||
/**
|
||||
@@ -714,6 +731,57 @@ export const zPreviewSubscribeRequest = z.object({
|
||||
plan_slug: z.string()
|
||||
})
|
||||
|
||||
/**
|
||||
* Pre/post-discount price for a team credit stop, in cents.
|
||||
*/
|
||||
export const zTeamCreditStopPrice = z.object({
|
||||
list_price_cents: z.coerce
|
||||
.bigint()
|
||||
.min(BigInt('-9223372036854775808'), {
|
||||
message: 'Invalid value: Expected int64 to be >= -9223372036854775808'
|
||||
})
|
||||
.max(BigInt('9223372036854775807'), {
|
||||
message: 'Invalid value: Expected int64 to be <= 9223372036854775807'
|
||||
}),
|
||||
price_cents: z.coerce
|
||||
.bigint()
|
||||
.min(BigInt('-9223372036854775808'), {
|
||||
message: 'Invalid value: Expected int64 to be >= -9223372036854775808'
|
||||
})
|
||||
.max(BigInt('9223372036854775807'), {
|
||||
message: 'Invalid value: Expected int64 to be <= 9223372036854775807'
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* A selectable preset on the team pricing slider. Echoed on subscribe via
|
||||
* team_credit_stop_id; the backend owns the resolved amounts. credits is a
|
||||
* RAW monthly credit count (not cents). Save% is derived by the FE as
|
||||
* (list_price_cents - price_cents) / list_price_cents.
|
||||
*
|
||||
*/
|
||||
export const zTeamCreditStop = z.object({
|
||||
id: z.string(),
|
||||
credits: z.coerce
|
||||
.bigint()
|
||||
.min(BigInt('-9223372036854775808'), {
|
||||
message: 'Invalid value: Expected int64 to be >= -9223372036854775808'
|
||||
})
|
||||
.max(BigInt('9223372036854775807'), {
|
||||
message: 'Invalid value: Expected int64 to be <= 9223372036854775807'
|
||||
}),
|
||||
monthly: zTeamCreditStopPrice,
|
||||
yearly: zTeamCreditStopPrice
|
||||
})
|
||||
|
||||
/**
|
||||
* Credit-stop ladder for the pricing slider (BE-1254). Returned by GET /api/billing/plans for every workspace regardless of the caller's token or workspace type (the personal/team distinction was removed); omitted only when the catalog defines no stops.
|
||||
*/
|
||||
export const zTeamCreditStops = z.object({
|
||||
default_stop_index: z.number().int(),
|
||||
stops: z.array(zTeamCreditStop)
|
||||
})
|
||||
|
||||
/**
|
||||
* Reason why a plan is unavailable
|
||||
*/
|
||||
@@ -773,7 +841,50 @@ export const zPlan = z.object({
|
||||
*/
|
||||
export const zBillingPlansResponse = z.object({
|
||||
current_plan_slug: z.string().optional(),
|
||||
plans: z.array(zPlan)
|
||||
plans: z.array(zPlan),
|
||||
team_credit_stops: zTeamCreditStops.optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* The team credit stop a workspace is currently subscribed to: the
|
||||
* per-workspace slider choice recorded at subscribe time
|
||||
* (workspace_subscriptions.team_credit_stop_id). Amounts are owned by the
|
||||
* catalog, not the subscription row. Returned on GET /api/billing/status
|
||||
* for per-credit Team plans (BE-1254).
|
||||
*
|
||||
*/
|
||||
export const zTeamCreditStopSummary = z.object({
|
||||
id: z.string(),
|
||||
credits_monthly: z.coerce
|
||||
.bigint()
|
||||
.min(BigInt('-9223372036854775808'), {
|
||||
message: 'Invalid value: Expected int64 to be >= -9223372036854775808'
|
||||
})
|
||||
.max(BigInt('9223372036854775807'), {
|
||||
message: 'Invalid value: Expected int64 to be <= 9223372036854775807'
|
||||
}),
|
||||
stop_usd: z.coerce
|
||||
.bigint()
|
||||
.min(BigInt('-9223372036854775808'), {
|
||||
message: 'Invalid value: Expected int64 to be >= -9223372036854775808'
|
||||
})
|
||||
.max(BigInt('9223372036854775807'), {
|
||||
message: 'Invalid value: Expected int64 to be <= 9223372036854775807'
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* A provider the user may configure a secret for. The shape is deliberately minimal (identifier only) and reserved for future per-provider fields such as sub-keys.
|
||||
*/
|
||||
export const zSecretProvider = z.object({
|
||||
id: z.string()
|
||||
})
|
||||
|
||||
/**
|
||||
* The providers available to the authenticated user in the current workspace.
|
||||
*/
|
||||
export const zSecretProvidersResponse = z.object({
|
||||
data: z.array(zSecretProvider)
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -813,7 +924,7 @@ export const zCreateSecretRequest = z.object({
|
||||
})
|
||||
|
||||
/**
|
||||
* A single billing event such as a charge, credit, or adjustment.
|
||||
* A single history event. The cloud history-events store is the single source of truth for both billing events (charges, credits, adjustments) and user-facing usage events.
|
||||
*/
|
||||
export const zBillingEvent = z.object({
|
||||
event_type: z.string(),
|
||||
@@ -868,7 +979,8 @@ export const zBillingStatusResponse = z.object({
|
||||
billing_status: zBillingStatus.optional(),
|
||||
has_funds: z.boolean(),
|
||||
cancel_at: z.string().datetime().optional(),
|
||||
renewal_date: z.string().datetime().optional()
|
||||
renewal_date: z.string().datetime().optional(),
|
||||
team_credit_stop: zTeamCreditStopSummary.nullable()
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -930,6 +1042,7 @@ export const zOAuthConsentChallenge = z.object({
|
||||
csrf_token: z.string(),
|
||||
client_display_name: z.string(),
|
||||
resource_display_name: z.string(),
|
||||
redirect_uri: z.string().url(),
|
||||
scopes: z.array(z.string()),
|
||||
workspaces: z.array(zOAuthConsentChallengeWorkspace)
|
||||
})
|
||||
@@ -1056,6 +1169,66 @@ export const zSyncApiKeyRequest = z.object({
|
||||
customer_id: z.string().min(1)
|
||||
})
|
||||
|
||||
/**
|
||||
* The personal workspace's provisioned billing identity.
|
||||
*/
|
||||
export const zEnsureWorkspaceBillingProvisionedResponse = z.object({
|
||||
workspace_id: z.string(),
|
||||
stripe_customer_id: z.string(),
|
||||
metronome_customer_id: z.string(),
|
||||
metronome_contract_id: z.string()
|
||||
})
|
||||
|
||||
/**
|
||||
* The caller's already-resolved legacy (comfy-api) customer identity. When
|
||||
* present and carrying provider IDs, provisioning ATTACHES this identity to
|
||||
* the personal workspace (sharing the existing balance and subscription)
|
||||
* instead of minting a net-new empty customer. Omit (or send with no
|
||||
* provider IDs) for a free user with nothing to attach — provisioning then
|
||||
* creates net-new. This closes the create-new-before-attach gap: a caller
|
||||
* that already knows the legacy identity hands it over so the very first
|
||||
* provisioning is an attach.
|
||||
*
|
||||
*/
|
||||
export const zEnsureWorkspaceBillingLegacySnapshot = z.object({
|
||||
stripe_customer_id: z.string().optional(),
|
||||
metronome_customer_id: z.string().optional(),
|
||||
metronome_contract_id: z.string().optional(),
|
||||
has_funds: z.boolean().optional(),
|
||||
subscription_tier: z.string().optional(),
|
||||
legacy_stripe_subscription_id: z.string().optional(),
|
||||
legacy_comfy_user_id: z.string().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Request body for ensuring a user's personal workspace carries a fully
|
||||
* provisioned billing identity. Sent by comfy-api's CreateCustomer (BE-1047)
|
||||
* with the already canonical-resolved user identity.
|
||||
*
|
||||
*/
|
||||
export const zEnsureWorkspaceBillingProvisionedRequest = z.object({
|
||||
user_id: z.string().min(1),
|
||||
email: z.string().email().min(1),
|
||||
snapshot: zEnsureWorkspaceBillingLegacySnapshot.optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Firebase UIDs linked to the canonical comfy_user_id. Empty list when
|
||||
* no mappings exist (not an error — callers can treat empty as "unknown
|
||||
* canonical").
|
||||
*
|
||||
*/
|
||||
export const zListLinkedFirebaseUidsResponse = z.object({
|
||||
firebase_uids: z.array(z.string())
|
||||
})
|
||||
|
||||
/**
|
||||
* Request body for reverse-looking-up Firebase UIDs linked to a canonical comfy_user_id.
|
||||
*/
|
||||
export const zListLinkedFirebaseUidsRequest = z.object({
|
||||
comfy_user_id: z.string().min(1)
|
||||
})
|
||||
|
||||
/**
|
||||
* Response confirming the validity and scope of a workspace API key.
|
||||
*/
|
||||
@@ -1172,7 +1345,8 @@ export const zMember = z.object({
|
||||
name: z.string(),
|
||||
email: z.string().email(),
|
||||
role: z.enum(['owner', 'member']),
|
||||
joined_at: z.string().datetime()
|
||||
joined_at: z.string().datetime(),
|
||||
is_original_owner: z.boolean()
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -1183,6 +1357,13 @@ export const zListMembersResponse = z.object({
|
||||
pagination: zPaginationInfo
|
||||
})
|
||||
|
||||
/**
|
||||
* Request body for changing a workspace member's role.
|
||||
*/
|
||||
export const zUpdateMemberRoleRequest = z.object({
|
||||
role: z.enum(['owner', 'member'])
|
||||
})
|
||||
|
||||
/**
|
||||
* Request body for updating an existing workspace's settings.
|
||||
*/
|
||||
@@ -1227,6 +1408,60 @@ export const zWorkspace = z.object({
|
||||
created_at: z.string().datetime()
|
||||
})
|
||||
|
||||
/**
|
||||
* Exchange poll result. Pending until the code is redeemed in the browser.
|
||||
*/
|
||||
export const zDesktopLoginCodeExchangeResponse = z.object({
|
||||
status: z.enum(['pending', 'complete']),
|
||||
custom_token: z.string().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Request to exchange a redeemed login code for a custom token.
|
||||
*/
|
||||
export const zDesktopLoginCodeExchangeRequest = z.object({
|
||||
code: z.string(),
|
||||
code_verifier: z.string().min(43).max(128)
|
||||
})
|
||||
|
||||
/**
|
||||
* Result of redeeming a desktop login code.
|
||||
*/
|
||||
export const zDesktopLoginCodeRedeemResponse = z.object({
|
||||
status: z.enum(['redeemed'])
|
||||
})
|
||||
|
||||
/**
|
||||
* Request to claim a desktop login code for the authenticated user.
|
||||
*/
|
||||
export const zDesktopLoginCodeRedeemRequest = z.object({
|
||||
code: z.string()
|
||||
})
|
||||
|
||||
/**
|
||||
* A freshly minted desktop login code and its polling parameters.
|
||||
*/
|
||||
export const zDesktopLoginCodeCreateResponse = z.object({
|
||||
code: z.string(),
|
||||
expires_in: z.number().int(),
|
||||
poll_interval: z.number().int()
|
||||
})
|
||||
|
||||
/**
|
||||
* Request to mint a desktop login code.
|
||||
*/
|
||||
export const zDesktopLoginCodeCreateRequest = z.object({
|
||||
installation_id: z
|
||||
.string()
|
||||
.min(8)
|
||||
.max(128)
|
||||
.regex(/^[A-Za-z0-9._-]+$/)
|
||||
.optional(),
|
||||
platform: z.string().min(1).max(32),
|
||||
app_version: z.string().min(1).max(64),
|
||||
code_challenge: z.string().min(43).max(128)
|
||||
})
|
||||
|
||||
/**
|
||||
* Abbreviated workspace metadata used in list responses.
|
||||
*/
|
||||
@@ -1294,6 +1529,15 @@ export const zTasksListResponse = z.object({
|
||||
pagination: zPaginationInfo
|
||||
})
|
||||
|
||||
/**
|
||||
* Result of authorizing a legal-hold release on a user's deletion.
|
||||
*/
|
||||
export const zReleaseHoldResponse = z.object({
|
||||
firebase_id: z.string(),
|
||||
released: z.boolean(),
|
||||
message: z.string()
|
||||
})
|
||||
|
||||
/**
|
||||
* Current status of a user data deletion request.
|
||||
*/
|
||||
@@ -1363,6 +1607,20 @@ export const zJobDetailResponse = z.object({
|
||||
execution_meta: z.record(z.unknown()).optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Response for POST /api/jobs/cancel.
|
||||
*/
|
||||
export const zJobsCancelResponse = z.object({
|
||||
cancelled: z.array(z.string())
|
||||
})
|
||||
|
||||
/**
|
||||
* Request to cancel multiple jobs by ID.
|
||||
*/
|
||||
export const zJobsCancelRequest = z.object({
|
||||
job_ids: z.array(z.string().uuid()).min(1).max(100)
|
||||
})
|
||||
|
||||
/**
|
||||
* Response for POST /api/jobs/{job_id}/cancel. Returned on both fresh cancels and idempotent no-ops.
|
||||
*/
|
||||
@@ -1529,6 +1787,7 @@ export const zAsset = z.object({
|
||||
user_metadata: z.record(z.unknown()).optional(),
|
||||
metadata: z.record(z.unknown()).readonly().optional(),
|
||||
preview_url: z.string().url().optional(),
|
||||
short_url: z.string().nullish(),
|
||||
preview_id: z.string().uuid().nullish(),
|
||||
job_id: z.string().uuid().nullish(),
|
||||
created_at: z.string().datetime(),
|
||||
@@ -1624,6 +1883,7 @@ export const zSystemStatsResponse = z.object({
|
||||
python_version: z.string(),
|
||||
embedded_python: z.boolean(),
|
||||
comfyui_version: z.string(),
|
||||
deploy_environment: z.string().optional(),
|
||||
comfyui_frontend_version: z.string().optional(),
|
||||
workflow_templates_version: z.string().optional(),
|
||||
cloud_version: z.string().optional(),
|
||||
@@ -1962,6 +2222,7 @@ export const zAssetWritable = z.object({
|
||||
tags: z.array(z.string()).optional(),
|
||||
user_metadata: z.record(z.unknown()).optional(),
|
||||
preview_url: z.string().url().optional(),
|
||||
short_url: z.string().nullish(),
|
||||
preview_id: z.string().uuid().nullish(),
|
||||
job_id: z.string().uuid().nullish(),
|
||||
created_at: z.string().datetime(),
|
||||
@@ -2180,7 +2441,11 @@ export const zGetJobDetailData = z.object({
|
||||
path: z.object({
|
||||
job_id: z.string().uuid()
|
||||
}),
|
||||
query: z.never().optional()
|
||||
query: z
|
||||
.object({
|
||||
short_link: z.enum(['ephemeral_tool_chain', 'default']).optional()
|
||||
})
|
||||
.optional()
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -2201,6 +2466,17 @@ export const zCancelJobData = z.object({
|
||||
*/
|
||||
export const zCancelJobResponse = zJobCancelResponse
|
||||
|
||||
export const zCancelJobsData = z.object({
|
||||
body: zJobsCancelRequest,
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Success - cancel requests dispatched (or jobs were already terminal)
|
||||
*/
|
||||
export const zCancelJobsResponse = zJobsCancelResponse
|
||||
|
||||
export const zViewFileData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
@@ -2580,6 +2856,17 @@ export const zCreateSecretData = z.object({
|
||||
*/
|
||||
export const zCreateSecretResponse = zSecretResponse
|
||||
|
||||
export const zListSecretProvidersData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Success
|
||||
*/
|
||||
export const zListSecretProvidersResponse = zSecretProvidersResponse
|
||||
|
||||
export const zDeleteSecretData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.object({
|
||||
@@ -2881,6 +3168,40 @@ export const zExchangeTokenData = z.object({
|
||||
*/
|
||||
export const zExchangeTokenResponse2 = zExchangeTokenResponse
|
||||
|
||||
export const zCreateDesktopLoginCodeData = z.object({
|
||||
body: zDesktopLoginCodeCreateRequest,
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Login code created
|
||||
*/
|
||||
export const zCreateDesktopLoginCodeResponse = zDesktopLoginCodeCreateResponse
|
||||
|
||||
export const zRedeemDesktopLoginCodeData = z.object({
|
||||
body: zDesktopLoginCodeRedeemRequest,
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Code redeemed (or already redeemed by the same user)
|
||||
*/
|
||||
export const zRedeemDesktopLoginCodeResponse = zDesktopLoginCodeRedeemResponse
|
||||
|
||||
export const zExchangeDesktopLoginCodeData = z.object({
|
||||
body: zDesktopLoginCodeExchangeRequest,
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Pending (not yet redeemed) or complete with a custom token
|
||||
*/
|
||||
export const zExchangeDesktopLoginCodeResponse =
|
||||
zDesktopLoginCodeExchangeResponse
|
||||
|
||||
export const zGetJwksData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
@@ -3150,6 +3471,19 @@ export const zRemoveWorkspaceMemberData = z.object({
|
||||
*/
|
||||
export const zRemoveWorkspaceMemberResponse = z.void()
|
||||
|
||||
export const zUpdateWorkspaceMemberRoleData = z.object({
|
||||
body: zUpdateMemberRoleRequest,
|
||||
path: z.object({
|
||||
userId: z.string()
|
||||
}),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Member role updated
|
||||
*/
|
||||
export const zUpdateWorkspaceMemberRoleResponse = zMember
|
||||
|
||||
export const zListWorkspaceApiKeysData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
@@ -3236,6 +3570,19 @@ export const zSetReviewStatusData = z.object({
|
||||
*/
|
||||
export const zSetReviewStatusResponse2 = zSetReviewStatusResponse
|
||||
|
||||
export const zAdminDeleteHubWorkflowData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.object({
|
||||
share_id: z.string()
|
||||
}),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Successfully deleted
|
||||
*/
|
||||
export const zAdminDeleteHubWorkflowResponse = z.void()
|
||||
|
||||
export const zUpdateHubWorkflowData = z.object({
|
||||
body: zUpdateHubWorkflowRequest,
|
||||
path: z.object({
|
||||
@@ -3277,6 +3624,19 @@ export const zCreateDeletionRequestResponse = z.object({
|
||||
user_found_in_cloud: z.boolean()
|
||||
})
|
||||
|
||||
export const zReleaseDeletionHoldData = z.object({
|
||||
body: z.object({
|
||||
firebase_id: z.string()
|
||||
}),
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Release authorized; the deletion workflow will proceed
|
||||
*/
|
||||
export const zReleaseDeletionHoldResponse = zReleaseHoldResponse
|
||||
|
||||
export const zReportPartnerUsageData = z.object({
|
||||
body: zPartnerUsageRequest,
|
||||
path: z.never().optional(),
|
||||
@@ -3288,6 +3648,38 @@ export const zReportPartnerUsageData = z.object({
|
||||
*/
|
||||
export const zReportPartnerUsageResponse = zPartnerUsageResponse
|
||||
|
||||
export const zGetHistoryEventsData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
query: z
|
||||
.object({
|
||||
workspace_id: z.string().optional(),
|
||||
user_id: z.string().optional(),
|
||||
event_type: z.string().optional(),
|
||||
start_date: z.string().datetime().optional(),
|
||||
end_date: z.string().datetime().optional(),
|
||||
page: z.number().int().optional(),
|
||||
limit: z.number().int().optional()
|
||||
})
|
||||
.optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Paginated cloud history events for the workspace
|
||||
*/
|
||||
export const zGetHistoryEventsResponse = zBillingEventsResponse
|
||||
|
||||
export const zReportHistoryEventData = z.object({
|
||||
body: zHistoryEventRequest,
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* History event recorded successfully
|
||||
*/
|
||||
export const zReportHistoryEventResponse = zPartnerUsageResponse
|
||||
|
||||
export const zUpdateSubscriptionCacheData = z.object({
|
||||
body: z.object({
|
||||
user_id: z.string(),
|
||||
@@ -3305,6 +3697,29 @@ export const zUpdateSubscriptionCacheResponse = z.object({
|
||||
status: z.string().optional()
|
||||
})
|
||||
|
||||
export const zListLinkedFirebaseUidsData = z.object({
|
||||
body: zListLinkedFirebaseUidsRequest,
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Linked Firebase UIDs (possibly empty list)
|
||||
*/
|
||||
export const zListLinkedFirebaseUidsResponse2 = zListLinkedFirebaseUidsResponse
|
||||
|
||||
export const zEnsureWorkspaceBillingProvisionedData = z.object({
|
||||
body: zEnsureWorkspaceBillingProvisionedRequest,
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* The workspace's provisioned billing identity
|
||||
*/
|
||||
export const zEnsureWorkspaceBillingProvisionedResponse2 =
|
||||
zEnsureWorkspaceBillingProvisionedResponse
|
||||
|
||||
export const zInsertDynamicConfigData = z.object({
|
||||
body: z.record(z.unknown()),
|
||||
path: z.never().optional(),
|
||||
@@ -4010,6 +4425,14 @@ export const zGetModelPreviewData = z.object({
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
export const zShortLinkRedirectData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.object({
|
||||
id: z.string()
|
||||
}),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
export const zGetLegacyPromptByIdData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.object({
|
||||
@@ -4070,14 +4493,23 @@ export const zGetLegacyUserdataV2Data = z.object({
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
export const zGetLegacyAssetContentData = z.object({
|
||||
export const zGetAssetContentData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.object({
|
||||
id: z.string()
|
||||
}),
|
||||
query: z.never().optional()
|
||||
query: z
|
||||
.object({
|
||||
disposition: z.enum(['inline', 'attachment']).optional()
|
||||
})
|
||||
.optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Asset content stream (local runtime streams the bytes directly)
|
||||
*/
|
||||
export const zGetAssetContentResponse = z.string()
|
||||
|
||||
export const zGetLegacyViewMetadataData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.object({
|
||||
|
||||
@@ -4,5 +4,5 @@
|
||||
"rootDir": "src",
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src/**/*", "*.config.ts"]
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
|
||||
@@ -4,5 +4,5 @@
|
||||
"rootDir": "src",
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src/**/*", "vitest.config.ts"]
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
|
||||
@@ -35,10 +35,10 @@
|
||||
:class="
|
||||
sidebarLocation === 'left'
|
||||
? cn(
|
||||
'side-bar-panel pointer-events-auto bg-comfy-menu-bg',
|
||||
'side-bar-panel pointer-events-auto bg-comfy-menu-bg focus-visible:outline-hidden',
|
||||
sidebarPanelVisible && 'min-w-78'
|
||||
)
|
||||
: 'pointer-events-auto bg-comfy-menu-bg'
|
||||
: 'pointer-events-auto bg-comfy-menu-bg focus-visible:outline-hidden'
|
||||
"
|
||||
:min-size="
|
||||
sidebarLocation === 'left' ? SIDEBAR_MIN_SIZE : BUILDER_MIN_SIZE
|
||||
@@ -82,7 +82,7 @@
|
||||
</SplitterPanel>
|
||||
<SplitterPanel
|
||||
v-show="bottomPanelVisible && !focusMode"
|
||||
class="bottom-panel pointer-events-auto max-w-full overflow-x-auto rounded-lg border border-(--p-panel-border-color) bg-comfy-menu-bg"
|
||||
class="bottom-panel pointer-events-auto max-w-full overflow-x-auto rounded-lg border border-(--p-panel-border-color) bg-comfy-menu-bg focus-visible:outline-hidden"
|
||||
>
|
||||
<slot name="bottom-panel" />
|
||||
</SplitterPanel>
|
||||
@@ -95,10 +95,10 @@
|
||||
:class="
|
||||
sidebarLocation === 'right'
|
||||
? cn(
|
||||
'side-bar-panel pointer-events-auto bg-comfy-menu-bg',
|
||||
'side-bar-panel pointer-events-auto bg-comfy-menu-bg focus-visible:outline-hidden',
|
||||
sidebarPanelVisible && 'min-w-78'
|
||||
)
|
||||
: 'pointer-events-auto bg-comfy-menu-bg'
|
||||
: 'pointer-events-auto bg-comfy-menu-bg focus-visible:outline-hidden'
|
||||
"
|
||||
:min-size="
|
||||
sidebarLocation === 'right' ? SIDEBAR_MIN_SIZE : BUILDER_MIN_SIZE
|
||||
|
||||
@@ -7,7 +7,7 @@ import Password from 'primevue/password'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import ProgressSpinner from 'primevue/progressspinner'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent, h, nextTick, ref } from 'vue'
|
||||
import { computed, defineComponent, h, nextTick, ref } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import enMessages from '@/locales/en/main.json' with { type: 'json' }
|
||||
@@ -38,29 +38,45 @@ vi.mock('@/stores/authStore', () => ({
|
||||
}))
|
||||
|
||||
const mockTurnstileEnabled = ref(false)
|
||||
const mockTurnstileEnforced = ref(false)
|
||||
const mockTurnstileToken = ref('')
|
||||
const mockTurnstileUnavailable = ref(false)
|
||||
const mockReset = vi.fn()
|
||||
let emitTurnstileToken: ((token: string) => void) | undefined
|
||||
let emitTurnstileUnavailable: ((unavailable: boolean) => void) | undefined
|
||||
|
||||
// The reset-on-toggle behavior lives in useTurnstileGate itself (see
|
||||
// useTurnstile.test.ts); this fake just wires token/unavailable through to
|
||||
// `waiting` the same way so SignUpForm's submit gating can be exercised.
|
||||
vi.mock('@/composables/auth/useTurnstile', () => ({
|
||||
useTurnstile: () => ({
|
||||
enabled: mockTurnstileEnabled,
|
||||
enforced: mockTurnstileEnforced
|
||||
enabled: mockTurnstileEnabled
|
||||
}),
|
||||
useTurnstileGate: () => ({
|
||||
token: mockTurnstileToken,
|
||||
unavailable: mockTurnstileUnavailable,
|
||||
waiting: computed(
|
||||
() =>
|
||||
mockTurnstileEnabled.value &&
|
||||
!mockTurnstileToken.value &&
|
||||
!mockTurnstileUnavailable.value
|
||||
)
|
||||
})
|
||||
}))
|
||||
|
||||
// Stub the real widget (which loads the external Turnstile script) with one that
|
||||
// exposes a spyable reset() and lets a test drive the v-model token the way a
|
||||
// solved challenge would.
|
||||
// exposes a spyable reset() and lets a test drive the v-model token/unavailable
|
||||
// the way a solved challenge (or a broken/slow widget) would.
|
||||
vi.mock('./TurnstileWidget.vue', async () => {
|
||||
const { defineComponent: defineMock } = await import('vue')
|
||||
return {
|
||||
default: defineMock({
|
||||
name: 'TurnstileWidget',
|
||||
emits: ['update:token'],
|
||||
emits: ['update:token', 'update:unavailable'],
|
||||
setup(_, { expose, emit }) {
|
||||
expose({ reset: mockReset })
|
||||
emitTurnstileToken = (token: string) => emit('update:token', token)
|
||||
emitTurnstileUnavailable = (unavailable: boolean) =>
|
||||
emit('update:unavailable', unavailable)
|
||||
return () => null
|
||||
}
|
||||
})
|
||||
@@ -92,9 +108,11 @@ describe('SignUpForm', () => {
|
||||
beforeEach(() => {
|
||||
mockLoadingRef.value = false
|
||||
mockTurnstileEnabled.value = false
|
||||
mockTurnstileEnforced.value = false
|
||||
mockTurnstileToken.value = ''
|
||||
mockTurnstileUnavailable.value = false
|
||||
mockReset.mockClear()
|
||||
emitTurnstileToken = undefined
|
||||
emitTurnstileUnavailable = undefined
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@@ -211,43 +229,22 @@ describe('SignUpForm', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('Turnstile token hygiene', () => {
|
||||
it('clears the stale token when Turnstile becomes disabled', async () => {
|
||||
mockTurnstileEnabled.value = true
|
||||
mockTurnstileEnforced.value = true
|
||||
const { user } = renderComponent()
|
||||
await fillValidSignup(user)
|
||||
|
||||
emitTurnstileToken!('stale-token')
|
||||
await nextTick()
|
||||
expect(
|
||||
screen.getByRole('button', { name: signUpButton })
|
||||
).not.toBeDisabled()
|
||||
|
||||
mockTurnstileEnabled.value = false
|
||||
await nextTick()
|
||||
|
||||
// re-enable: the stale token must have been cleared so submit is blocked again
|
||||
mockTurnstileEnabled.value = true
|
||||
await nextTick()
|
||||
|
||||
expect(screen.getByRole('button', { name: signUpButton })).toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
// Regression coverage for the shadow-mode race: previously submit was only
|
||||
// gated in 'enforce' mode, so most real signups in 'shadow' mode raced
|
||||
// ahead of the async Cloudflare challenge and reached the backend with an
|
||||
// empty token. Gating now depends only on whether the widget is enabled
|
||||
// (shadow or enforce both render it), so both modes behave identically here.
|
||||
describe('Turnstile submit gating', () => {
|
||||
it('disables the submit button in enforce mode until a token is present', async () => {
|
||||
it('disables the submit button until a token is present', async () => {
|
||||
mockTurnstileEnabled.value = true
|
||||
mockTurnstileEnforced.value = true
|
||||
renderComponent()
|
||||
await nextTick()
|
||||
|
||||
expect(screen.getByRole('button', { name: signUpButton })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('does not emit submit in enforce mode while the token is empty', async () => {
|
||||
it('does not emit submit while the token is empty', async () => {
|
||||
mockTurnstileEnabled.value = true
|
||||
mockTurnstileEnforced.value = true
|
||||
const onSubmit = vi.fn()
|
||||
const { user } = renderComponent({ onSubmit })
|
||||
await fillValidSignup(user)
|
||||
@@ -257,9 +254,8 @@ describe('SignUpForm', () => {
|
||||
expect(onSubmit).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('emits submit with the token in enforce mode once the challenge is solved', async () => {
|
||||
it('emits submit with the token once the challenge is solved', async () => {
|
||||
mockTurnstileEnabled.value = true
|
||||
mockTurnstileEnforced.value = true
|
||||
const onSubmit = vi.fn()
|
||||
const { user } = renderComponent({ onSubmit })
|
||||
await fillValidSignup(user)
|
||||
@@ -271,13 +267,14 @@ describe('SignUpForm', () => {
|
||||
expect(onSubmit).toHaveBeenCalledWith(expectedValues, 'token-xyz')
|
||||
})
|
||||
|
||||
it('emits submit without a token in shadow mode (never blocks)', async () => {
|
||||
it('emits submit without a token once the widget reports itself unavailable (broken/slow load fallback)', async () => {
|
||||
mockTurnstileEnabled.value = true
|
||||
mockTurnstileEnforced.value = false
|
||||
const onSubmit = vi.fn()
|
||||
const { user } = renderComponent({ onSubmit })
|
||||
await fillValidSignup(user)
|
||||
|
||||
emitTurnstileUnavailable!(true)
|
||||
await nextTick()
|
||||
await user.click(screen.getByRole('button', { name: signUpButton }))
|
||||
|
||||
expect(onSubmit).toHaveBeenCalledWith(expectedValues, undefined)
|
||||
|
||||
@@ -33,10 +33,11 @@
|
||||
v-if="turnstileEnabled"
|
||||
ref="turnstileWidget"
|
||||
v-model:token="turnstileToken"
|
||||
v-model:unavailable="turnstileUnavailable"
|
||||
/>
|
||||
|
||||
<small
|
||||
v-show="submitBlockedByTurnstile"
|
||||
v-show="waitingForTurnstile"
|
||||
id="comfy-org-sign-up-turnstile-hint"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
@@ -51,11 +52,9 @@
|
||||
v-else
|
||||
type="submit"
|
||||
class="mt-4 h-10 font-medium"
|
||||
:disabled="!$form.valid || submitBlockedByTurnstile"
|
||||
:disabled="!$form.valid || waitingForTurnstile"
|
||||
:aria-describedby="
|
||||
submitBlockedByTurnstile
|
||||
? 'comfy-org-sign-up-turnstile-hint'
|
||||
: undefined
|
||||
waitingForTurnstile ? 'comfy-org-sign-up-turnstile-hint' : undefined
|
||||
"
|
||||
>
|
||||
{{ t('auth.signup.signUpButton') }}
|
||||
@@ -70,11 +69,11 @@ import { zodResolver } from '@primevue/forms/resolvers/zod'
|
||||
import { useThrottleFn } from '@vueuse/core'
|
||||
import InputText from 'primevue/inputtext'
|
||||
import ProgressSpinner from 'primevue/progressspinner'
|
||||
import { computed, ref, useTemplateRef, watch } from 'vue'
|
||||
import { computed, useTemplateRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useTurnstile } from '@/composables/auth/useTurnstile'
|
||||
import { useTurnstile, useTurnstileGate } from '@/composables/auth/useTurnstile'
|
||||
import { signUpSchema } from '@/schemas/signInSchema'
|
||||
import type { SignUpData } from '@/schemas/signInSchema'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
@@ -86,25 +85,21 @@ const { t } = useI18n()
|
||||
const authStore = useAuthStore()
|
||||
const loading = computed(() => authStore.loading)
|
||||
|
||||
const { enabled: turnstileEnabled, enforced: turnstileEnforced } =
|
||||
useTurnstile()
|
||||
const turnstileToken = ref('')
|
||||
const { enabled: turnstileEnabled } = useTurnstile()
|
||||
const {
|
||||
token: turnstileToken,
|
||||
unavailable: turnstileUnavailable,
|
||||
waiting: waitingForTurnstile
|
||||
} = useTurnstileGate(turnstileEnabled)
|
||||
const turnstileWidget =
|
||||
useTemplateRef<InstanceType<typeof TurnstileWidget>>('turnstileWidget')
|
||||
const submitBlockedByTurnstile = computed(
|
||||
() => turnstileEnforced.value && !turnstileToken.value
|
||||
)
|
||||
|
||||
watch(turnstileEnabled, (on) => {
|
||||
if (!on) turnstileToken.value = ''
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
submit: [values: SignUpData, turnstileToken?: string]
|
||||
}>()
|
||||
|
||||
const onSubmit = useThrottleFn((event: FormSubmitEvent) => {
|
||||
if (event.valid && !submitBlockedByTurnstile.value) {
|
||||
if (event.valid && !waitingForTurnstile.value) {
|
||||
emit(
|
||||
'submit',
|
||||
event.values as SignUpData,
|
||||
|
||||
@@ -261,4 +261,138 @@ describe('TurnstileWidget', () => {
|
||||
|
||||
expect(api.remove).toHaveBeenCalledWith('widget-id')
|
||||
})
|
||||
|
||||
// A widget that never resolves (broken script, ad-blocker, CDN outage, or a
|
||||
// hung challenge) must eventually tell the parent it cannot be relied on,
|
||||
// so submission can fall back instead of blocking a legitimate signup
|
||||
// forever.
|
||||
describe('unavailable fallback', () => {
|
||||
it('reports unavailable when the Turnstile script fails to load', async () => {
|
||||
mockLoadTurnstile.mockRejectedValue(new Error('script failed'))
|
||||
|
||||
const { emitted } = renderWidget()
|
||||
await flush()
|
||||
|
||||
expect(emitted()['update:unavailable']?.at(-1)).toEqual([true])
|
||||
})
|
||||
|
||||
it('reports unavailable on a challenge error', async () => {
|
||||
const { api, options } = fakeTurnstile()
|
||||
mockLoadTurnstile.mockResolvedValue(api)
|
||||
|
||||
const { emitted } = renderWidget()
|
||||
await flush()
|
||||
|
||||
options()!['error-callback']!()
|
||||
await flush()
|
||||
|
||||
expect(emitted()['update:unavailable']?.at(-1)).toEqual([true])
|
||||
})
|
||||
|
||||
it('clears the unavailable fallback once a token is solved', async () => {
|
||||
const { api, options } = fakeTurnstile()
|
||||
mockLoadTurnstile.mockResolvedValue(api)
|
||||
|
||||
const { emitted } = renderWidget()
|
||||
await flush()
|
||||
|
||||
options()!['error-callback']!()
|
||||
await flush()
|
||||
expect(emitted()['update:unavailable']?.at(-1)).toEqual([true])
|
||||
|
||||
options()!.callback!('token-abc')
|
||||
await flush()
|
||||
|
||||
expect(emitted()['update:unavailable']?.at(-1)).toEqual([false])
|
||||
})
|
||||
|
||||
it('falls back once the widget fails to resolve within the load timeout', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const { api, options } = fakeTurnstile()
|
||||
mockLoadTurnstile.mockResolvedValue(api)
|
||||
|
||||
const { emitted } = renderWidget()
|
||||
// Let the onMounted hook's `await loadTurnstile()` microtask settle
|
||||
// and render() run, without yet advancing to the timeout itself.
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(options()).toBeDefined()
|
||||
expect(emitted()['update:unavailable']).toBeUndefined()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(9_000)
|
||||
|
||||
expect(emitted()['update:unavailable']?.at(-1)).toEqual([true])
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not fall back once a token arrives before the load timeout', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const { api, options } = fakeTurnstile()
|
||||
mockLoadTurnstile.mockResolvedValue(api)
|
||||
|
||||
const { emitted } = renderWidget()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
options()!.callback!('token-abc')
|
||||
await vi.advanceTimersByTimeAsync(9_000)
|
||||
|
||||
expect(emitted()['update:unavailable']).toBeUndefined()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('resets the widget to fetch a fresh challenge on token expiry', async () => {
|
||||
const { api, options } = fakeTurnstile()
|
||||
mockLoadTurnstile.mockResolvedValue(api)
|
||||
window.turnstile = api as unknown as NonNullable<Window['turnstile']>
|
||||
|
||||
renderWidget()
|
||||
await flush()
|
||||
|
||||
options()!.callback!('token-abc')
|
||||
options()!['expired-callback']!()
|
||||
await flush()
|
||||
|
||||
expect(api.reset).toHaveBeenCalledWith('widget-id')
|
||||
})
|
||||
|
||||
it('falls back if a post-solve expiry is not followed by a fresh token within the load timeout', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const { api, options } = fakeTurnstile()
|
||||
mockLoadTurnstile.mockResolvedValue(api)
|
||||
window.turnstile = api as unknown as NonNullable<Window['turnstile']>
|
||||
|
||||
const { emitted } = renderWidget()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
// Establish a solved, available widget: an initial error marks it
|
||||
// unavailable, then solving a challenge clears that (the same
|
||||
// transition the existing "clears the unavailable fallback" test
|
||||
// verifies), so the expiry below is the only thing driving fallback.
|
||||
options()!['error-callback']!()
|
||||
options()!.callback!('token-abc')
|
||||
expect(emitted()['update:unavailable']?.at(-1)).toEqual([false])
|
||||
|
||||
// The token later expires (e.g. tab backgrounded past its ~300s
|
||||
// lifetime) without the widget itself erroring.
|
||||
options()!['expired-callback']!()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
// A fresh challenge was requested, but nothing solves it before the
|
||||
// re-armed load timeout elapses, so submission must eventually be
|
||||
// unblocked rather than staying stuck forever.
|
||||
expect(emitted()['update:unavailable']?.at(-1)).toEqual([false])
|
||||
await vi.advanceTimersByTimeAsync(9_000)
|
||||
|
||||
expect(emitted()['update:unavailable']?.at(-1)).toEqual([true])
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useTimeoutFn } from '@vueuse/core'
|
||||
import { onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
@@ -20,6 +21,14 @@ import { getTurnstileSiteKey } from '@/config/turnstile'
|
||||
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
|
||||
|
||||
const token = defineModel<string>('token', { default: '' })
|
||||
/**
|
||||
* Set true whenever the widget cannot be relied on to ever produce a token:
|
||||
* the Cloudflare script failed to load, the rendered challenge errored out,
|
||||
* or it simply hasn't resolved within `TURNSTILE_LOAD_TIMEOUT_MS`. The parent
|
||||
* uses this to stop waiting on a token so a broken/slow widget (network
|
||||
* issue, ad-blocker, CDN outage) can never permanently block signup.
|
||||
*/
|
||||
const unavailable = defineModel<boolean>('unavailable', { default: false })
|
||||
|
||||
const { t } = useI18n()
|
||||
const colorPaletteStore = useColorPaletteStore()
|
||||
@@ -28,6 +37,16 @@ const containerRef = ref<HTMLDivElement>()
|
||||
const errorMessage = ref('')
|
||||
let widgetId: string | undefined
|
||||
|
||||
/** How long to wait for the widget to resolve before falling back. */
|
||||
const TURNSTILE_LOAD_TIMEOUT_MS = 9_000
|
||||
const { start: armTimeout, stop: clearLoadTimeout } = useTimeoutFn(
|
||||
() => {
|
||||
unavailable.value = true
|
||||
},
|
||||
TURNSTILE_LOAD_TIMEOUT_MS,
|
||||
{ immediate: false }
|
||||
)
|
||||
|
||||
const clearToken = () => {
|
||||
token.value = ''
|
||||
}
|
||||
@@ -46,12 +65,18 @@ const reset = () => {
|
||||
errorMessage.value = ''
|
||||
if (widgetId && window.turnstile) {
|
||||
window.turnstile.reset(widgetId)
|
||||
// A widget that renders can request a fresh challenge, so give it
|
||||
// another chance before falling back again.
|
||||
unavailable.value = false
|
||||
armTimeout()
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ reset })
|
||||
|
||||
onMounted(async () => {
|
||||
armTimeout()
|
||||
|
||||
try {
|
||||
const turnstile = await loadTurnstile()
|
||||
if (!containerRef.value) return
|
||||
@@ -64,23 +89,37 @@ onMounted(async () => {
|
||||
sitekey: getTurnstileSiteKey(),
|
||||
theme,
|
||||
callback: (newToken: string) => {
|
||||
clearLoadTimeout()
|
||||
errorMessage.value = ''
|
||||
unavailable.value = false
|
||||
token.value = newToken
|
||||
},
|
||||
'expired-callback': () => {
|
||||
clearToken()
|
||||
errorMessage.value = t('auth.turnstile.expired')
|
||||
if (widgetId && window.turnstile) {
|
||||
window.turnstile.reset(widgetId)
|
||||
// A solved token can expire on its own (e.g. the tab was
|
||||
// backgrounded past the token's ~300s lifetime) without the widget
|
||||
// ever erroring, so proactively request a fresh challenge and
|
||||
// re-arm the load timeout in case it doesn't resolve in time.
|
||||
armTimeout()
|
||||
}
|
||||
},
|
||||
'error-callback': () => {
|
||||
clearToken()
|
||||
clearLoadTimeout()
|
||||
console.warn('Turnstile challenge failed')
|
||||
errorMessage.value = t('auth.turnstile.failed')
|
||||
unavailable.value = true
|
||||
if (widgetId && window.turnstile) window.turnstile.reset(widgetId)
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
clearLoadTimeout()
|
||||
console.warn('Turnstile failed to load', error)
|
||||
errorMessage.value = t('auth.turnstile.failed')
|
||||
unavailable.value = true
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick, ref } from 'vue'
|
||||
|
||||
import {
|
||||
isTurnstileEnabled,
|
||||
normalizeTurnstileMode,
|
||||
useTurnstile
|
||||
useTurnstile,
|
||||
useTurnstileGate
|
||||
} from '@/composables/auth/useTurnstile'
|
||||
import { getTurnstileSiteKey } from '@/config/turnstile'
|
||||
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
|
||||
@@ -137,3 +139,63 @@ describe('useTurnstile', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('useTurnstileGate', () => {
|
||||
it('waits while enabled with no token yet', () => {
|
||||
const { waiting } = useTurnstileGate(ref(true))
|
||||
expect(waiting.value).toBe(true)
|
||||
})
|
||||
|
||||
it('never waits while disabled', () => {
|
||||
const { waiting } = useTurnstileGate(ref(false))
|
||||
expect(waiting.value).toBe(false)
|
||||
})
|
||||
|
||||
it('stops waiting once a token arrives', () => {
|
||||
const { token, waiting } = useTurnstileGate(ref(true))
|
||||
|
||||
token.value = 'token-abc'
|
||||
|
||||
expect(waiting.value).toBe(false)
|
||||
})
|
||||
|
||||
it('stops waiting once the widget reports itself unavailable', () => {
|
||||
const { unavailable, waiting } = useTurnstileGate(ref(true))
|
||||
|
||||
unavailable.value = true
|
||||
|
||||
expect(waiting.value).toBe(false)
|
||||
})
|
||||
|
||||
it('clears stale token/unavailable state when enabled turns off', async () => {
|
||||
const enabled = ref(true)
|
||||
const { token, unavailable } = useTurnstileGate(enabled)
|
||||
token.value = 'stale-token'
|
||||
unavailable.value = true
|
||||
|
||||
enabled.value = false
|
||||
await nextTick()
|
||||
|
||||
expect(token.value).toBe('')
|
||||
expect(unavailable.value).toBe(false)
|
||||
})
|
||||
|
||||
// Regression coverage: the reset used to only run on the enabled->disabled
|
||||
// transition, so state written while the widget was briefly disabled could
|
||||
// survive into the next enabled widget instance.
|
||||
it('clears stale token/unavailable state when enabled turns back on', async () => {
|
||||
const enabled = ref(true)
|
||||
const { token, unavailable } = useTurnstileGate(enabled)
|
||||
|
||||
enabled.value = false
|
||||
await nextTick()
|
||||
token.value = 'stale-token'
|
||||
unavailable.value = true
|
||||
|
||||
enabled.value = true
|
||||
await nextTick()
|
||||
|
||||
expect(token.value).toBe('')
|
||||
expect(unavailable.value).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { computed } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
import { getTurnstileSiteKey } from '@/config/turnstile'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
@@ -42,3 +43,31 @@ export function useTurnstile() {
|
||||
|
||||
return { mode, siteKey, enabled, enforced }
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit-gating state for the signup form's Turnstile widget: a token/
|
||||
* unavailable pair, plus `waiting`, which is true while a real token is still
|
||||
* needed. Waits in both shadow and enforce mode (`enabled`), not just
|
||||
* `enforced`, so shadow mode's token can't race the async Cloudflare
|
||||
* challenge; falls back open once the widget reports `unavailable` so a
|
||||
* broken/slow load can never permanently block signup.
|
||||
*
|
||||
* `token`/`unavailable` reset on every `enabled` transition, in either
|
||||
* direction, so state from a previous widget instance can never leak into a
|
||||
* freshly (re-)rendered one.
|
||||
*/
|
||||
export function useTurnstileGate(enabled: Ref<boolean>) {
|
||||
const token = ref('')
|
||||
const unavailable = ref(false)
|
||||
|
||||
const waiting = computed(
|
||||
() => enabled.value && !token.value && !unavailable.value
|
||||
)
|
||||
|
||||
watch(enabled, () => {
|
||||
token.value = ''
|
||||
unavailable.value = false
|
||||
})
|
||||
|
||||
return { token, unavailable, waiting }
|
||||
}
|
||||
|
||||
@@ -110,17 +110,18 @@
|
||||
>
|
||||
<span class="flex items-center gap-1 text-text-primary">
|
||||
{{ $t('subscription.additionalCredits') }}
|
||||
<button
|
||||
<Button
|
||||
v-tooltip="{
|
||||
value: $t('subscription.additionalCreditsTooltip'),
|
||||
showDelay: 300
|
||||
}"
|
||||
type="button"
|
||||
variant="muted-textonly"
|
||||
size="icon-sm"
|
||||
:aria-label="$t('subscription.additionalCreditsInfo')"
|
||||
class="flex items-center text-muted"
|
||||
class="text-muted"
|
||||
>
|
||||
<i class="icon-[lucide--info] size-4" />
|
||||
</button>
|
||||
</Button>
|
||||
<span
|
||||
v-if="isSpendingAdditional"
|
||||
class="flex h-3.5 items-center rounded-full bg-base-foreground px-1 text-2xs/none font-semibold text-base-background uppercase"
|
||||
|
||||
@@ -152,8 +152,12 @@ describe('resolveMissingMediaAssetSources', () => {
|
||||
|
||||
it('stops reading cloud output asset pages once all requested names are found', async () => {
|
||||
const target = 'target-output.png'
|
||||
const outputAsset = makeAsset('ComfyUI_00001_.png', target)
|
||||
mockGetAssetsPageByTag.mockResolvedValueOnce(
|
||||
makeAssetPage([makeAsset(target)], { hasMore: true, total: 501 })
|
||||
makeAssetPage([outputAsset], {
|
||||
hasMore: true,
|
||||
total: 501
|
||||
})
|
||||
)
|
||||
|
||||
const result = await resolveMissingMediaAssetSources({
|
||||
@@ -163,10 +167,34 @@ describe('resolveMissingMediaAssetSources', () => {
|
||||
allowCompactSuffix: true
|
||||
})
|
||||
|
||||
expect(result.generatedAssets).toEqual([makeAsset(target)])
|
||||
expect(result.generatedAssets).toEqual([outputAsset])
|
||||
expect(mockGetAssetsPageByTag).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('does not stop cloud output asset paging on a flat asset name collision', async () => {
|
||||
const target = 'target-output.mp4'
|
||||
const collidingNameAsset = makeAsset(target)
|
||||
const matchingHashAsset = makeAsset('ComfyUI_00001_.mp4', target)
|
||||
mockGetAssetsPageByTag
|
||||
.mockResolvedValueOnce(
|
||||
makeAssetPage([collidingNameAsset], { hasMore: true, total: 501 })
|
||||
)
|
||||
.mockResolvedValueOnce(makeAssetPage([matchingHashAsset]))
|
||||
|
||||
const result = await resolveMissingMediaAssetSources({
|
||||
isCloud: true,
|
||||
includeGeneratedAssets: true,
|
||||
generatedMatchNames: new Set([target]),
|
||||
allowCompactSuffix: true
|
||||
})
|
||||
|
||||
expect(result.generatedAssets).toEqual([
|
||||
collidingNameAsset,
|
||||
matchingHashAsset
|
||||
])
|
||||
expect(mockGetAssetsPageByTag).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('aborts cloud output asset loading when input asset loading fails', async () => {
|
||||
const inputError = new Error('input failed')
|
||||
let rejectInputAssets!: (err: Error) => void
|
||||
|
||||
@@ -140,7 +140,7 @@ async function fetchCloudGeneratedAssets(
|
||||
|
||||
for (const asset of batch) {
|
||||
assets.push(asset)
|
||||
rememberResolvedTargetNames(
|
||||
rememberResolvedCloudTargetNames(
|
||||
asset,
|
||||
targetNames,
|
||||
foundTargetNames,
|
||||
@@ -262,6 +262,19 @@ function rememberResolvedTargetNames(
|
||||
}
|
||||
}
|
||||
|
||||
function rememberResolvedCloudTargetNames(
|
||||
asset: AssetItem,
|
||||
targetNames: ReadonlySet<string>,
|
||||
foundTargetNames: Set<string>,
|
||||
options: MediaPathDetectionOptions
|
||||
) {
|
||||
if (targetNames.size === 0 || !asset.hash) return
|
||||
|
||||
for (const name of getMediaPathDetectionNames(asset.hash, options)) {
|
||||
if (targetNames.has(name)) foundTargetNames.add(name)
|
||||
}
|
||||
}
|
||||
|
||||
function hasResolvedAllTargetNames(
|
||||
targetNames: ReadonlySet<string>,
|
||||
foundTargetNames: ReadonlySet<string>
|
||||
|
||||
@@ -660,6 +660,87 @@ describe('verifyMediaCandidates', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('matches cloud output videos with history subfolders against flat asset hashes', async () => {
|
||||
const outputHash = 'cloud-video-hash.mp4'
|
||||
const candidates = [
|
||||
makeCandidate('1', `video/${outputHash} [output]`, {
|
||||
nodeType: 'LoadVideo',
|
||||
widgetName: 'file',
|
||||
mediaType: 'video',
|
||||
isMissing: undefined
|
||||
})
|
||||
]
|
||||
const resolveAssetSources = makeAssetResolver(
|
||||
[],
|
||||
[makeAsset('ComfyUI_00001_.mp4', outputHash)]
|
||||
)
|
||||
|
||||
await verifyMediaCandidates(candidates, {
|
||||
isCloud: true,
|
||||
resolveAssetSources
|
||||
})
|
||||
|
||||
expect(resolveAssetSources).toHaveBeenCalledWith({
|
||||
signal: undefined,
|
||||
isCloud: true,
|
||||
includeGeneratedAssets: true,
|
||||
generatedMatchNames: new Set([outputHash]),
|
||||
allowCompactSuffix: true
|
||||
})
|
||||
expect(candidates[0]).toMatchObject({
|
||||
name: `video/${outputHash} [output]`,
|
||||
isMissing: false
|
||||
})
|
||||
})
|
||||
|
||||
it('does not match subfoldered cloud output media against unrelated flat asset names', async () => {
|
||||
const outputHash = 'cloud-video-hash.mp4'
|
||||
const candidates = [
|
||||
makeCandidate('1', `video/${outputHash} [output]`, {
|
||||
nodeType: 'LoadVideo',
|
||||
widgetName: 'file',
|
||||
mediaType: 'video',
|
||||
isMissing: undefined
|
||||
})
|
||||
]
|
||||
const resolveAssetSources = makeAssetResolver([], [makeAsset(outputHash)])
|
||||
|
||||
await verifyMediaCandidates(candidates, {
|
||||
isCloud: true,
|
||||
resolveAssetSources
|
||||
})
|
||||
|
||||
expect(candidates[0]).toMatchObject({
|
||||
name: `video/${outputHash} [output]`,
|
||||
isMissing: true
|
||||
})
|
||||
})
|
||||
|
||||
it('stops cloud output paging after a subfoldered candidate matches a flat asset hash', async () => {
|
||||
const outputHash = 'cloud-video-hash.mp4'
|
||||
const candidates = [
|
||||
makeCandidate('1', `video/${outputHash} [output]`, {
|
||||
nodeType: 'LoadVideo',
|
||||
widgetName: 'file',
|
||||
mediaType: 'video',
|
||||
isMissing: undefined
|
||||
})
|
||||
]
|
||||
mockGetAssetsPageByTag.mockResolvedValueOnce(
|
||||
makeAssetPage([makeAsset('ComfyUI_00001_.mp4', outputHash)], {
|
||||
hasMore: true
|
||||
})
|
||||
)
|
||||
|
||||
await verifyMediaCandidates(candidates, { isCloud: true })
|
||||
|
||||
expect(mockGetAssetsPageByTag).toHaveBeenCalledOnce()
|
||||
expect(candidates[0]).toMatchObject({
|
||||
name: `video/${outputHash} [output]`,
|
||||
isMissing: false
|
||||
})
|
||||
})
|
||||
|
||||
it('does not satisfy output annotations with input assets of the same name', async () => {
|
||||
const candidates = [
|
||||
makeCandidate('1', 'photo.png [output]', { isMissing: undefined })
|
||||
|
||||
@@ -165,6 +165,7 @@ export async function verifyMediaCandidates(
|
||||
const pathOptions = { allowCompactSuffix: isCloud }
|
||||
const generatedMatchNames = getGeneratedCandidateMatchNames(
|
||||
pending,
|
||||
isCloud,
|
||||
pathOptions
|
||||
)
|
||||
|
||||
@@ -189,37 +190,47 @@ export async function verifyMediaCandidates(
|
||||
|
||||
const inputAssetIdentifiers = new Set<string>()
|
||||
const outputAssetIdentifiers = new Set<string>()
|
||||
const outputAssetHashIdentifiers = new Set<string>()
|
||||
addAssetIdentifiers(inputAssetIdentifiers, inputAssets, pathOptions)
|
||||
addAssetIdentifiers(outputAssetIdentifiers, generatedAssets, pathOptions)
|
||||
addAssetHashIdentifiers(
|
||||
outputAssetHashIdentifiers,
|
||||
generatedAssets,
|
||||
pathOptions
|
||||
)
|
||||
|
||||
for (const candidate of pending) {
|
||||
const detectionNames = getMediaPathDetectionNames(
|
||||
candidate.name,
|
||||
pathOptions
|
||||
)
|
||||
const type = getAnnotatedMediaPathTypeForDetection(
|
||||
candidate.name,
|
||||
pathOptions
|
||||
)
|
||||
const identifiers =
|
||||
type === 'output' ? outputAssetIdentifiers : inputAssetIdentifiers
|
||||
candidate.isMissing = !detectionNames.some((name) => identifiers.has(name))
|
||||
const isOutputCandidate = type === 'output'
|
||||
const identifiers = isOutputCandidate
|
||||
? outputAssetIdentifiers
|
||||
: inputAssetIdentifiers
|
||||
candidate.isMissing = !isCandidateResolved(
|
||||
candidate,
|
||||
identifiers,
|
||||
isOutputCandidate,
|
||||
isCloud,
|
||||
outputAssetHashIdentifiers,
|
||||
pathOptions
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function getGeneratedCandidateMatchNames(
|
||||
candidates: MissingMediaCandidate[],
|
||||
isCloud: boolean,
|
||||
pathOptions: { allowCompactSuffix: boolean }
|
||||
): Set<string> {
|
||||
const names = new Set<string>()
|
||||
for (const candidate of candidates) {
|
||||
if (!isGeneratedCandidate(candidate, pathOptions)) continue
|
||||
|
||||
names.add(
|
||||
normalizeAnnotatedMediaPathForDetection(candidate.name, pathOptions)
|
||||
)
|
||||
}
|
||||
return names
|
||||
return new Set(
|
||||
candidates
|
||||
.filter((candidate) => isGeneratedCandidate(candidate, pathOptions))
|
||||
.map((candidate) =>
|
||||
getGeneratedCandidateLookupName(candidate.name, isCloud, pathOptions)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
function isGeneratedCandidate(
|
||||
@@ -233,6 +244,43 @@ function isGeneratedCandidate(
|
||||
return type === 'output'
|
||||
}
|
||||
|
||||
function getGeneratedCandidateLookupName(
|
||||
value: string,
|
||||
isCloud: boolean,
|
||||
pathOptions: { allowCompactSuffix: boolean }
|
||||
): string {
|
||||
const normalized = normalizeAnnotatedMediaPathForDetection(value, pathOptions)
|
||||
return isCloud ? getMediaPathBasename(normalized) : normalized
|
||||
}
|
||||
|
||||
function isCandidateResolved(
|
||||
candidate: MissingMediaCandidate,
|
||||
identifiers: ReadonlySet<string>,
|
||||
isOutputCandidate: boolean,
|
||||
isCloud: boolean,
|
||||
outputAssetHashIdentifiers: ReadonlySet<string>,
|
||||
pathOptions: { allowCompactSuffix: boolean }
|
||||
): boolean {
|
||||
const detectionNames = getMediaPathDetectionNames(candidate.name, pathOptions)
|
||||
if (detectionNames.some((name) => identifiers.has(name))) return true
|
||||
if (!isOutputCandidate || !isCloud) return false
|
||||
|
||||
const normalized = normalizeAnnotatedMediaPathForDetection(
|
||||
candidate.name,
|
||||
pathOptions
|
||||
)
|
||||
const basename = getMediaPathBasename(normalized)
|
||||
return basename !== normalized && outputAssetHashIdentifiers.has(basename)
|
||||
}
|
||||
|
||||
function getMediaPathBasename(value: string): string {
|
||||
const separatorIndex = Math.max(
|
||||
value.lastIndexOf('/'),
|
||||
value.lastIndexOf('\\')
|
||||
)
|
||||
return separatorIndex === -1 ? value : value.slice(separatorIndex + 1)
|
||||
}
|
||||
|
||||
function addAssetIdentifiers(
|
||||
identifiers: Set<string>,
|
||||
assets: AssetItem[],
|
||||
@@ -245,6 +293,19 @@ function addAssetIdentifiers(
|
||||
}
|
||||
}
|
||||
|
||||
function addAssetHashIdentifiers(
|
||||
identifiers: Set<string>,
|
||||
assets: AssetItem[],
|
||||
pathOptions: { allowCompactSuffix: boolean }
|
||||
) {
|
||||
for (const asset of assets) {
|
||||
if (!asset.hash) continue
|
||||
for (const name of getMediaPathDetectionNames(asset.hash, pathOptions)) {
|
||||
identifiers.add(name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Group confirmed-missing candidates by file name into view models. */
|
||||
export function groupCandidatesByName(
|
||||
candidates: MissingMediaCandidate[]
|
||||
|
||||
@@ -30,6 +30,90 @@ describe('preservedQueryManager', () => {
|
||||
expect(sessionStorage.getItem('Comfy.PreservedQuery.template')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('merges newly captured keys into the payload when merge is set', () => {
|
||||
capturePreservedQuery(
|
||||
NAMESPACE,
|
||||
{ template: 'flux' },
|
||||
['template', 'source', 'mode'],
|
||||
{ merge: true }
|
||||
)
|
||||
|
||||
capturePreservedQuery(
|
||||
NAMESPACE,
|
||||
{ source: 'custom' },
|
||||
['template', 'source', 'mode'],
|
||||
{ merge: true }
|
||||
)
|
||||
|
||||
const merged = mergePreservedQueryIntoQuery(NAMESPACE)
|
||||
expect(merged).toEqual({ template: 'flux', source: 'custom' })
|
||||
})
|
||||
|
||||
it('replaces the whole payload on capture by default', () => {
|
||||
capturePreservedQuery(NAMESPACE, { template: 'flux', source: 'custom' }, [
|
||||
'template',
|
||||
'source',
|
||||
'mode'
|
||||
])
|
||||
|
||||
capturePreservedQuery(NAMESPACE, { template: 'sdxl' }, [
|
||||
'template',
|
||||
'source',
|
||||
'mode'
|
||||
])
|
||||
|
||||
expect(mergePreservedQueryIntoQuery(NAMESPACE)).toEqual({
|
||||
template: 'sdxl'
|
||||
})
|
||||
})
|
||||
|
||||
it('leaves the payload untouched when a default capture has no valid values', () => {
|
||||
capturePreservedQuery(NAMESPACE, { template: 'flux' }, ['template'])
|
||||
|
||||
capturePreservedQuery(NAMESPACE, { template: '' }, ['template'])
|
||||
|
||||
expect(getPreservedQueryParam(NAMESPACE, 'template')).toBe('flux')
|
||||
})
|
||||
|
||||
it('captures the first non-empty string element of an array-valued param', () => {
|
||||
capturePreservedQuery(NAMESPACE, { template: ['', 'flux', 'sdxl'] }, [
|
||||
'template'
|
||||
])
|
||||
|
||||
expect(getPreservedQueryParam(NAMESPACE, 'template')).toBe('flux')
|
||||
})
|
||||
|
||||
it('does not stash empty, null, or all-junk array values', () => {
|
||||
capturePreservedQuery(
|
||||
NAMESPACE,
|
||||
{ template: '', source: null, mode: ['', null] },
|
||||
['template', 'source', 'mode']
|
||||
)
|
||||
|
||||
expect(getPreservedQueryParam(NAMESPACE, 'template')).toBeUndefined()
|
||||
expect(getPreservedQueryParam(NAMESPACE, 'source')).toBeUndefined()
|
||||
expect(getPreservedQueryParam(NAMESPACE, 'mode')).toBeUndefined()
|
||||
expect(mergePreservedQueryIntoQuery(NAMESPACE)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('removes a preserved key on empty value when merge is set', () => {
|
||||
capturePreservedQuery(
|
||||
NAMESPACE,
|
||||
{ template: 'flux', source: 'custom' },
|
||||
['template', 'source'],
|
||||
{ merge: true }
|
||||
)
|
||||
|
||||
capturePreservedQuery(NAMESPACE, { template: '' }, ['template', 'source'], {
|
||||
merge: true
|
||||
})
|
||||
|
||||
expect(getPreservedQueryParam(NAMESPACE, 'template')).toBeUndefined()
|
||||
expect(mergePreservedQueryIntoQuery(NAMESPACE)).toEqual({
|
||||
source: 'custom'
|
||||
})
|
||||
})
|
||||
|
||||
it('reads a preserved query param by key', () => {
|
||||
capturePreservedQuery(NAMESPACE, { template: 'flux' }, ['template'])
|
||||
|
||||
@@ -78,6 +162,16 @@ describe('preservedQueryManager', () => {
|
||||
expect(merged).toBeUndefined()
|
||||
})
|
||||
|
||||
it('overwrites an array-valued live query key with the stashed string', () => {
|
||||
capturePreservedQuery(NAMESPACE, { template: 'flux' }, ['template'])
|
||||
|
||||
const merged = mergePreservedQueryIntoQuery(NAMESPACE, {
|
||||
template: ['existing', 'other']
|
||||
})
|
||||
|
||||
expect(merged).toEqual({ template: 'flux' })
|
||||
})
|
||||
|
||||
it('clears cached payload', () => {
|
||||
capturePreservedQuery(NAMESPACE, { template: 'flux' }, ['template'])
|
||||
|
||||
|
||||
@@ -4,7 +4,12 @@ const STORAGE_PREFIX = 'Comfy.PreservedQuery.'
|
||||
const preservedQueries = new Map<string, Record<string, string>>()
|
||||
|
||||
const readQueryParam = (value: unknown): string | undefined => {
|
||||
return typeof value === 'string' ? value : undefined
|
||||
if (typeof value === 'string') return value
|
||||
if (!Array.isArray(value)) return undefined
|
||||
return value.find(
|
||||
(entry: unknown): entry is string =>
|
||||
typeof entry === 'string' && entry !== ''
|
||||
)
|
||||
}
|
||||
|
||||
const getStorageKey = (namespace: string) => `${STORAGE_PREFIX}${namespace}`
|
||||
@@ -65,25 +70,65 @@ export const hydratePreservedQuery = (namespace: string) => {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* By default each capture replaces the namespace stash with the values present
|
||||
* in the given query. With `merge`, values are merged into the existing stash
|
||||
* and a key supplied with an empty value clears its stashed entry — for
|
||||
* namespaces where the stash, not the URL, is the surviving carrier.
|
||||
*/
|
||||
export const capturePreservedQuery = (
|
||||
namespace: string,
|
||||
query: LocationQuery,
|
||||
keys: string[]
|
||||
keys: string[],
|
||||
{ merge = false }: { merge?: boolean } = {}
|
||||
) => {
|
||||
const payload: Record<string, string> = {}
|
||||
|
||||
keys.forEach((key) => {
|
||||
const value = readQueryParam(query[key])
|
||||
if (value) {
|
||||
payload[key] = value
|
||||
if (!merge) {
|
||||
const payload: Record<string, string> = {}
|
||||
keys.forEach((key) => {
|
||||
const value = readQueryParam(query[key])
|
||||
if (value) {
|
||||
payload[key] = value
|
||||
}
|
||||
})
|
||||
if (Object.keys(payload).length === 0) {
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
if (Object.keys(payload).length === 0) {
|
||||
preservedQueries.set(namespace, payload)
|
||||
writeToStorage(namespace, payload)
|
||||
return
|
||||
}
|
||||
|
||||
preservedQueries.set(namespace, payload)
|
||||
hydratePreservedQuery(namespace)
|
||||
const payload: Record<string, string> = {
|
||||
...(preservedQueries.get(namespace) ?? {})
|
||||
}
|
||||
let changed = false
|
||||
|
||||
keys.forEach((key) => {
|
||||
if (!Object.hasOwn(query, key)) return
|
||||
|
||||
const value = readQueryParam(query[key])
|
||||
if (value) {
|
||||
payload[key] = value
|
||||
changed = true
|
||||
return
|
||||
}
|
||||
|
||||
if (key in payload) {
|
||||
delete payload[key]
|
||||
changed = true
|
||||
}
|
||||
})
|
||||
|
||||
if (!changed) {
|
||||
return
|
||||
}
|
||||
|
||||
if (Object.keys(payload).length === 0) {
|
||||
preservedQueries.delete(namespace)
|
||||
} else {
|
||||
preservedQueries.set(namespace, payload)
|
||||
}
|
||||
writeToStorage(namespace, payload)
|
||||
}
|
||||
|
||||
|
||||
196
src/platform/navigation/preservedQueryTracker.test.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Router, RouterHistory } from 'vue-router'
|
||||
import { createMemoryHistory, createRouter } from 'vue-router'
|
||||
|
||||
import {
|
||||
clearPreservedQuery,
|
||||
getPreservedQueryParam
|
||||
} from '@/platform/navigation/preservedQueryManager'
|
||||
import { installPreservedQueryTracker } from '@/platform/navigation/preservedQueryTracker'
|
||||
|
||||
const STRIPPED_NAMESPACE = 'test_strip'
|
||||
const SECOND_STRIPPED_NAMESPACE = 'test_strip_b'
|
||||
const PLAIN_NAMESPACE = 'test_plain'
|
||||
|
||||
const strippedDefinition = {
|
||||
namespace: STRIPPED_NAMESPACE,
|
||||
keys: ['one_time_code'],
|
||||
stripAfterCapture: true
|
||||
}
|
||||
|
||||
const plainDefinition = {
|
||||
namespace: PLAIN_NAMESPACE,
|
||||
keys: ['plain_code', 'plain_source']
|
||||
}
|
||||
|
||||
function createTestRouter(
|
||||
history: RouterHistory = createMemoryHistory()
|
||||
): Router {
|
||||
return createRouter({
|
||||
history,
|
||||
routes: [{ path: '/:pathMatch(.*)*', component: { template: '<div />' } }]
|
||||
})
|
||||
}
|
||||
|
||||
describe('installPreservedQueryTracker', () => {
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear()
|
||||
clearPreservedQuery(STRIPPED_NAMESPACE)
|
||||
clearPreservedQuery(SECOND_STRIPPED_NAMESPACE)
|
||||
clearPreservedQuery(PLAIN_NAMESPACE)
|
||||
})
|
||||
|
||||
it('strips marked keys from the URL while preserving other query and hash', async () => {
|
||||
const router = createTestRouter()
|
||||
installPreservedQueryTracker(router, [strippedDefinition])
|
||||
|
||||
await router.push('/?one_time_code=otc_abc123&keep=a+b#frag')
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe('/?keep=a+b#frag')
|
||||
expect(getPreservedQueryParam(STRIPPED_NAMESPACE, 'one_time_code')).toBe(
|
||||
'otc_abc123'
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps params of non-strip namespaces in the URL and still captures them', async () => {
|
||||
const router = createTestRouter()
|
||||
installPreservedQueryTracker(router, [plainDefinition])
|
||||
|
||||
await router.push('/?plain_code=alpha&plain_source=beta')
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe(
|
||||
'/?plain_code=alpha&plain_source=beta'
|
||||
)
|
||||
expect(getPreservedQueryParam(PLAIN_NAMESPACE, 'plain_code')).toBe('alpha')
|
||||
expect(getPreservedQueryParam(PLAIN_NAMESPACE, 'plain_source')).toBe('beta')
|
||||
})
|
||||
|
||||
it('replaces a non-strip namespace stash on later captures', async () => {
|
||||
const router = createTestRouter()
|
||||
installPreservedQueryTracker(router, [plainDefinition])
|
||||
|
||||
await router.push('/?plain_code=alpha&plain_source=beta')
|
||||
await router.push('/?plain_code=gamma')
|
||||
|
||||
expect(getPreservedQueryParam(PLAIN_NAMESPACE, 'plain_code')).toBe('gamma')
|
||||
expect(
|
||||
getPreservedQueryParam(PLAIN_NAMESPACE, 'plain_source')
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
it('navigates exactly once when no strip-marked keys are present', async () => {
|
||||
const router = createTestRouter()
|
||||
installPreservedQueryTracker(router, [strippedDefinition])
|
||||
let completedNavigations = 0
|
||||
router.afterEach(() => {
|
||||
completedNavigations++
|
||||
})
|
||||
|
||||
await router.push('/?keep=1')
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe('/?keep=1')
|
||||
expect(completedNavigations).toBe(1)
|
||||
})
|
||||
|
||||
it('scrubs empty and null values from the URL without stashing them', async () => {
|
||||
const router = createTestRouter()
|
||||
installPreservedQueryTracker(router, [strippedDefinition])
|
||||
|
||||
await router.push('/?one_time_code=')
|
||||
expect(router.currentRoute.value.fullPath).toBe('/')
|
||||
expect(
|
||||
getPreservedQueryParam(STRIPPED_NAMESPACE, 'one_time_code')
|
||||
).toBeUndefined()
|
||||
|
||||
await router.push('/?one_time_code')
|
||||
expect(router.currentRoute.value.fullPath).toBe('/')
|
||||
expect(
|
||||
getPreservedQueryParam(STRIPPED_NAMESPACE, 'one_time_code')
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
it('clears a stale stripped value when the URL supplies an empty value', async () => {
|
||||
const router = createTestRouter()
|
||||
installPreservedQueryTracker(router, [strippedDefinition])
|
||||
|
||||
await router.push('/?one_time_code=otc_abc123')
|
||||
expect(getPreservedQueryParam(STRIPPED_NAMESPACE, 'one_time_code')).toBe(
|
||||
'otc_abc123'
|
||||
)
|
||||
|
||||
await router.push('/?one_time_code=')
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe('/')
|
||||
expect(
|
||||
getPreservedQueryParam(STRIPPED_NAMESPACE, 'one_time_code')
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
it('stashes the first value of a repeated param and cleans the URL', async () => {
|
||||
const router = createTestRouter()
|
||||
installPreservedQueryTracker(router, [strippedDefinition])
|
||||
|
||||
await router.push('/?one_time_code=otc_A&one_time_code=otc_B')
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe('/')
|
||||
expect(getPreservedQueryParam(STRIPPED_NAMESPACE, 'one_time_code')).toBe(
|
||||
'otc_A'
|
||||
)
|
||||
})
|
||||
|
||||
it('strips keys of multiple marked namespaces in a single redirect', async () => {
|
||||
const router = createTestRouter()
|
||||
let navigationAttempts = 0
|
||||
router.beforeEach((_to, _from, next) => {
|
||||
navigationAttempts++
|
||||
next()
|
||||
})
|
||||
installPreservedQueryTracker(router, [
|
||||
strippedDefinition,
|
||||
{
|
||||
namespace: SECOND_STRIPPED_NAMESPACE,
|
||||
keys: ['second_code'],
|
||||
stripAfterCapture: true
|
||||
}
|
||||
])
|
||||
|
||||
await router.push('/?one_time_code=otc_x&second_code=sc_y&keep=1')
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe('/?keep=1')
|
||||
expect(navigationAttempts).toBe(2)
|
||||
expect(getPreservedQueryParam(STRIPPED_NAMESPACE, 'one_time_code')).toBe(
|
||||
'otc_x'
|
||||
)
|
||||
expect(
|
||||
getPreservedQueryParam(SECOND_STRIPPED_NAMESPACE, 'second_code')
|
||||
).toBe('sc_y')
|
||||
})
|
||||
|
||||
it('keeps the prior history entry reachable after the strip redirect', async () => {
|
||||
const router = createTestRouter()
|
||||
installPreservedQueryTracker(router, [strippedDefinition])
|
||||
|
||||
await router.push('/start')
|
||||
await router.push('/?one_time_code=otc_abc123')
|
||||
expect(router.currentRoute.value.fullPath).toBe('/')
|
||||
|
||||
router.go(-1)
|
||||
await vi.waitFor(() =>
|
||||
expect(router.currentRoute.value.fullPath).toBe('/start')
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps replace navigation from adding a back target', async () => {
|
||||
const history = createMemoryHistory()
|
||||
const router = createTestRouter(history)
|
||||
installPreservedQueryTracker(router, [strippedDefinition])
|
||||
|
||||
await router.push('/start')
|
||||
await router.replace('/?one_time_code=otc_abc123')
|
||||
expect(router.currentRoute.value.fullPath).toBe('/')
|
||||
|
||||
router.go(-1)
|
||||
|
||||
expect(history.location).toBe('/')
|
||||
})
|
||||
})
|
||||
@@ -5,25 +5,48 @@ import {
|
||||
hydratePreservedQuery
|
||||
} from '@/platform/navigation/preservedQueryManager'
|
||||
|
||||
interface PreservedQueryDefinition {
|
||||
namespace: string
|
||||
keys: string[]
|
||||
/**
|
||||
* When set, keys present in the query are removed from the client-side URL
|
||||
* before navigation completes. Later guards, afterEach hooks, and views must
|
||||
* read a strip-marked key from the preserved-query stash instead of
|
||||
* route.query or fullPath. Because the stash is the only carrier after
|
||||
* stripping, captures for the namespace merge into the existing stash and an
|
||||
* explicitly empty value clears the stashed key; non-strip namespaces keep
|
||||
* replace-on-capture semantics.
|
||||
*/
|
||||
stripAfterCapture?: boolean
|
||||
}
|
||||
|
||||
export const installPreservedQueryTracker = (
|
||||
router: Router,
|
||||
definitions: Array<{ namespace: string; keys: string[] }>
|
||||
definitions: PreservedQueryDefinition[]
|
||||
) => {
|
||||
const trackedDefinitions = definitions.map((definition) => ({
|
||||
...definition
|
||||
}))
|
||||
|
||||
router.beforeEach((to, _from, next) => {
|
||||
const queryKeys = new Set(Object.keys(to.query))
|
||||
const keysToStrip = new Set<string>()
|
||||
|
||||
trackedDefinitions.forEach(({ namespace, keys }) => {
|
||||
definitions.forEach(({ namespace, keys, stripAfterCapture }) => {
|
||||
hydratePreservedQuery(namespace)
|
||||
const shouldCapture = keys.some((key) => queryKeys.has(key))
|
||||
if (shouldCapture) {
|
||||
capturePreservedQuery(namespace, to.query, keys)
|
||||
const presentKeys = keys.filter((key) => queryKeys.has(key))
|
||||
if (presentKeys.length === 0) return
|
||||
capturePreservedQuery(namespace, to.query, keys, {
|
||||
merge: stripAfterCapture
|
||||
})
|
||||
if (stripAfterCapture) {
|
||||
presentKeys.forEach((key) => keysToStrip.add(key))
|
||||
}
|
||||
})
|
||||
|
||||
next()
|
||||
if (keysToStrip.size === 0) {
|
||||
next()
|
||||
return
|
||||
}
|
||||
|
||||
const cleanedQuery = { ...to.query }
|
||||
keysToStrip.forEach((key) => delete cleanedQuery[key])
|
||||
next({ path: to.path, query: cleanedQuery, hash: to.hash })
|
||||
})
|
||||
}
|
||||
|
||||
@@ -195,6 +195,46 @@ describe('PostHogTelemetryProvider', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('platform axes (client / deployment)', () => {
|
||||
afterEach(() => {
|
||||
delete window.__comfyDesktop2
|
||||
})
|
||||
|
||||
it('registers client=web and deployment=cloud in a plain browser', async () => {
|
||||
createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
expect(hoisted.mockRegister).toHaveBeenCalledWith({
|
||||
client: 'web',
|
||||
deployment: 'cloud'
|
||||
})
|
||||
})
|
||||
|
||||
it('registers client=desktop when the desktop preload bridge is present', async () => {
|
||||
window.__comfyDesktop2 = {
|
||||
isRemote: () => false,
|
||||
Telemetry: { capture: vi.fn() }
|
||||
}
|
||||
createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
expect(hoisted.mockRegister).toHaveBeenCalledWith({
|
||||
client: 'desktop',
|
||||
deployment: 'cloud'
|
||||
})
|
||||
})
|
||||
|
||||
it('registers platform axes before flushing pre-init queued events', async () => {
|
||||
const provider = createProvider()
|
||||
provider.trackSignupOpened()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
const registerOrder = hoisted.mockRegister.mock.invocationCallOrder[0]
|
||||
const captureOrder = hoisted.mockCapture.mock.invocationCallOrder[0]
|
||||
expect(registerOrder).toBeLessThan(captureOrder)
|
||||
})
|
||||
})
|
||||
|
||||
describe('desktop entry capture', () => {
|
||||
function setLocation(search: string): void {
|
||||
Object.defineProperty(window.location, 'search', {
|
||||
@@ -208,12 +248,20 @@ describe('PostHogTelemetryProvider', () => {
|
||||
setLocation('')
|
||||
})
|
||||
|
||||
// The platform-axes register (client/deployment) always fires, so these
|
||||
// assert no register call carrying desktop-entry attribution props.
|
||||
function desktopEntryRegisterCalls(): unknown[][] {
|
||||
return hoisted.mockRegister.mock.calls.filter(
|
||||
([props]) => props && 'source_app' in (props as Record<string, unknown>)
|
||||
)
|
||||
}
|
||||
|
||||
it('does not register desktop props when utm_source is absent', async () => {
|
||||
setLocation('')
|
||||
createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
expect(hoisted.mockRegister).not.toHaveBeenCalled()
|
||||
expect(desktopEntryRegisterCalls()).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('does not register desktop props when utm_source is not comfy.desktop', async () => {
|
||||
@@ -221,7 +269,7 @@ describe('PostHogTelemetryProvider', () => {
|
||||
createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
expect(hoisted.mockRegister).not.toHaveBeenCalled()
|
||||
expect(desktopEntryRegisterCalls()).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('registers source_app and desktop_device_id when arriving from desktop', async () => {
|
||||
|
||||
@@ -143,6 +143,9 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
|
||||
before_send: createPostHogBeforeSend()
|
||||
})
|
||||
this.isInitialized = true
|
||||
// Before flushEventQueue so pre-init events also carry the
|
||||
// platform super properties.
|
||||
this.registerPlatformProps()
|
||||
this.flushEventQueue()
|
||||
this.registerDesktopEntryProps()
|
||||
|
||||
@@ -285,6 +288,18 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
|
||||
)
|
||||
}
|
||||
|
||||
private registerPlatformProps(): void {
|
||||
if (!this.posthog) return
|
||||
try {
|
||||
this.posthog.register({
|
||||
client: window.__comfyDesktop2 ? 'desktop' : 'web',
|
||||
deployment: 'cloud'
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to register platform props:', error)
|
||||
}
|
||||
}
|
||||
|
||||
private registerDesktopEntryProps(): void {
|
||||
if (!this.posthog) return
|
||||
const props = readDesktopEntryProps()
|
||||
|
||||
@@ -530,8 +530,6 @@ export function useSlotLinkInteraction({
|
||||
|
||||
raf.flush()
|
||||
|
||||
raf.flush()
|
||||
|
||||
if (!state.source) {
|
||||
cleanupInteraction()
|
||||
app.canvas?.setDirty(true, true)
|
||||
@@ -579,24 +577,18 @@ export function useSlotLinkInteraction({
|
||||
const graph = app.canvas?.graph ?? null
|
||||
const context = { adapter, graph, session: dragContext }
|
||||
|
||||
const attemptSnapped = () => tryConnectToCandidate(snappedCandidate)
|
||||
|
||||
const domSlotCandidate = resolveSlotTargetCandidate(target, context)
|
||||
const attemptDomSlot = () => tryConnectToCandidate(domSlotCandidate)
|
||||
|
||||
const nodeSurfaceSlotCandidate = resolveNodeSurfaceSlotCandidate(
|
||||
target,
|
||||
context
|
||||
)
|
||||
const attemptNodeSurface = () =>
|
||||
tryConnectToCandidate(nodeSurfaceSlotCandidate)
|
||||
const attemptReroute = () => tryConnectViaRerouteAtPointer()
|
||||
|
||||
if (attemptSnapped()) return true
|
||||
if (attemptDomSlot()) return true
|
||||
if (attemptNodeSurface()) return true
|
||||
if (attemptReroute()) return true
|
||||
return false
|
||||
return (
|
||||
tryConnectToCandidate(snappedCandidate) ||
|
||||
tryConnectToCandidate(domSlotCandidate) ||
|
||||
tryConnectToCandidate(nodeSurfaceSlotCandidate) ||
|
||||
tryConnectViaRerouteAtPointer()
|
||||
)
|
||||
}
|
||||
|
||||
const onPointerDown = (event: PointerEvent) => {
|
||||
|
||||