mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-19 02:06:38 +00:00
Compare commits
1 Commits
codex/rum-
...
nathaniel/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cfb1be400f |
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/fallow-rs/fallow/main/schema.json",
|
||||
"entry": ["src/main.ts", "index.html"],
|
||||
"duplicates": {
|
||||
"minOccurrences": 3,
|
||||
"ignore": ["**/*.generated.*", "**/generatedManagerTypes.ts"]
|
||||
},
|
||||
"rules": {}
|
||||
}
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -16,7 +16,6 @@ yarn.lock
|
||||
.eslintcache
|
||||
.prettiercache
|
||||
.stylelintcache
|
||||
.fallow/
|
||||
|
||||
node_modules
|
||||
.pnpm-store
|
||||
|
||||
@@ -88,11 +88,6 @@ const config: StorybookConfig = {
|
||||
replacement:
|
||||
process.cwd() + '/src/storybook/mocks/useFeatureFlags.ts'
|
||||
},
|
||||
{
|
||||
find: '@/platform/workspace/composables/useWorkspaceUI',
|
||||
replacement:
|
||||
process.cwd() + '/src/storybook/mocks/useWorkspaceUI.ts'
|
||||
},
|
||||
{
|
||||
find: '@/platform/workspace/stores/teamWorkspaceStore',
|
||||
replacement:
|
||||
|
||||
730
GIT_BRANCHING_STRATEGY.md
Normal file
730
GIT_BRANCHING_STRATEGY.md
Normal file
@@ -0,0 +1,730 @@
|
||||
# Git Branching and Release Strategy
|
||||
|
||||
Status: Proposed
|
||||
Scope: ComfyUI_frontend branching, release management, and environment promotion
|
||||
Audience: frontend engineers, release rotation, QA, cloud and core release stakeholders
|
||||
|
||||
This document does three things:
|
||||
|
||||
1. Maps the current branching and release process and its measured pain points.
|
||||
2. Reviews a draft proposal to replace it with a 4-tier environment-branch model
|
||||
(testing, dev, staging, main), including an honest scorecard and a
|
||||
keep-or-reject disposition for every element of that proposal.
|
||||
3. Specifies the recommended strategy, how it addresses each pain point, and a
|
||||
phased rollout plan with risks and open questions.
|
||||
|
||||
## 1. Executive summary
|
||||
|
||||
The current process is not "only main". The repo runs one eternal development
|
||||
branch plus 55 frozen release branches (38 core lines, core/1.6 through
|
||||
core/1.47 with gaps; 17 cloud lines, cloud/1.31 through cloud/1.47), a
|
||||
label-driven cherry-pick backport pipeline,
|
||||
and a biweekly promotion train into ComfyUI core. The pain is real but it is
|
||||
not caused by a missing environment hierarchy. It is caused by the distance
|
||||
between main and the shipped lines: the longer a pinned release line lives,
|
||||
the more cherry-picks it needs, the harder each one gets, and the bigger and
|
||||
riskier each release batch becomes.
|
||||
|
||||
The draft 4-tier proposal is reviewed in section 4. Verdict: do not adopt as
|
||||
written (overall 4/10 for this repo), because it models a single-track web
|
||||
application while this product permanently ships three concurrent version
|
||||
tracks, and its central promise (eliminating backports) is unachievable while
|
||||
ComfyUI core pins an exact frontend package version. Eight of its underlying
|
||||
instincts are correct and are adopted into the recommendation.
|
||||
|
||||
The recommended strategy (section 6) is: one eternal branch (main), short-lived
|
||||
version branches only where a pinned version demands them (core/x.y), pipeline
|
||||
promotion of build-once artifacts for cloud environments instead of environment
|
||||
branches, hard freeze-as-code and drift limits, invariant checks instead of
|
||||
notification bots, and a machine-maintained production pointer that gives git
|
||||
visibility into what is deployed without human merge ceremony. This direction
|
||||
aligns with and extends the org's existing shipping-speed initiative rather
|
||||
than relitigating it.
|
||||
|
||||
## 2. Current topology: three concurrent tracks
|
||||
|
||||
Any branching strategy for this repo must first model what actually ships.
|
||||
There is no single production. Three tracks run concurrently, each with its own
|
||||
consumers, artifact, and patch path:
|
||||
|
||||
| Track | Consumers | Artifact | Deploys via | Patch path today |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Nightly | Community users running `--front-end-version @latest` (thousands) | GitHub release built from main | Nightly version-bump PR, tag, release | Fix merges to main, ships next nightly |
|
||||
| Cloud | cloud.comfy.org users | Static assets built per commit SHA into GCS by the cloud repo | testcloud tracks the active `cloud/x.y` tip; staging and prod promote a SHA pointer via overlays and ArgoCD | Cherry-pick to `cloud/x.y` via backport label, then staged deploy |
|
||||
| Core GA / desktop | ComfyUI stable and desktop installs | `comfyui-frontend-package` wheel on PyPI, pinned exactly in ComfyUI `requirements.txt` | Biweekly train: patch bump on `core/x.y`, PyPI publish, pin-bump PR on ComfyUI | Cherry-pick to `core/x.y`, patch release, new pin PR |
|
||||
|
||||
As of mid July 2026: main is at 1.48.x, cloud runs the 1.47 line, and the
|
||||
ComfyUI pin is still on 1.45.x. Three minors of distance between main and core
|
||||
GA is the normal operating state, not an anomaly.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
FIX[Fix merges to main] --> NB[Nightly bump and tag] --> NU[Nightly users]
|
||||
FIX --> CLB[Cherry-pick onto cloud line] --> STG[Staging deploy] --> PRD[Prod deploy] --> CU[Cloud users]
|
||||
FIX --> COB[Cherry-pick onto core line] --> PYP[PyPI patch release] --> PIN[ComfyUI pin update] --> DU[Core and desktop users]
|
||||
```
|
||||
|
||||
Key mechanics worth naming because the strategy must preserve or deliberately
|
||||
replace each one:
|
||||
|
||||
- Every minor bump on main automatically freezes the previous minor into paired
|
||||
`core/x.y` and `cloud/x.y` branches and rotates the matching backport labels.
|
||||
- Backports are label-driven: `needs-backport` plus a target label triggers an
|
||||
automated cherry-pick PR; conflicts fall back to a documented manual path.
|
||||
- The cloud deploy already promotes build-once artifacts: assets are built one
|
||||
time per SHA, and staging and prod move by repointing that SHA. Rollback is a
|
||||
pointer revert with no rebuild.
|
||||
- Core GA promotion is a biweekly scheduled train that publishes to PyPI and
|
||||
drafts the ComfyUI pin-bump PR.
|
||||
- A rotating release owner (the "sheriff") drives promotions; per-PR backport
|
||||
ownership was deliberately moved to feature pods in June 2026.
|
||||
- Frontend builds destined for core GA soak in the core nightly channel
|
||||
(about two weeks for graph-touching changes) because there is no automated
|
||||
signal when a change breaks a community custom node. The soak is a
|
||||
compensating control for missing telemetry, not a property of safe code.
|
||||
|
||||
These mechanics are documented operationally in `docs/release-process.md`,
|
||||
which remains the runbook of record for day-to-day releases. This document
|
||||
governs the target strategy, and that runbook gets updated as each phase in
|
||||
section 8 lands.
|
||||
|
||||
## 3. Pain points of the current process
|
||||
|
||||
Numbered for traceability to section 7. All are observed, not hypothetical.
|
||||
|
||||
- **PP1. Drift compounds on long-lived lines.** The 1.45 line spans 69 days
|
||||
from its minor cut to its latest patch and is still the pinned line: 19
|
||||
patch releases, 61 commits landed on it after it froze (54 backport
|
||||
cherry-picks plus 7 patch-release bumps), and main-vs-stable divergence of
|
||||
428 PRs by the latest patch. Backports onto old lines increasingly do not
|
||||
apply cleanly, and some do not even work once applied because the
|
||||
surrounding code diverged (a fix was backported to a GA line where it could
|
||||
not function without a second dependency PR, and was ultimately abandoned
|
||||
as a known issue).
|
||||
- **PP2. Per-PR cherry-picks can silently miss a target.** A fix that needed
|
||||
two cloud lines landed on only one; the next release shipped the regression
|
||||
back to users. Three independent safeguards (notification bot, sweep check,
|
||||
PR comment) all failed to catch it (FE-713).
|
||||
- **PP3. Release batches grow superlinearly risky.** Missing one train window
|
||||
meant a double-minor release: 401 PRs validated in a single QA pass, the
|
||||
largest surface the team has ever had to certify, including high-risk
|
||||
subsystem rewrites.
|
||||
- **PP4. The backport tooling itself fails silently.** The notification bot was
|
||||
down for six weeks without anyone noticing; the repo-level auto-merge setting
|
||||
is off, which silently turned the backport auto-merge flag into a no-op and
|
||||
required a workaround workflow; the manual retry path of the backport
|
||||
workflow was broken (FE-1282).
|
||||
- **PP5. Freeze state lives in conversation, not tooling.** A verbally frozen
|
||||
line was bumped past by an engineer who reasonably read stalled automation as
|
||||
a missed dispatch. The resulting premature minor cut cost one to two weeks of
|
||||
recovery. The post-mortem's first action item: encode freeze state durably.
|
||||
- **PP6. Human bottlenecks and burnout.** One engineer ran the release rotation
|
||||
for roughly two months straight; urgent core releases have required chasing
|
||||
approvals late at night. Backport PRs add review friction (authors cannot
|
||||
always self-approve, approvers are asleep, label permissions vary).
|
||||
- **PP7. Latency invites bypasses.** A simple graph change takes a minimum of
|
||||
about 16 days to reach core GA (review, two-week soak, release), up to 27 if
|
||||
it just misses a window. Under deadline pressure a customer demo was shipped
|
||||
via a one-off deployment that routed around the release process entirely,
|
||||
creating an unowned production surface.
|
||||
- **PP8. Release state is hard to read.** Answering "what exactly is on cloud
|
||||
prod" requires cross-referencing a branch tip, a deploy tag, and a SHA in
|
||||
another repo's values file. Public release surfaces (GitHub releases, docs
|
||||
changelog, in-app update notice) have drifted out of sync.
|
||||
- **PP9. QA involvement is ad hoc.** Test plans are hand-built per release
|
||||
(one release needed a bespoke plan naming 84 high-risk PRs); there is no
|
||||
standing definition of entry and exit criteria per promotion gate.
|
||||
- **PP10. Everything above burns cross-team trust.** Slow stable releases are
|
||||
a recurring source of cross-team friction, and the release rotation absorbs
|
||||
that pressure personally.
|
||||
|
||||
## 4. Review of the draft 4-tier proposal
|
||||
|
||||
### 4.1 The proposed model
|
||||
|
||||
The draft proposes four long-lived branches, each auto-deploying to its own
|
||||
standing environment, promoted wholesale (no cherry-picks) by a release
|
||||
manager, with hotfixes cut from main re-entering through staging, and a
|
||||
main-to-testing back-merge closing each cycle:
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
T[testing branch and env] -->|release manager merge| D[dev branch and env]
|
||||
D -->|release manager merge| S[staging branch and env]
|
||||
S -->|release manager merge| M[main branch equals prod]
|
||||
M -.->|back-merge at cycle end| T
|
||||
M -->|urgent defect| H[hotfix branch] --> S
|
||||
```
|
||||
|
||||
Claimed properties: engineers merge freely into testing; dev is a guaranteed
|
||||
stable baseline; staging mirrors production for UAT; main is 1-to-1 with what
|
||||
is deployed, "eliminating the complicated and error-prone process of
|
||||
back-porting entirely."
|
||||
|
||||
This model was reviewed against the org context in sections 2 and 3, the
|
||||
industry evidence in section 5, and an adversarial defense pass (a reviewer
|
||||
whose explicit job was to defend the proposal and refute the critique; several
|
||||
initial critique points were withdrawn or narrowed as a result and are noted
|
||||
below).
|
||||
|
||||
### 4.2 What the proposal gets right
|
||||
|
||||
These instincts are correct, and each is adopted in section 6 via a cheaper
|
||||
mechanism than a branch tier:
|
||||
|
||||
- **K1. Deployed state should be inspectable in git.** Today it is not (PP8).
|
||||
Adopted as a machine-maintained prod pointer (6.5).
|
||||
- **K2. Freezes must be enforced by tooling, not by verbal agreement.** The
|
||||
premature-bump incident (PP5) proves it. Adopted as freeze-as-code (6.6).
|
||||
- **K3. Promotions need named owners and explicit gates.** Adopted via GitHub
|
||||
Environments protection rules and the existing rotation (6.4).
|
||||
- **K4. Engineers need a guaranteed-good base to branch from when trunk is
|
||||
red.** Adopted as last-green stable tags on main (6.7).
|
||||
- **K5. Fix propagation needs a hard discipline.** The draft's down-merge SLA
|
||||
becomes the inverse and industry-standard rule: upstream first, fix lands on
|
||||
main before any release line (6.3).
|
||||
- **K6. Whole-unit promotion beats per-PR picking wherever a track allows it.**
|
||||
Wholesale promotion makes the PP2 failure class (a missed per-PR pick)
|
||||
unrepresentable. Adopted for the cloud track as whole-artifact promotion,
|
||||
and bounded on the core track by a drift SLO (6.8).
|
||||
- **K7. Every promotion should auto-deploy its surface.** Directionally right
|
||||
and already sanctioned by in-flight work that removes no-value manual steps
|
||||
(FE-1176). Adopted throughout.
|
||||
- **K8. QA deserves a first-class, named slot in the release path.** The only
|
||||
document in this debate that gives QA an explicit stage. Adopted as defined
|
||||
entry/exit criteria per gate instead of a dedicated environment (6.4).
|
||||
|
||||
### 4.3 Findings
|
||||
|
||||
Ordered by severity. "Withdrawn" notes mark initial critiques that did not
|
||||
survive the adversarial defense, kept here so the review is honest about its
|
||||
own error bars.
|
||||
|
||||
**Fatal (any one of these blocks adoption as written):**
|
||||
|
||||
- **F1. The central claim is false for this product.** "Eliminates
|
||||
back-porting entirely" cannot hold while ComfyUI core pins an exact
|
||||
`comfyui-frontend-package` version and desktop users run pinned installs.
|
||||
A linear four-tier chain holds exactly one version in flight; it has no
|
||||
mechanism to patch a shipped 1.45 while 1.47 is mid-promotion and 1.48 is
|
||||
on nightly. Fixes to pinned lines remain cherry-picks plus PyPI patch
|
||||
releases, that is, backports. The proposal eliminates the word by omitting
|
||||
the surface that needs it: nowhere does it mention the pin, PyPI, desktop,
|
||||
the soak, or custom nodes. (Narrowed but confirmed under defense: wholesale
|
||||
trains do reduce drift-driven backport volume; they do not remove the
|
||||
pinned-version axis.)
|
||||
- **F2. It re-models an existing artifact pipeline as merge ceremony, and
|
||||
regresses it.** Cloud promotion is already build-once: one SHA-keyed asset
|
||||
build, promoted by pointer, rollback with no rebuild. Branch-tier CD means
|
||||
each tier builds its own merge commit, so the artifact validated on staging
|
||||
is provably not the artifact deployed from main. Worse, GitHub pull request
|
||||
merges never fast-forward, so gated promotion PRs mint a new SHA at every
|
||||
tier: "human-gated promotion" and "main is SHA-identical to prod" are
|
||||
mutually exclusive with native GitHub mechanics. One of the model's two
|
||||
core promises must break.
|
||||
- **F3. The soak has no home.** The two-week custom-node soak is the org's
|
||||
central regression control for its worst historical failure class. In a
|
||||
linear chain it either occupies the staging tier permanently (capping all
|
||||
release cadence at soak length and colliding with the hotfix path) or
|
||||
silently disappears while its automated replacement remains unstaffed.
|
||||
- **F4. It rows against the org's sanctioned direction.** The shipping-speed
|
||||
initiative targets PR-to-prod under 48 hours, backport rate under 10
|
||||
percent, and stable tags cut from main every 24 to 48 hours, backed by an
|
||||
internal PRD and in-flight Linear work (FE-1176, FE-602/BE-800, release
|
||||
gate automation). The draft adds an N-day merge freeze (at roughly 330
|
||||
merged PRs per month across all targets, about 230 of them on main, that
|
||||
is a real stall), three human promotion gates,
|
||||
and full-cycle latency for every change. DORA's trunk research lists "no
|
||||
code freezes" as a success criterion for elite delivery.
|
||||
|
||||
**Major:**
|
||||
|
||||
- **F5. The hotfix path is inoperable mid-cycle.** Hotfixes cut from main
|
||||
re-enter through staging. Whenever staging holds next-cycle content in UAT
|
||||
(most of the calendar), an urgent prod fix either waits out UAT or drags
|
||||
unreleased work to production. At the observed fix rate on live lines
|
||||
(54 backports in the 53 days after the 1.45 line froze, about one per
|
||||
day), "restart UAT on every hotfix" is a validation livelock. Notably, both parents of this model do it differently:
|
||||
GitLab Flow is strictly upstream-first, and GitFlow merges hotfixes to
|
||||
master directly. The draft inherits the weaker property of each.
|
||||
- **F6. "Guaranteed stable dev" is a smoke test, not a guarantee.** dev
|
||||
receives testing wholesale, so at promotion time dev is byte-identical to
|
||||
testing at freeze. The only stability delta is whatever a quick smoke pass
|
||||
catches, and the tier's only unique content comes from direct-to-dev
|
||||
hotfixes, which is the environment-drift anti-pattern. A second standing
|
||||
environment running identical code adds configuration-drift false positives,
|
||||
not signal. (Narrowed under defense: as a lagged checkpoint, dev does have
|
||||
value mid-cycle; but a last-green tag delivers the same checkpoint without
|
||||
a branch, an environment, or a gate.)
|
||||
- **F7. Every gate is human and every suite is undefined.** The model's
|
||||
stability claims rest entirely on "smoke tests," "all regression and
|
||||
acceptance tests," and release-manager judgment. No suite, owner, or pass
|
||||
criterion is named anywhere. Against roughly 330 PRs per month with a
|
||||
two-person QA function, undefined manual gates become either the bottleneck
|
||||
or a rubber stamp; the 401-PR pass (PP3) becomes the steady state, since
|
||||
biweekly wholesale promotion at current velocity is roughly 110 to 165 PRs
|
||||
per batch (main-line content alone runs about 110 per two weeks).
|
||||
- **F8. No rollback story, and env-branch rollback poisons future
|
||||
promotions.** The draft never mentions rollback. Reverting a bad promotion
|
||||
merge makes git treat that content as already-merged, silently dropping it
|
||||
from the next wholesale promotion until someone reverts the revert. And the
|
||||
moment a prod deploy fails after the staging-to-main merge, main is ahead of
|
||||
prod again, the exact state the model claims to abolish.
|
||||
- **F9. The four auto-deploy pipelines do not exist and cannot be driven from
|
||||
this repo.** This repo never deploys anything; it fire-and-forgets a
|
||||
dispatch to the cloud repo, which owns builds, secrets, and ArgoCD. Standing
|
||||
up per-branch CD means building four cross-repo deploy paths while the
|
||||
backend concurrently moves to per-service delivery with its own promotion
|
||||
model. The proposal is also purely additive: since it cannot serve the
|
||||
pinned-package axis (F1), all existing release machinery keeps running
|
||||
beside it, and the net delta is plus three eternal branches, plus one or two
|
||||
standing environments, plus three human gates.
|
||||
|
||||
**Spec gaps and smaller issues:**
|
||||
|
||||
- **F10. Wholesale-minus-exceptions needs a written procedure.** Excluding one
|
||||
bad PR from a promotion is revert-then-reland (and revert-the-revert later),
|
||||
a known git footgun that will otherwise be improvised for the first time
|
||||
during a release emergency. The draft's own exception clause re-authorizes
|
||||
the cherry-picking it bans. (Initial claim "this is not a git operation" was
|
||||
withdrawn: revert/reland is standard practice; the finding is that the
|
||||
procedure is unwritten and its failure mode, silently dropped features, has
|
||||
no detecting check.)
|
||||
- **F11. Freeze semantics are underspecified rather than contradictory.**
|
||||
(Initial "rules 1.c and 1.d contradict" was withdrawn.) The real gap:
|
||||
stabilization fixes must merge during the freeze, so the freeze is porous by
|
||||
design, with no tooling-enforced definition of what may enter, which is
|
||||
exactly the un-encoded-freeze failure mode of PP5.
|
||||
- **F12. Naming and contract frictions.** A tier named dev that is more stable
|
||||
than testing inverts every industry convention; repointing main's semantics
|
||||
would break the nightly channel contract, though the model does not actually
|
||||
require the top tier to be named main (withdrawn as a fatal objection: tier
|
||||
names are free variables; kept as a migration note). Rule 6.a's ancestry
|
||||
policy has no native GitHub primitive and needs a small custom status check;
|
||||
cheap, but this org's post-mortems document exactly this class of bespoke
|
||||
automation failing silently (PP4).
|
||||
|
||||
### 4.4 Scorecard
|
||||
|
||||
| Dimension | Score | Basis |
|
||||
| --- | --- | --- |
|
||||
| Internal coherence | 5/10 | F5, F8, F10, F11 are real; several initial coherence critiques were withdrawn under defense |
|
||||
| Fit to this product's topology | 3/10 | F1, F3: the pinned-package axis and the soak are unmodeled |
|
||||
| Operability on GitHub with current infra | 2/10 | F2 SHA exclusivity, F9 pipelines do not exist, additive process surface |
|
||||
| Alignment with current industry evidence | 3/10 | Section 5; env-branch promotion is a documented anti-pattern for continuously deployed surfaces, with narrow exceptions that map to pipeline approval gates |
|
||||
| Quality of underlying instincts | 8/10 | K1 through K8 are correct and adopted |
|
||||
| **Overall, as written, for this repo** | **4/10** | Fatal findings F1 through F4 |
|
||||
|
||||
For fairness: as a generic process for a single-track web application in the
|
||||
era it comes from, this model rates roughly 6.5/10; GitLab Flow's environment
|
||||
branch variant, which it closely resembles, remains documented practice. The
|
||||
low score here is about fit to this product, not about the model's pedigree.
|
||||
|
||||
### 4.5 Disposition of each proposal element
|
||||
|
||||
| Proposal element | Disposition | Why |
|
||||
| --- | --- | --- |
|
||||
| Four long-lived environment branches | Reject | F1, F2, F3, F9; environments become pipeline stages instead |
|
||||
| Wholesale promotion, no cherry-picks | Adapt | Correct for cloud as whole-artifact promotion (K6); impossible for the pinned core axis (F1) |
|
||||
| Release-manager gated merges at three tiers | Adapt | One named human gate per surface via GitHub Environments required reviewers; no new role hierarchy (PP6) |
|
||||
| N-day code freeze on the integration branch | Reject | F4; freezes are the documented anti-pattern and main never freezes today |
|
||||
| Anything in testing ships in the current cycle | Adapt | Correct WIP-limit instinct; becomes the enforceable drift SLO in 6.8 |
|
||||
| dev tier as stable baseline | Replace | Last-green stable tags on main (K4, F6) |
|
||||
| staging tier for UAT and alpha access | Keep the capability | Already exists as the staging environment plus auth-gated and per-PR preview deploys; formalize QA criteria (K8) |
|
||||
| main equals prod, 1-to-1 | Replace | Machine-maintained prod pointer ref (K1); merge-based parity is unenforceable on GitHub (F2) |
|
||||
| Hotfix via branch from main through staging | Reject | F5; hotfix path per track defined in 6.9 |
|
||||
| Back-merge main to testing at cycle end | Reject | Unnecessary once there is a single eternal branch; upstream-first makes down-merges structural (K5) |
|
||||
| GitHub-enforced containment policy (6.a) | Adopt the idea | As a 20-line ancestry status check plus release-content verification (6.8), with heartbeat alerts given PP4 |
|
||||
| Version tag on every prod deploy | Already exists | Cloud deploy tags and nightly tags; kept and unified in the prod pointer spec |
|
||||
|
||||
## 5. What the industry does
|
||||
|
||||
Genealogy first, because the draft cites a remembered decade-old post with
|
||||
detailed charts. No post titled "a better git branching strategy" from that
|
||||
era appears to exist. The remembered title and charts almost certainly belong
|
||||
to Vincent Driessen's "A successful Git branching model" (nvie.com, 2010, the
|
||||
GitFlow post). The remembered content, four auto-deploying environment tiers
|
||||
promoted by wholesale merge, matches GitLab Flow's environment-branches
|
||||
variant (2014). The hotfix-from-prod and back-merge mechanics are GitFlow's.
|
||||
The draft is therefore a hybrid of two models from 2010 to 2014, both of whose
|
||||
authors have since published significant caveats:
|
||||
|
||||
- Driessen added a note to the GitFlow post in 2020: teams shipping
|
||||
continuously delivered web software should use a much simpler flow such as
|
||||
GitHub Flow; GitFlow-style models remain reasonable for explicitly versioned
|
||||
software with multiple versions in the wild. Both halves apply here, because
|
||||
this product is both.
|
||||
- GitLab's own docs now describe the environment-branch variant alongside a
|
||||
release-branch variant with an explicit upstream-first rule (fix on main,
|
||||
cherry-pick down), the same policy Google and Red Hat practice.
|
||||
|
||||
The current consensus, by product shape:
|
||||
|
||||
- **Continuously deployed services** (GitHub, Shopify, Google, the DORA
|
||||
research corpus): one mainline, short-lived topic branches, merge queue,
|
||||
feature flags, canary or percentage rollout. Environments are deployment
|
||||
pipeline stages or GitOps folders, not branches. DORA's trunk criteria:
|
||||
three or fewer active branches, daily merges, no code freezes; elite
|
||||
performers correlate strongly with this shape. Environment-branch promotion
|
||||
is repeatedly documented as an anti-pattern (Fowler's branching patterns;
|
||||
the GitOps literature: merge-order skew, unintended config riding along,
|
||||
undocumented prod drift from direct-to-branch hotfixes, per-branch rebuilds
|
||||
violating build-once). The one conceded exception, regulated sign-off audit
|
||||
trails, is satisfied by pipeline approval gates with deployment history.
|
||||
- **Versioned or embedded software** (Chrome, Firefox, Microsoft Release
|
||||
Flow): trunk plus short-lived per-release branches, cut just in time,
|
||||
fix-only, upstream-first cherry-picks with approval, never merged back,
|
||||
retired when the version leaves support. Chrome ships a milestone every four
|
||||
weeks this way; Firefox calls the cherry-pick an uplift and gates it on
|
||||
release management approval. The heavyweight tooling both maintain is
|
||||
evidence that backports are a cost to be minimized, not a routine channel.
|
||||
- **Hybrid products like this one** split the axes: trunk plus pipeline
|
||||
promotion and flags for the continuously deployed surface; trunk plus
|
||||
short-lived release branches for the pinned, versioned surface. That is
|
||||
Microsoft Release Flow on one side and Chrome-style trains on the other,
|
||||
sharing one trunk.
|
||||
|
||||
Sources: nvie.com/posts/a-successful-git-branching-model,
|
||||
about.gitlab.com/topics/version-control/what-is-gitlab-flow,
|
||||
dora.dev/capabilities/trunk-based-development,
|
||||
martinfowler.com/articles/branching-patterns.html,
|
||||
trunkbaseddevelopment.com/branch-for-release,
|
||||
octopus.com/blog/stop-using-branches-deploying-different-gitops-environments,
|
||||
beyond.minimumcd.org/docs/reference/practices/immutable-artifacts,
|
||||
devblogs.microsoft.com/devops/release-flow-how-we-do-branching-on-the-vsts-team,
|
||||
chromium.googlesource.com/chromium/src/+/master/docs/process/release_cycle.md,
|
||||
wiki.mozilla.org/Release_Management/Release_Process,
|
||||
github.blog/engineering/engineering-principles/deploying-branches-to-github-com,
|
||||
shopify.engineering/successfully-merging-work-1000-developers.
|
||||
|
||||
## 6. Recommended strategy
|
||||
|
||||
### 6.1 Principles
|
||||
|
||||
- **P1. One eternal branch.** main is the single integration branch and the
|
||||
nightly channel. Its semantics are a locked contract: nightly releases,
|
||||
npm type publishing, cloud build dispatch, CI triggers, and community
|
||||
tooling all key off it.
|
||||
- **P2. Build once, promote artifacts.** Every merge to main produces one
|
||||
SHA-keyed artifact. Environments receive that artifact by pointer; nothing
|
||||
is ever rebuilt per environment. The artifact that passes staging and canary
|
||||
is byte-identical to the artifact in production.
|
||||
- **P3. Upstream first, always.** Every fix lands on main first, with tests.
|
||||
Release lines receive changes only via the backport pipeline. Direct commits
|
||||
to `core/*` and `cloud/*` are blocked for humans (release automation
|
||||
excepted). This is the rule that prevents the PP2 class.
|
||||
- **P4. Branches only where a version demands one.** A long-lived branch
|
||||
exists only to serve a pinned, shipped version (`core/x.y` while ComfyUI
|
||||
pins it). No branch exists to represent an environment.
|
||||
- **P5. Invariants over notifications.** Every policy in this document that
|
||||
matters is enforced by a required check or a reconciling audit with
|
||||
heartbeat alerting, not by a bot that posts a message. PP4 is the reason.
|
||||
- **P6. Gates are automation-first, with at most one named human approval per
|
||||
surface,** held by the existing release rotation. No new role hierarchy.
|
||||
- **P7. Small batches on a fixed cadence.** The unit of release stays as small
|
||||
as the gates allow. The 401-PR batch is the documented anti-goal.
|
||||
|
||||
### 6.2 Branch roles
|
||||
|
||||
| Branch | Lifetime | Purpose | Who writes to it |
|
||||
| --- | --- | --- | --- |
|
||||
| `main` | Eternal | Integration, nightly channel, source of all builds | Engineers via peer-approved PRs |
|
||||
| `core/x.y` | Weeks; dies when the ComfyUI pin moves off x.y | Serve the pinned PyPI line | Backport automation and release bumps only |
|
||||
| `cloud/x.y` | Interim only; retired per 6.10 | Cloud release train until cloud CD lands | Backport automation only |
|
||||
| `feature/*`, `fix/*` | Days | Topic branches off main (or a last-green tag when main is red) | The author |
|
||||
| Prod pointer ref (`deployed/cloud-prod`) | Eternal, machine-written | Mirrors the verified deployed SHA after every prod sync, rollbacks included | Deploy pipeline only |
|
||||
|
||||
Target state, Phase 2 and later (until the cloud branch retirement in 6.10
|
||||
lands, the cloud leg still promotes from the interim cloud/x.y line):
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
ENG[Engineer merges PR to main] --> BUILD[One artifact built per SHA]
|
||||
BUILD --> NIGHT[Nightly tag and release]
|
||||
BUILD --> TC[testcloud] --> SC[staging] --> CAN[Canary percent rollout] --> PROD[Cloud prod]
|
||||
PROD --> PTR[Prod pointer ref advances]
|
||||
ENG --> GATE[Overnight release gate CI] --> TAGS[Last-green stable tag]
|
||||
TAGS -->|next minor cut| LINE[core release line, backports only] --> TRAIN[Biweekly core train: patch bump, PyPI, pin PR]
|
||||
```
|
||||
|
||||
### 6.3 The two release axes, explicitly separated
|
||||
|
||||
- **Cloud axis (continuous):** main to testcloud to staging to prod is
|
||||
artifact promotion through pipeline stages. Gates live in the pipeline: the
|
||||
overnight release-gate suite for candidate selection, a staging smoke
|
||||
checklist with named QA criteria, and canary metrics with automatic
|
||||
rollback once the canary program lands. Incomplete features land on main
|
||||
dark behind feature flags so the trunk stays releasable while work is in
|
||||
progress. No environment branches.
|
||||
- **Core axis (versioned):** `core/x.y` branches exist because an exact
|
||||
version is pinned by another product and shipped to desktops. They are cut
|
||||
automatically (as today; in the target state the cut is taken from the
|
||||
newest last-green tag rather than the raw pre-bump commit), receive fixes
|
||||
only via upstream-first backports, and are retired when the pin moves. The fix for PP1 is not a new topology;
|
||||
it is shortening how long these lines live and how far they drift (6.8).
|
||||
|
||||
### 6.4 Promotion gates
|
||||
|
||||
Implemented as GitHub Environments deployment protection rules (required
|
||||
reviewers plus deployment history), not as branch merges. This natively
|
||||
provides the audit trail that is the one legitimate case for human-gated
|
||||
promotion, and it is the same primitive the draft's release-manager gates
|
||||
actually wanted.
|
||||
|
||||
| Gate | Trigger | Automated criteria | Human |
|
||||
| --- | --- | --- | --- |
|
||||
| PR into main | Every PR | Unit, component, lint, typecheck | One peer approval |
|
||||
| Release-gate verdict | Nightly | Behavioral suite, critical-path browser tests, custom-node harness when live | None; red verdict is an incident |
|
||||
| Staging promotion | On green candidate | Artifact exists, gate verdict green | None (auto) |
|
||||
| Prod promotion | Sheriff action | Staging smoke checklist green | One: rotation owner |
|
||||
| Core GA train | Biweekly schedule | Line is green, soak or harness criteria met | One: pin PR merge in ComfyUI |
|
||||
|
||||
QA's slot (K8): the staging smoke checklist and the release regression scope
|
||||
are standing documents with named QA owners and defined entry and exit
|
||||
criteria, replacing hand-built per-release plans (PP9).
|
||||
|
||||
### 6.5 Production visibility (adopts K1)
|
||||
|
||||
A machine-maintained ref, `deployed/cloud-prod`, is written by the deploy
|
||||
pipeline only after the production sync reports healthy, and it mirrors the
|
||||
deployed SHA in both directions: a verified rollback moves the ref backward
|
||||
to the rolled-back SHA (a recorded, non-fast-forward move with the rollback
|
||||
reason logged, which the reconciler treats as healthy), so the ref never lies
|
||||
about production during an incident. A reconciling check compares the ref against the cloud repo's
|
||||
deployed SHA on every deploy and alerts on mismatch (P5). Answering "what is on prod" becomes
|
||||
`git log deployed/cloud-prod`, which is the legitimate requirement behind the
|
||||
draft's main-equals-prod rule, delivered without merge ceremony and immune to
|
||||
the F2 impossibility.
|
||||
|
||||
### 6.6 Freeze-as-code (adopts K2)
|
||||
|
||||
Freeze state is a first-class marker (a repo variable or protected file) that
|
||||
release automation checks before acting: the minor-bump dispatch refuses to
|
||||
cut past a frozen line, and backport targeting warns on frozen targets. All
|
||||
release bots carry heartbeat alerts; silence is an incident. These are the
|
||||
premature-bump post-mortem action items, promoted into the strategy.
|
||||
|
||||
### 6.7 Stable base for engineers (adopts K4)
|
||||
|
||||
The nightly release-gate verdict stamps an immutable dated tag (for example
|
||||
`stable/2026-07-18`) on the newest commit that passed the full gate and
|
||||
advances a `stable/latest-green` branch-style ref to it. A moving ref, not a
|
||||
moving tag, on purpose: git clients do not force-update moved tags by
|
||||
default, so a moving tag would silently go stale locally. When main is red,
|
||||
engineers branch from the ref or the dated tag instead of a standing dev
|
||||
branch. Red main is itself an incident
|
||||
with a named owner, which is what actually keeps the trunk usable.
|
||||
|
||||
### 6.8 Drift limits and content verification (adopts K6 and the containment idea)
|
||||
|
||||
- **Drift SLO:** the pinned release line being older than 28 days, or the pin
|
||||
sitting more than two minors behind main, pages the rotation owner and
|
||||
forces a train decision. This is the WIP limit that the draft expressed as
|
||||
"anything in testing ships this cycle," made enforceable. PP1's 69-day line
|
||||
becomes structurally impossible to reach silently. A breach also blocks the
|
||||
next minor cut until acknowledged. The SLO activates with a burn-in
|
||||
exemption for the already-breached 1.45 line, and a breach that traces to
|
||||
an unmerged ComfyUI pin PR escalates per risk R2 instead of paging the
|
||||
rotation, because that lever is not frontend-side.
|
||||
- **Convergence invariant:** a scheduled check asserts every commit on a live
|
||||
release line is an ancestor of main or arrived via the backport pipeline,
|
||||
and that every prod-deployed SHA is reachable from a release line. Roughly
|
||||
20 lines of CI; catches what three notification bots missed (PP2).
|
||||
- **Release-content verification:** before a train departs, an automated
|
||||
check confirms every PR labeled for that line actually landed on it, and
|
||||
posts the diff of intent versus content. FE-713 becomes a failing check
|
||||
instead of a user report.
|
||||
|
||||
### 6.9 Hotfix and rollback runbooks
|
||||
|
||||
Interim topology (while cloud/x.y lines exist; after the 6.10 retirement the
|
||||
cloud leg promotes last-green artifacts from main instead):
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
Q1{Where is the defect live?} -->|Nightly only| A1[Fix on main, ships next nightly]
|
||||
Q1 -->|Cloud prod| A2[Fix on main with tests] --> A3[Backport label to the live cloud line] --> A4[Staging smoke on the patched artifact] --> A5[Prod promotion by rotation owner]
|
||||
Q1 -->|Core GA or desktop| A6[Fix on main with tests] --> A7[Backport label to the pinned core line] --> A8[Patch release to PyPI] --> A9[Pin bump PR on ComfyUI]
|
||||
Q1 -->|Deploy itself is bad, code is fine| R1[Rollback: repoint prod to previous SHA, no rebuild]
|
||||
```
|
||||
|
||||
Rules: hotfixes never restart full UAT and never bundle unreleased content;
|
||||
validation scope is the affected area plus the standing smoke checklist. The
|
||||
sanctioned path must stay hours-scale (target: under 8 working hours from
|
||||
fix merged on main to prod promotion), because the org has already
|
||||
demonstrated that a slower sanctioned path manufactures shadow deploy surfaces
|
||||
(PP7). Rollback is always a pointer move, never a git revert of a promotion;
|
||||
revert is reserved for code defects on main with an explicit reland step.
|
||||
|
||||
### 6.10 Cloud branch retirement (the end state for the cloud axis)
|
||||
|
||||
`cloud/x.y` branches exist today because cloud deploys from a frozen line.
|
||||
Once the release-gate suite plus canary auto-rollback are proven (several
|
||||
clean ramps and at least one real auto-rollback in production), cloud promotes
|
||||
last-green artifacts from main directly and the cloud branch family retires.
|
||||
The cutover checklist must cover: release-branch creation stops cutting
|
||||
`cloud/<minor>` and rotating cloud labels, the cloud deploy tag workflow
|
||||
retires, build dispatch keys on main SHAs and promotion events, and testcloud
|
||||
repoints from the cloud line tip to last-green tags. Until then, cloud/x.y
|
||||
continues exactly as today; this document changes nothing about it yet.
|
||||
|
||||
## 7. How this solves the current pain points
|
||||
|
||||
| Pain point | Mechanism in this strategy | Status |
|
||||
| --- | --- | --- |
|
||||
| PP1 drift on long lines | Drift SLO (6.8) caps line age; shorter trains; upstream-first keeps lines fix-only | Mitigated; fully solved only if ComfyUI adopts pin bumps at train cadence (risk R2) |
|
||||
| PP2 missed per-PR backports | Release-content verification plus convergence invariant (6.8); whole-artifact promotion on cloud removes per-PR picks there entirely | Solved: becomes a failing check, and unrepresentable on the cloud axis post 6.10 |
|
||||
| PP3 giant QA batches | Fixed cadence with drift SLO forces small trains (P7); release-gate CI carries per-merge burden | Mitigated; batch size cannot silently grow past the SLO, and drops under 100 if the 13.1 decision moves the core train to weekly |
|
||||
| PP4 silent tooling failure | Invariants and reconciling audits with heartbeat alerts replace notification-only bots (P5); prerequisite fixes named in 8 | Mitigated; bots can still break, but silence itself now alarms |
|
||||
| PP5 verbal freezes | Freeze-as-code guards the bump and backport paths (6.6) | Solved: the premature-bump incident becomes mechanically impossible |
|
||||
| PP6 human bottleneck | One human gate per surface on the existing rotation; merge-on-green everywhere else; backport approval rules simplified by making automation the only writer to release lines | Mitigated; the rotation remains, its late-night surface shrinks |
|
||||
| PP7 latency invites bypasses | Hours-scale sanctioned hotfix lane (6.9); canary replaces calendar soak for cloud; core latency shrinks as the harness replaces the soak | Partially solved now, fully contingent on the custom-node harness (risk R1) |
|
||||
| PP8 unreadable release state | Prod pointer ref (6.5); unified tags; one strategy document | Solved |
|
||||
| PP9 ad hoc QA | Standing gate criteria with named QA owners (6.4) | Solved by process definition |
|
||||
| PP10 cross-team trust | Publish the delivery metrics (9) on a fixed cadence so progress is legible instead of argued | Mitigated; trust follows the numbers |
|
||||
|
||||
The honest line on backports: this strategy does not eliminate them, because
|
||||
nothing can while another product pins an exact version. It makes them rare
|
||||
(drift SLO plus faster trains), safe (upstream-first plus content
|
||||
verification), and boring (automation is the only writer to release lines).
|
||||
The draft's promise was elimination; the achievable promise is a backport
|
||||
rate under 10 percent with no silent misses.
|
||||
|
||||
## 8. Rollout plan
|
||||
|
||||
Prerequisites (fix before anything else changes): repair the manual backport
|
||||
retry workflow (FE-1282), turn on the repo auto-merge setting and retire the
|
||||
cron workaround, add heartbeat alerts to every release bot, and correct the
|
||||
stale PyPI-attribution in docs/release-process.md so the runbook of record
|
||||
matches the actual workflows.
|
||||
|
||||
- **Phase 0, immediately:** adopt this document; freeze-as-code; prod pointer
|
||||
ref; drift SLO alerting; convergence invariant check; standing QA gate
|
||||
criteria drafted (their operational acceptance is P1 item 9). No branch
|
||||
topology changes at all.
|
||||
- **Phase 1, with the release gate:** overnight release-gate suite produces
|
||||
last-green tags; stable tags become the engineer base and the cloud
|
||||
candidate source; staging promotion goes automatic on green.
|
||||
- **Phase 2, with the canary:** percentage rollout with metric-gated
|
||||
auto-ramp and auto-rollback on cloud prod; hotfix lane switches to
|
||||
canary-validated promotion.
|
||||
- **Phase 3, retirement:** cloud/x.y family retires per 6.10. Core/x.y
|
||||
remains, on shorter trains, as long as ComfyUI pins exact versions; the
|
||||
calendar soak retires only when the custom-node harness is live, staffed,
|
||||
and has held the escaped-regression guardrail flat for two full cycles.
|
||||
|
||||
## 9. Goals and success metrics
|
||||
|
||||
| Goal | Metric | Target | Horizon |
|
||||
| --- | --- | --- | --- |
|
||||
| Ship fast | PR merge to cloud prod | Under 48 hours median | Phase 2 |
|
||||
| Backports rare | Backported PRs / merged PRs | Under 10 percent | Phase 2 |
|
||||
| Lines stay young | Max live release-line age | Under 28 days | Phase 1 |
|
||||
| No silent misses | Escaped regressions from missed backports | Zero | Phase 0 |
|
||||
| Batches stay small | PRs per QA-certified release | Under 100 | Phase 2, after the 13.1 cadence decision |
|
||||
| Prod is legible | Time to answer "what is on prod" | One git command | Phase 0 |
|
||||
| No shadow deploys | Unowned one-off production surfaces | Zero new | Phase 0 |
|
||||
|
||||
Leading indicators are the first three; report all seven on a fixed cadence in
|
||||
the cross-team channel (PP10).
|
||||
|
||||
## 10. Non-goals
|
||||
|
||||
- Renaming or repointing main. Its contract is locked (P1).
|
||||
- Eliminating release branches while ComfyUI pins exact frontend versions.
|
||||
That coupling is a product decision owned elsewhere; this strategy minimizes
|
||||
its cost rather than pretending it away.
|
||||
- Standing up new long-lived environments. Existing surfaces (testcloud,
|
||||
staging, per-PR previews) cover every tier the draft wanted.
|
||||
- Changing ComfyUI core's own release process, cadence, or the soak policy it
|
||||
requires; this document only defines what the frontend does on each axis.
|
||||
- Prescribing the backend or cloud repo's deployment architecture (per-service
|
||||
delivery work proceeds independently).
|
||||
|
||||
## 11. Requirements
|
||||
|
||||
**P0 (the strategy is not adopted without these):**
|
||||
|
||||
1. Freeze-as-code marker checked by version-bump and backport automation.
|
||||
Acceptance: a bump dispatch against a frozen line fails with a clear error;
|
||||
verified by test.
|
||||
2. Prod pointer ref written only by the deploy pipeline after verified sync,
|
||||
with a reconciling audit. Acceptance: mismatch alarms within one deploy
|
||||
cycle.
|
||||
3. Convergence invariant and release-content verification checks, with
|
||||
heartbeat alerting. Acceptance: a deliberately mislabeled test PR is caught
|
||||
before a train departs.
|
||||
4. Drift SLO alerting at 28 days of line age or two minors of pin divergence.
|
||||
Acceptance: alert fires in a rehearsal against a stale line.
|
||||
5. Documented hotfix and rollback runbooks (6.9) with an hours-scale SLA.
|
||||
Acceptance: one rehearsed hotfix per quarter meets the SLA.
|
||||
6. Prerequisite tooling fixes: FE-1282, auto-merge setting, bot heartbeats.
|
||||
Acceptance: a manually dispatched backport retry succeeds end to end; a
|
||||
backport PR merges via repo auto-merge with the cron workaround retired;
|
||||
killing a release bot raises its heartbeat alert.
|
||||
|
||||
**P1 (fast follows):**
|
||||
|
||||
7. Overnight release-gate suite stamping last-green tags; red verdict is an
|
||||
incident with a named owner. Acceptance: a green run stamps the dated tag
|
||||
within an hour; a red run opens an owned incident.
|
||||
8. Automatic staging promotion on green; GitHub Environments protection rules
|
||||
with deployment history as the prod gate. Acceptance: a green candidate
|
||||
reaches staging with zero human actions, and a prod promotion without the
|
||||
required reviewer is blocked in rehearsal.
|
||||
9. Standing QA gate criteria with named owners replacing per-release plans.
|
||||
Acceptance: the next release runs from the standing criteria with no
|
||||
bespoke test plan authored.
|
||||
|
||||
**P2 (future, explicitly sequenced):**
|
||||
|
||||
10. Canary with metric-gated auto-ramp and auto-rollback. Acceptance: one
|
||||
production auto-rollback triggered by canary metrics in a game day.
|
||||
11. cloud/x.y retirement per the 6.10 checklist. Acceptance: the cutover
|
||||
checklist fully executed, verified by a workflow inventory showing no
|
||||
half-dead machinery.
|
||||
12. Soak retirement gated on the custom-node harness guardrail. Acceptance:
|
||||
the harness holds the escaped-regression guardrail flat for two full
|
||||
cycles before any soak shortening.
|
||||
|
||||
## 12. Risk register
|
||||
|
||||
| ID | Risk | L | I | Level | Mitigation | Owner |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| R1 | Custom-node harness stays unstaffed, so soak cannot shrink and core latency persists | High | High | Critical | Escalate staffing as the single gating dependency of the whole program; do not shorten the soak before the harness holds the guardrail | Eng leadership |
|
||||
| R2 | ComfyUI pin adoption lags frontend trains, so drift SLO breaches are unresolvable frontend-side | High | High | Critical | Agree a pin-adoption SLA with core release stakeholders; SLO breaches tracing to an unmerged pin PR escalate to leadership instead of paging the rotation | FE eng manager with core stakeholders |
|
||||
| R3 | Canary program slips; cloud branches linger in a half-retired state | Med | High | High | Retirement is the last step with explicit cutover criteria; interim state is exactly today's process | Release pipeline owner |
|
||||
| R4 | Prod pointer or invariant checks silently break, recreating PP4 | Med | High | High | Heartbeat alerts plus reconciliation against the deploy system on every run; silence pages | DevOps |
|
||||
| R5 | Drift SLO is ignored under deadline pressure | Med | Med | Medium | SLO breach pages the rotation and blocks the next minor cut until acknowledged | Rotation owner |
|
||||
| R6 | Incremental rollout gives no visible "fixed" moment; cross-team pressure continues | Med | Med | Medium | Publish the section 9 metrics on a fixed cadence; make progress legible | FE eng manager |
|
||||
| R7 | Flag debt accumulates as flags gate incomplete work on trunk | Med | Low | Low | Review-for-deletion date on every flag; monthly cleanup | FE leads |
|
||||
|
||||
Doing nothing carries its own critical risks (drift compounds, the next
|
||||
missed backport ships another regression, rotation burnout continues); the
|
||||
status quo is not the safe option.
|
||||
|
||||
## 13. Open questions
|
||||
|
||||
1. Cadence of the core train once the drift SLO lands: stay biweekly or move
|
||||
to weekly? At current velocity the under-100 batch target in section 9 is
|
||||
reachable only with weekly trains, so this question blocks that one
|
||||
metric's horizon. (Rotation owner plus core release stakeholders; blocking
|
||||
for the batch-size target, non-blocking for everything else.)
|
||||
2. Should the prod pointer cover core GA and desktop as refs too
|
||||
(`deployed/core-ga`), or is PyPI plus the pin authoritative enough?
|
||||
(Frontend leads; non-blocking.)
|
||||
3. Who owns the standing QA gate criteria documents long-term as QA staffing
|
||||
changes? (QA plus FE eng manager; blocking for Phase 0 sign-off.)
|
||||
4. Does the org-admin limitation on separate go-live approvers (surfaced
|
||||
during FE-1176) need resolution before GitHub Environments become the prod
|
||||
gate? (DevOps plus org admins; blocking for P1 item 8.)
|
||||
|
||||
## 14. References
|
||||
|
||||
Internal: `docs/release-process.md` (the operational runbook of record,
|
||||
updated as phases land); the shipping-speed initiative and its release-gate
|
||||
and canary design docs; the release rotation runbook; the
|
||||
premature-version-bump post-mortem; FE-713, FE-1176, FE-1282, FE-602/BE-800;
|
||||
the draft 4-tier proposal this document reviews.
|
||||
|
||||
External: see section 5 source list.
|
||||
@@ -8,13 +8,11 @@ export class ComfyActionbar {
|
||||
public readonly root: Locator
|
||||
public readonly queueButton: ComfyQueueButton
|
||||
public readonly propertiesButton: Locator
|
||||
public readonly dragHandle: Locator
|
||||
|
||||
constructor(public readonly page: Page) {
|
||||
this.root = page.locator('.actionbar-container')
|
||||
this.queueButton = new ComfyQueueButton(this)
|
||||
this.propertiesButton = this.root.getByLabel('Toggle properties panel')
|
||||
this.dragHandle = this.root.locator('.drag-handle')
|
||||
}
|
||||
|
||||
async isDocked() {
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
import type { Locator } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
export class FreeTierQuota {
|
||||
readonly root: Locator
|
||||
|
||||
constructor(comfyPage: ComfyPage) {
|
||||
this.root = comfyPage.page.getByTestId(TestIds.topbar.freeTierQuota)
|
||||
}
|
||||
|
||||
async getMax() {
|
||||
const text = await this.root.textContent()
|
||||
return text?.match(/(\d+) \/ (\d+)/)?.[2]
|
||||
}
|
||||
async getAvailable() {
|
||||
const text = await this.root.textContent()
|
||||
return text?.match(/(\d+) \/ (\d+)/)?.[1]
|
||||
}
|
||||
}
|
||||
@@ -103,8 +103,7 @@ export const TestIds = {
|
||||
loginButtonPopoverLearnMore: 'login-button-popover-learn-more',
|
||||
workflowTabs: 'topbar-workflow-tabs',
|
||||
integratedTabBarActions: 'integrated-tab-bar-actions',
|
||||
actionBarButtons: 'action-bar-buttons',
|
||||
freeTierQuota: 'free-tier-quota'
|
||||
actionBarButtons: 'action-bar-buttons'
|
||||
},
|
||||
nodeLibrary: {
|
||||
bookmarksSection: 'node-library-bookmarks-section'
|
||||
|
||||
@@ -28,11 +28,11 @@ const APP_URL = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
|
||||
// matches it against the members self-row.
|
||||
const SELF_EMAIL = 'e2e@test.comfy.org'
|
||||
|
||||
// billing_control_enabled routes personal workspaces to the unified pricing
|
||||
// table asserted here; without it they fall back to the legacy table.
|
||||
// consolidated_billing_enabled routes personal workspaces to the unified
|
||||
// pricing table asserted here; without it they fall back to the legacy table.
|
||||
const BOOT_FEATURES = {
|
||||
team_workspaces_enabled: true,
|
||||
billing_control_enabled: true
|
||||
consolidated_billing_enabled: true
|
||||
} satisfies RemoteConfig
|
||||
// Disable the experimental Asset API: with it on (cloud default) the unmocked
|
||||
// asset endpoints 403 and workflow restore throws uncaught, aborting the
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
import { expect, mergeTests } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { jsonRoute } from '@e2e/fixtures/utils/jsonRoute'
|
||||
import { FreeTierQuota } from '@e2e/fixtures/components/FreeTierQuota'
|
||||
import { ExecutionHelper } from '@e2e/fixtures/helpers/ExecutionHelper'
|
||||
import { webSocketFixture } from '@e2e/fixtures/ws'
|
||||
|
||||
const wstest = mergeTests(test, webSocketFixture)
|
||||
|
||||
test.describe('Free Tier Quota', { tag: ['@cloud', '@vue-nodes'] }, () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
const features = {
|
||||
free_tier_job_allowance_enabled: true,
|
||||
free_tier_balance: { allowance: 5, remaining: 3, used: 0 }
|
||||
}
|
||||
await page.route('**/api/features', (r) => r.fulfill(jsonRoute(features)))
|
||||
})
|
||||
|
||||
wstest('Free Tier Quota', async ({ comfyPage, comfyMouse, getWebSocket }) => {
|
||||
const execution = new ExecutionHelper(comfyPage, await getWebSocket())
|
||||
const freeTierQuota = new FreeTierQuota(comfyPage)
|
||||
|
||||
await test.step('Populates initial state from config', async () => {
|
||||
await expect.poll(() => freeTierQuota.getAvailable()).toBe('3')
|
||||
expect(await freeTierQuota.getMax()).toBe('5')
|
||||
})
|
||||
|
||||
await test.step('available decrements on run', async () => {
|
||||
await execution.run()
|
||||
await expect.poll(() => freeTierQuota.getAvailable()).toBe('2')
|
||||
})
|
||||
|
||||
await test.step('connects to detached run button', async () => {
|
||||
const handle = comfyPage.actionbar.dragHandle
|
||||
await comfyMouse.dragElementBy(handle, { x: -100, y: 100 })
|
||||
await expect.poll(() => comfyPage.actionbar.isDocked()).toBe(false)
|
||||
expect(await freeTierQuota.getAvailable()).toBe('2')
|
||||
await comfyMouse.dragElementBy(handle, { x: 100, y: -100 })
|
||||
await expect.poll(() => comfyPage.actionbar.isDocked()).toBe(true)
|
||||
})
|
||||
|
||||
await test.step('Detects workflows with Partner nodes', async () => {
|
||||
await comfyPage.searchBoxV2.addNode('Node With Price Badge')
|
||||
const node = await comfyPage.vueNodes.getFixtureByTitle('Price Badge')
|
||||
await expect.poll(() => freeTierQuota.getAvailable()).toBe(undefined)
|
||||
await node.delete()
|
||||
await expect.poll(() => freeTierQuota.getAvailable()).toBe('2')
|
||||
})
|
||||
|
||||
await test.step('Does not decrease past 0', async () => {
|
||||
await execution.run()
|
||||
await expect.poll(() => freeTierQuota.getAvailable()).toBe('1')
|
||||
await execution.run()
|
||||
await expect.poll(() => freeTierQuota.getAvailable()).toBe(undefined)
|
||||
await execution.run()
|
||||
await execution.run()
|
||||
await execution.run()
|
||||
await comfyPage.nextFrame()
|
||||
expect(await freeTierQuota.getAvailable()).toBe(undefined)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -29,8 +29,6 @@
|
||||
"dev:test": "cross-env VITE_USE_LEGACY_DEFAULT_GRAPH=true vite --config vite.config.mts",
|
||||
"dev": "vite --config vite.config.mts",
|
||||
"devtools:pycheck": "python3 -m compileall -q tools/devtools",
|
||||
"fallow": "fallow",
|
||||
"fallow:audit": "fallow audit",
|
||||
"format:check": "oxfmt --check",
|
||||
"format": "oxfmt --write",
|
||||
"json-schema": "tsx scripts/generate-json-schema.ts",
|
||||
@@ -174,7 +172,6 @@
|
||||
"eslint-plugin-testing-library": "catalog:",
|
||||
"eslint-plugin-unused-imports": "catalog:",
|
||||
"eslint-plugin-vue": "catalog:",
|
||||
"fallow": "catalog:",
|
||||
"fast-check": "catalog:",
|
||||
"fs-extra": "^11.2.0",
|
||||
"globals": "catalog:",
|
||||
|
||||
88
pnpm-lock.yaml
generated
88
pnpm-lock.yaml
generated
@@ -240,9 +240,6 @@ catalogs:
|
||||
eslint-plugin-vue:
|
||||
specifier: ^10.9.1
|
||||
version: 10.9.1
|
||||
fallow:
|
||||
specifier: ^2.102.0
|
||||
version: 2.102.0
|
||||
fast-check:
|
||||
specifier: ^4.5.3
|
||||
version: 4.5.3
|
||||
@@ -766,9 +763,6 @@ importers:
|
||||
eslint-plugin-vue:
|
||||
specifier: 'catalog:'
|
||||
version: 10.9.1(@typescript-eslint/parser@8.60.0(eslint@10.4.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.4.0(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@10.4.0(jiti@2.6.1)))
|
||||
fallow:
|
||||
specifier: 'catalog:'
|
||||
version: 2.102.0
|
||||
fast-check:
|
||||
specifier: 'catalog:'
|
||||
version: 4.5.3
|
||||
@@ -1914,46 +1908,6 @@ packages:
|
||||
'@exodus/crypto':
|
||||
optional: true
|
||||
|
||||
'@fallow-cli/darwin-arm64@2.102.0':
|
||||
resolution: {integrity: sha512-B8wzfzJgoX6h5Gv2xQ9ZidO5Jb8/PWdssAxYbWs1pb5oJHZ6S5PLwXuUdINmNSIaRGQwTk4DC9/tIMFHFvd9uw==}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@fallow-cli/darwin-x64@2.102.0':
|
||||
resolution: {integrity: sha512-aLbTWWzQnleKdi56obAPXMJm7YA3qAnkIX9T3eocRHiagYqp8nsf4cslM0rZKvu2WwK34NaBm8x886gl4cl+zg==}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@fallow-cli/linux-arm64-gnu@2.102.0':
|
||||
resolution: {integrity: sha512-8nYeOSLSewqcKH/KUcKZaCq5QII5VTRX62l60B6UM1iKe/0jcmlQ2mOtx4rkHpeq3LL5emvT/ph4NNgGmWKSBg==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@fallow-cli/linux-arm64-musl@2.102.0':
|
||||
resolution: {integrity: sha512-2Zi33PXzZxD7HU5sPVwyElZGP7zyEdGo4hK0ewy6gMYgQ9BDfLnFhgaSSOzN1J4paIhYtBmVsLmqakyDKy22Jw==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@fallow-cli/linux-x64-gnu@2.102.0':
|
||||
resolution: {integrity: sha512-7Hys4X6hKuR/lqUaGXwezRzDrwXwu9KfahUy85WTuiG1to+ZbzDCqdbZ04LtnI8kK8ufrPDcq+ZXdyt5ksOJHA==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@fallow-cli/linux-x64-musl@2.102.0':
|
||||
resolution: {integrity: sha512-RuDY1jOEPgJOuHBgEpHVl6J7Xf2QLFklnNy6zO1nw8R1fLgWUAAKlFirn1Y93pb9bXzqpn5Gre3YlhgIZ3+LBA==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@fallow-cli/win32-arm64-msvc@2.102.0':
|
||||
resolution: {integrity: sha512-wsvHjLzWFvsYmCqnQLm1doSEHb9Z038+sFp1RzMcffPUBC5tqS3vCr8J8nolcLlPk7o3QWHQMXAqb1xSJe+doA==}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@fallow-cli/win32-x64-msvc@2.102.0':
|
||||
resolution: {integrity: sha512-rH1hd0PD0mm6pCxh1pw5jubpJsvV6f5rjixMoD5AZLzWa6NPJBpPPuzruLGtH/9CYy8B0y7zPrGGTKRO2PCGzg==}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@firebase/analytics-compat@0.2.18':
|
||||
resolution: {integrity: sha512-Hw9mzsSMZaQu6wrTbi3kYYwGw9nBqOHr47pVLxfr5v8CalsdrG5gfs9XUlPOZjHRVISp3oQrh1j7d3E+ulHPjQ==}
|
||||
peerDependencies:
|
||||
@@ -5728,11 +5682,6 @@ packages:
|
||||
extendable-media-recorder@9.2.27:
|
||||
resolution: {integrity: sha512-2X+Ixi1cxLek0Cj9x9atmhQ+apG+LwJpP2p3ypP8Pxau0poDnicrg7FTfPVQV5PW/3DHFm/eQ16vbgo5Yk3HGQ==}
|
||||
|
||||
fallow@2.102.0:
|
||||
resolution: {integrity: sha512-bkOT58kPVCB12d2apQjIKBw/qSdsGRPQFrN5ff9Yl5WzXRqlDTbT/MVdMXld4sJD5JQW1ftw2bTxJWCINggh6g==}
|
||||
engines: {node: '>=16'}
|
||||
hasBin: true
|
||||
|
||||
fast-check@4.5.3:
|
||||
resolution: {integrity: sha512-IE9csY7lnhxBnA8g/WI5eg/hygA6MGWJMSNfFRrBlXUciADEhS1EDB0SIsMSvzubzIlOBbVITSsypCsW717poA==}
|
||||
engines: {node: '>=12.17.0'}
|
||||
@@ -10095,30 +10044,6 @@ snapshots:
|
||||
|
||||
'@exodus/bytes@1.7.0': {}
|
||||
|
||||
'@fallow-cli/darwin-arm64@2.102.0':
|
||||
optional: true
|
||||
|
||||
'@fallow-cli/darwin-x64@2.102.0':
|
||||
optional: true
|
||||
|
||||
'@fallow-cli/linux-arm64-gnu@2.102.0':
|
||||
optional: true
|
||||
|
||||
'@fallow-cli/linux-arm64-musl@2.102.0':
|
||||
optional: true
|
||||
|
||||
'@fallow-cli/linux-x64-gnu@2.102.0':
|
||||
optional: true
|
||||
|
||||
'@fallow-cli/linux-x64-musl@2.102.0':
|
||||
optional: true
|
||||
|
||||
'@fallow-cli/win32-arm64-msvc@2.102.0':
|
||||
optional: true
|
||||
|
||||
'@fallow-cli/win32-x64-msvc@2.102.0':
|
||||
optional: true
|
||||
|
||||
'@firebase/analytics-compat@0.2.18(@firebase/app-compat@0.2.53)(@firebase/app@0.11.4)':
|
||||
dependencies:
|
||||
'@firebase/analytics': 0.10.12(@firebase/app@0.11.4)
|
||||
@@ -14170,19 +14095,6 @@ snapshots:
|
||||
subscribable-things: 2.1.53
|
||||
tslib: 2.8.1
|
||||
|
||||
fallow@2.102.0:
|
||||
dependencies:
|
||||
detect-libc: 2.1.2
|
||||
optionalDependencies:
|
||||
'@fallow-cli/darwin-arm64': 2.102.0
|
||||
'@fallow-cli/darwin-x64': 2.102.0
|
||||
'@fallow-cli/linux-arm64-gnu': 2.102.0
|
||||
'@fallow-cli/linux-arm64-musl': 2.102.0
|
||||
'@fallow-cli/linux-x64-gnu': 2.102.0
|
||||
'@fallow-cli/linux-x64-musl': 2.102.0
|
||||
'@fallow-cli/win32-arm64-msvc': 2.102.0
|
||||
'@fallow-cli/win32-x64-msvc': 2.102.0
|
||||
|
||||
fast-check@4.5.3:
|
||||
dependencies:
|
||||
pure-rand: 7.0.1
|
||||
|
||||
@@ -89,7 +89,6 @@ catalog:
|
||||
eslint-plugin-testing-library: ^7.16.1
|
||||
eslint-plugin-unused-imports: ^4.4.1
|
||||
eslint-plugin-vue: ^10.9.1
|
||||
fallow: ^2.102.0
|
||||
fast-check: ^4.5.3
|
||||
firebase: ^11.6.0
|
||||
glob: ^13.0.6
|
||||
|
||||
@@ -629,7 +629,7 @@ describe('TopMenuSection', () => {
|
||||
await nextTick()
|
||||
|
||||
expect(querySpy).toHaveBeenCalledTimes(1)
|
||||
expect(actionbarContainer!.classList).not.toContain('w-0')
|
||||
expect(actionbarContainer!.classList).toContain('px-2')
|
||||
} finally {
|
||||
unmount()
|
||||
vi.unstubAllGlobals()
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
</div>
|
||||
|
||||
<div class="mx-1 flex flex-col items-end gap-1">
|
||||
<div class="flex items-start gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<div
|
||||
v-if="managerState.shouldShowManagerButtons.value || isCloud"
|
||||
class="pointer-events-auto flex h-12 shrink-0 items-center rounded-lg border border-interface-stroke bg-comfy-menu-bg px-2 shadow-interface"
|
||||
@@ -34,75 +34,61 @@
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="pointer-events-auto z-1 flex flex-col rounded-lg border border-interface-stroke bg-comfy-menu-bg px-2 py-1.75 shadow-interface"
|
||||
>
|
||||
<div ref="actionbarContainerRef" :class="actionbarContainerClass">
|
||||
<ActionBarButtons />
|
||||
<!-- Support for legacy topbar elements attached by custom scripts, hidden if no elements present -->
|
||||
<div
|
||||
ref="actionbarContainerRef"
|
||||
:class="
|
||||
cn(
|
||||
'actionbar-container relative flex items-center gap-2',
|
||||
isActionbarContainerEmpty &&
|
||||
'-ml-2 w-0 min-w-0 border-transparent shadow-none has-[.border-dashed]:ml-0 has-[.border-dashed]:w-auto has-[.border-dashed]:min-w-auto has-[.border-dashed]:border-interface-stroke has-[.border-dashed]:pl-2 has-[.border-dashed]:shadow-interface'
|
||||
)
|
||||
"
|
||||
>
|
||||
<ActionBarButtons />
|
||||
<!-- Support for legacy topbar elements attached by custom scripts, hidden if no elements present -->
|
||||
<div
|
||||
ref="legacyCommandsContainerRef"
|
||||
data-testid="legacy-topbar-container"
|
||||
class="[&:not(:has(*>*:not(:empty)))]:hidden"
|
||||
></div>
|
||||
ref="legacyCommandsContainerRef"
|
||||
data-testid="legacy-topbar-container"
|
||||
class="[&:not(:has(*>*:not(:empty)))]:hidden"
|
||||
></div>
|
||||
|
||||
<ComfyActionbar
|
||||
:top-menu-container="actionbarContainerRef"
|
||||
:queue-overlay-expanded="isQueueOverlayExpanded"
|
||||
@update:progress-target="updateProgressTarget"
|
||||
/>
|
||||
<CurrentUserButton
|
||||
v-if="isLoggedIn && !isIntegratedTabBar"
|
||||
class="shrink-0"
|
||||
/>
|
||||
<LoginButton v-else-if="isDesktop && !isIntegratedTabBar" />
|
||||
<ComfyActionbar
|
||||
:top-menu-container="actionbarContainerRef"
|
||||
:queue-overlay-expanded="isQueueOverlayExpanded"
|
||||
@update:progress-target="updateProgressTarget"
|
||||
/>
|
||||
<CurrentUserButton
|
||||
v-if="isLoggedIn && !isIntegratedTabBar"
|
||||
class="shrink-0"
|
||||
/>
|
||||
<LoginButton v-else-if="isDesktop && !isIntegratedTabBar" />
|
||||
<Button
|
||||
v-if="isCloud && flags.workflowSharingEnabled"
|
||||
v-tooltip.bottom="shareTooltipConfig"
|
||||
variant="secondary"
|
||||
:aria-label="t('actionbar.shareTooltip')"
|
||||
@click="() => openShareDialog().catch(toastErrorHandler)"
|
||||
@pointerenter="prefetchShareDialog"
|
||||
>
|
||||
<i class="icon-[comfy--send] size-4" />
|
||||
<span class="not-md:hidden">
|
||||
{{ t('actionbar.share') }}
|
||||
</span>
|
||||
</Button>
|
||||
<div v-if="!isRightSidePanelOpen" class="relative">
|
||||
<Button
|
||||
v-if="isCloud && flags.workflowSharingEnabled"
|
||||
v-tooltip.bottom="shareTooltipConfig"
|
||||
v-tooltip.bottom="rightSidePanelTooltipConfig"
|
||||
:class="
|
||||
cn(
|
||||
showErrorIndicatorOnPanelButton &&
|
||||
'outline-1 outline-destructive-background'
|
||||
)
|
||||
"
|
||||
variant="secondary"
|
||||
:aria-label="t('actionbar.shareTooltip')"
|
||||
@click="() => openShareDialog().catch(toastErrorHandler)"
|
||||
@pointerenter="prefetchShareDialog"
|
||||
size="icon"
|
||||
:aria-label="t('rightSidePanel.togglePanel')"
|
||||
@click="openRightSidePanel"
|
||||
>
|
||||
<i class="icon-[comfy--send] size-4" />
|
||||
<span class="not-md:hidden">
|
||||
{{ t('actionbar.share') }}
|
||||
</span>
|
||||
<i class="icon-[lucide--panel-right] size-4" />
|
||||
</Button>
|
||||
<div v-if="!isRightSidePanelOpen" class="relative">
|
||||
<Button
|
||||
v-tooltip.bottom="rightSidePanelTooltipConfig"
|
||||
:class="
|
||||
cn(
|
||||
showErrorIndicatorOnPanelButton &&
|
||||
'outline-1 outline-destructive-background'
|
||||
)
|
||||
"
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
:aria-label="t('rightSidePanel.togglePanel')"
|
||||
@click="openRightSidePanel"
|
||||
>
|
||||
<i class="icon-[lucide--panel-right] size-4" />
|
||||
</Button>
|
||||
<StatusBadge
|
||||
v-if="showErrorIndicatorOnPanelButton"
|
||||
variant="dot"
|
||||
severity="danger"
|
||||
class="absolute -top-1 -right-1"
|
||||
/>
|
||||
</div>
|
||||
<StatusBadge
|
||||
v-if="showErrorIndicatorOnPanelButton"
|
||||
variant="dot"
|
||||
severity="danger"
|
||||
class="absolute -top-1 -right-1"
|
||||
/>
|
||||
</div>
|
||||
<FreeTierQuota v-if="!isActionbarFloating" />
|
||||
</div>
|
||||
</div>
|
||||
<ErrorOverlay />
|
||||
@@ -161,7 +147,6 @@ import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { useQueueFeatureFlags } from '@/composables/queue/useQueueFeatureFlags'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
|
||||
import FreeTierQuota from '@/platform/cloud/subscription/components/FreeTierQuota.vue'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { app } from '@/scripts/app'
|
||||
@@ -224,6 +209,21 @@ const hasDockedButtons = computed(() => {
|
||||
const isActionbarContainerEmpty = computed(
|
||||
() => isActionbarFloating.value && !hasDockedButtons.value
|
||||
)
|
||||
const actionbarContainerClass = computed(() => {
|
||||
const base =
|
||||
'actionbar-container pointer-events-auto relative flex h-12 items-center gap-2 rounded-lg border bg-comfy-menu-bg shadow-interface'
|
||||
|
||||
if (isActionbarContainerEmpty.value) {
|
||||
return cn(
|
||||
base,
|
||||
'-ml-2 w-0 min-w-0 border-transparent shadow-none',
|
||||
'has-[.border-dashed]:ml-0 has-[.border-dashed]:w-auto has-[.border-dashed]:min-w-auto',
|
||||
'has-[.border-dashed]:border-interface-stroke has-[.border-dashed]:pl-2 has-[.border-dashed]:shadow-interface'
|
||||
)
|
||||
}
|
||||
|
||||
return cn(base, 'px-2', 'border-interface-stroke')
|
||||
})
|
||||
const isIntegratedTabBar = computed(
|
||||
() => settingStore.get('Comfy.UI.TabBarLayout') !== 'Legacy'
|
||||
)
|
||||
|
||||
@@ -75,7 +75,6 @@
|
||||
</Button>
|
||||
<ContextMenu ref="queueContextMenu" :model="queueContextMenuItems" />
|
||||
</div>
|
||||
<FreeTierQuota v-if="!isDocked" />
|
||||
</Panel>
|
||||
|
||||
<Teleport v-if="inlineProgressTarget" :to="inlineProgressTarget">
|
||||
@@ -110,7 +109,6 @@ import QueueInlineProgress from '@/components/queue/QueueInlineProgress.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useQueueFeatureFlags } from '@/composables/queue/useQueueFeatureFlags'
|
||||
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
|
||||
import FreeTierQuota from '@/platform/cloud/subscription/components/FreeTierQuota.vue'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
|
||||
@@ -4,11 +4,11 @@ import { nextTick, ref } from 'vue'
|
||||
|
||||
import CloudRunButtonWrapper from './CloudRunButtonWrapper.vue'
|
||||
|
||||
const mockCanRunWorkflows = ref(true)
|
||||
const mockIsActiveSubscription = ref(true)
|
||||
|
||||
vi.mock('@/composables/billing/useBillingContext', () => ({
|
||||
useBillingContext: () => ({
|
||||
canRunWorkflows: mockCanRunWorkflows
|
||||
isActiveSubscription: mockIsActiveSubscription
|
||||
})
|
||||
}))
|
||||
|
||||
@@ -32,7 +32,7 @@ function renderWrapper() {
|
||||
|
||||
describe('CloudRunButtonWrapper', () => {
|
||||
beforeEach(() => {
|
||||
mockCanRunWorkflows.value = true
|
||||
mockIsActiveSubscription.value = true
|
||||
})
|
||||
|
||||
it('renders the runnable queue button when the subscription is active', () => {
|
||||
@@ -45,7 +45,7 @@ describe('CloudRunButtonWrapper', () => {
|
||||
})
|
||||
|
||||
it('locks the run button when the subscription is inactive', () => {
|
||||
mockCanRunWorkflows.value = false
|
||||
mockIsActiveSubscription.value = false
|
||||
renderWrapper()
|
||||
|
||||
expect(screen.getByTestId('subscribe-to-run-button')).toBeInTheDocument()
|
||||
@@ -53,12 +53,12 @@ describe('CloudRunButtonWrapper', () => {
|
||||
})
|
||||
|
||||
it('unlocks the run button once the subscription becomes active again', async () => {
|
||||
mockCanRunWorkflows.value = false
|
||||
mockIsActiveSubscription.value = false
|
||||
renderWrapper()
|
||||
|
||||
expect(screen.getByTestId('subscribe-to-run-button')).toBeInTheDocument()
|
||||
|
||||
mockCanRunWorkflows.value = true
|
||||
mockIsActiveSubscription.value = true
|
||||
await nextTick()
|
||||
|
||||
expect(screen.getByTestId('queue-button')).toBeInTheDocument()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<component
|
||||
:is="currentButton"
|
||||
:key="canRunWorkflows ? 'queue' : 'subscribe'"
|
||||
:key="isActiveSubscription ? 'queue' : 'subscribe'"
|
||||
/>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
@@ -11,9 +11,9 @@ import ComfyQueueButton from '@/components/actionbar/ComfyRunButton/ComfyQueueBu
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import SubscribeToRunButton from '@/platform/cloud/subscription/components/SubscribeToRun.vue'
|
||||
|
||||
const { canRunWorkflows } = useBillingContext()
|
||||
const { isActiveSubscription } = useBillingContext()
|
||||
|
||||
const currentButton = computed(() =>
|
||||
canRunWorkflows.value ? ComfyQueueButton : SubscribeToRunButton
|
||||
isActiveSubscription.value ? ComfyQueueButton : SubscribeToRunButton
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -112,13 +112,5 @@ export interface BillingContext extends BillingState, BillingActions {
|
||||
* (legacy) per-member tier plan, which keeps the old team pricing table.
|
||||
*/
|
||||
isLegacyTeamPlan: ComputedRef<boolean>
|
||||
/**
|
||||
* True when the subscription is a team plan of either generation. Unlike
|
||||
* `isLegacyTeamPlan` this does not require an active subscription: the spend
|
||||
* gate folds billing_status into is_active, so a paused or payment-failed team
|
||||
* plan reports is_active=false and must still read as a team plan.
|
||||
*/
|
||||
isTeamPlan: ComputedRef<boolean>
|
||||
getMaxSeats: (tierKey: TierKey) => number
|
||||
canRunWorkflows: ComputedRef<boolean>
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ const DEFAULT_BILLING_STATUS: BillingStatusResponse = {
|
||||
|
||||
const {
|
||||
mockTeamWorkspacesEnabled,
|
||||
mockBillingControlEnabled,
|
||||
mockConsolidatedBillingEnabled,
|
||||
mockIsPersonal,
|
||||
mockPlans,
|
||||
mockPurchaseCredits,
|
||||
@@ -27,7 +27,7 @@ const {
|
||||
mockBillingStatus
|
||||
} = vi.hoisted(() => ({
|
||||
mockTeamWorkspacesEnabled: { value: false },
|
||||
mockBillingControlEnabled: { value: false },
|
||||
mockConsolidatedBillingEnabled: { value: false },
|
||||
mockIsPersonal: { value: true },
|
||||
mockPlans: { value: [] as Plan[] },
|
||||
mockPurchaseCredits: vi.fn(),
|
||||
@@ -59,11 +59,13 @@ vi.mock('@/composables/useFeatureFlags', async () => {
|
||||
teamWorkspacesEnabledRef.value = value
|
||||
}
|
||||
})
|
||||
const billingControlEnabledRef = ref(mockBillingControlEnabled.value)
|
||||
Object.defineProperty(mockBillingControlEnabled, 'value', {
|
||||
get: () => billingControlEnabledRef.value,
|
||||
const consolidatedBillingEnabledRef = ref(
|
||||
mockConsolidatedBillingEnabled.value
|
||||
)
|
||||
Object.defineProperty(mockConsolidatedBillingEnabled, 'value', {
|
||||
get: () => consolidatedBillingEnabledRef.value,
|
||||
set: (value: boolean) => {
|
||||
billingControlEnabledRef.value = value
|
||||
consolidatedBillingEnabledRef.value = value
|
||||
}
|
||||
})
|
||||
return {
|
||||
@@ -72,8 +74,8 @@ vi.mock('@/composables/useFeatureFlags', async () => {
|
||||
get teamWorkspacesEnabled() {
|
||||
return mockTeamWorkspacesEnabled.value
|
||||
},
|
||||
get billingControlEnabled() {
|
||||
return mockBillingControlEnabled.value
|
||||
get consolidatedBillingEnabled() {
|
||||
return mockConsolidatedBillingEnabled.value
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -163,7 +165,7 @@ describe('useBillingContext', () => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
mockTeamWorkspacesEnabled.value = false
|
||||
mockBillingControlEnabled.value = false
|
||||
mockConsolidatedBillingEnabled.value = false
|
||||
mockIsPersonal.value = true
|
||||
mockPlans.value = []
|
||||
mockBillingStatus.value = { ...DEFAULT_BILLING_STATUS }
|
||||
@@ -175,27 +177,27 @@ describe('useBillingContext', () => {
|
||||
expect(type.value).toBe('legacy')
|
||||
})
|
||||
|
||||
it('keeps personal on legacy when billing control is disabled', () => {
|
||||
it('keeps personal on legacy when consolidated billing is disabled', () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockBillingControlEnabled.value = false
|
||||
mockConsolidatedBillingEnabled.value = false
|
||||
mockIsPersonal.value = true
|
||||
|
||||
const { type } = useBillingContext()
|
||||
expect(type.value).toBe('legacy')
|
||||
})
|
||||
|
||||
it('selects workspace type for personal when billing control is enabled', () => {
|
||||
it('selects workspace type for personal when consolidated billing is enabled', () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockBillingControlEnabled.value = true
|
||||
mockConsolidatedBillingEnabled.value = true
|
||||
mockIsPersonal.value = true
|
||||
|
||||
const { type } = useBillingContext()
|
||||
expect(type.value).toBe('workspace')
|
||||
})
|
||||
|
||||
it('selects workspace type for team regardless of billing control', () => {
|
||||
it('selects workspace type for team regardless of consolidated billing', () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockBillingControlEnabled.value = false
|
||||
mockConsolidatedBillingEnabled.value = false
|
||||
mockIsPersonal.value = false
|
||||
|
||||
const { type } = useBillingContext()
|
||||
@@ -296,7 +298,7 @@ describe('useBillingContext', () => {
|
||||
expect(workspaceApi.getBillingStatus).not.toHaveBeenCalled()
|
||||
|
||||
// Authenticated remote config resolves the flag on for the same workspace
|
||||
mockBillingControlEnabled.value = true
|
||||
mockConsolidatedBillingEnabled.value = true
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
|
||||
await vi.waitFor(() => {
|
||||
@@ -305,16 +307,16 @@ describe('useBillingContext', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('moves a personal workspace to workspace billing when billing control flips on', async () => {
|
||||
it('moves a personal workspace to workspace billing when consolidated billing flips on', async () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockBillingControlEnabled.value = false
|
||||
mockConsolidatedBillingEnabled.value = false
|
||||
mockIsPersonal.value = true
|
||||
|
||||
const { type } = useBillingContext()
|
||||
await nextTick()
|
||||
expect(type.value).toBe('legacy')
|
||||
|
||||
mockBillingControlEnabled.value = true
|
||||
mockConsolidatedBillingEnabled.value = true
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(type.value).toBe('workspace')
|
||||
@@ -323,9 +325,9 @@ describe('useBillingContext', () => {
|
||||
})
|
||||
|
||||
describe('subscription mirror to workspace store', () => {
|
||||
it('mirrors subscription for personal workspaces on the billing control flow', async () => {
|
||||
it('mirrors subscription for personal workspaces on the consolidated billing flow', async () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockBillingControlEnabled.value = true
|
||||
mockConsolidatedBillingEnabled.value = true
|
||||
mockIsPersonal.value = true
|
||||
|
||||
const { initialize } = useBillingContext()
|
||||
@@ -553,110 +555,4 @@ describe('useBillingContext', () => {
|
||||
expect(isLegacyTeamPlan.value).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isTeamPlan', () => {
|
||||
it('is false for a personal workspace', () => {
|
||||
const { isTeamPlan } = useBillingContext()
|
||||
expect(isTeamPlan.value).toBe(false)
|
||||
})
|
||||
|
||||
// subscription_tier is omitted throughout: the backend sends 'TEAM' here, but
|
||||
// the FE's SubscriptionTier resolves to the registry spec, which has no TEAM
|
||||
// (tierPricing.ts imports comfyRegistryTypes for what is an ingest field).
|
||||
// isTeamPlan reads the credit stop and the slug, never the tier — which is
|
||||
// what keeps it working despite that divergence.
|
||||
it('is true for a credit-slider team sub, which carries a credit stop', async () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockIsPersonal.value = false
|
||||
mockBillingStatus.value = {
|
||||
is_active: true,
|
||||
has_funds: true,
|
||||
plan_slug: 'team_per_credit_monthly',
|
||||
team_credit_stop: {
|
||||
id: 'team_700',
|
||||
credits_monthly: 700,
|
||||
stop_usd: 332
|
||||
}
|
||||
}
|
||||
|
||||
const { initialize, isTeamPlan } = useBillingContext()
|
||||
await initialize()
|
||||
|
||||
expect(isTeamPlan.value).toBe(true)
|
||||
})
|
||||
|
||||
it('is true for a legacy team sub, identified by slug rather than credit stop', async () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockIsPersonal.value = false
|
||||
mockBillingStatus.value = {
|
||||
is_active: true,
|
||||
has_funds: true,
|
||||
subscription_tier: 'STANDARD',
|
||||
plan_slug: 'team-standard-annual'
|
||||
}
|
||||
|
||||
const { initialize, isTeamPlan } = useBillingContext()
|
||||
await initialize()
|
||||
|
||||
expect(isTeamPlan.value).toBe(true)
|
||||
})
|
||||
|
||||
// The banner states that need isTeamPlan most — paused and payment_failed —
|
||||
// are exactly the ones the backend reports with is_active=false, because the
|
||||
// spend gate folds billing_status into it. Coupling isTeamPlan to an active
|
||||
// subscription would blank the banner precisely when it is needed.
|
||||
it('stays true for a paused team plan, which the backend reports inactive', async () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockIsPersonal.value = false
|
||||
mockBillingStatus.value = {
|
||||
is_active: false,
|
||||
has_funds: true,
|
||||
billing_status: 'paused',
|
||||
plan_slug: 'team_per_credit_monthly',
|
||||
team_credit_stop: {
|
||||
id: 'team_700',
|
||||
credits_monthly: 700,
|
||||
stop_usd: 332
|
||||
}
|
||||
}
|
||||
|
||||
const { initialize, isTeamPlan } = useBillingContext()
|
||||
await initialize()
|
||||
|
||||
expect(isTeamPlan.value).toBe(true)
|
||||
})
|
||||
|
||||
it('stays true for a legacy team plan whose payment failed', async () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockIsPersonal.value = false
|
||||
mockBillingStatus.value = {
|
||||
is_active: false,
|
||||
has_funds: true,
|
||||
billing_status: 'payment_failed',
|
||||
subscription_tier: 'STANDARD',
|
||||
plan_slug: 'team-standard-annual'
|
||||
}
|
||||
|
||||
const { initialize, isTeamPlan } = useBillingContext()
|
||||
await initialize()
|
||||
|
||||
expect(isTeamPlan.value).toBe(true)
|
||||
})
|
||||
|
||||
it('is false for a team workspace on a personal-tier plan', async () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockIsPersonal.value = false
|
||||
mockBillingStatus.value = {
|
||||
is_active: true,
|
||||
has_funds: true,
|
||||
subscription_tier: 'PRO',
|
||||
plan_slug: 'pro-monthly'
|
||||
}
|
||||
|
||||
const { initialize, isTeamPlan } = useBillingContext()
|
||||
await initialize()
|
||||
|
||||
expect(isTeamPlan.value).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
getTierFeatures
|
||||
} from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import { useFreeTierQuota } from '@/platform/cloud/subscription/composables/useFreeTierQuota'
|
||||
import type { SubscriptionDialogOptions } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
|
||||
import type {
|
||||
PreviewSubscribeOptions,
|
||||
@@ -36,8 +35,8 @@ const LEGACY_TEAM_PLAN_SLUG_PREFIX = 'team-'
|
||||
*
|
||||
* - Team workspaces disabled (OSS/Desktop): legacy billing via /customers/*
|
||||
* - Team workspaces enabled: workspace billing via /api/billing/* for team
|
||||
* workspaces, and for personal workspaces once billing control is enabled;
|
||||
* personal workspaces otherwise stay on legacy billing
|
||||
* workspaces, and for personal workspaces once consolidated billing is
|
||||
* enabled; personal workspaces otherwise stay on legacy billing
|
||||
*
|
||||
* The context automatically initializes when the workspace changes and provides
|
||||
* a unified interface for subscription status, balance, and billing actions.
|
||||
@@ -130,16 +129,6 @@ function useBillingContextInternal(): BillingContext {
|
||||
|
||||
const isFreeTier = computed(() => subscription.value?.tier === 'FREE')
|
||||
|
||||
const freeTierQuota = useFreeTierQuota()
|
||||
|
||||
const canRunWorkflows = computed(
|
||||
() =>
|
||||
isActiveSubscription.value &&
|
||||
(!isFreeTier.value ||
|
||||
!freeTierQuota.quotaEnabled.value ||
|
||||
freeTierQuota.freeTierExecutionPermitted.value)
|
||||
)
|
||||
|
||||
const isLegacyTeamPlan = computed(
|
||||
() =>
|
||||
type.value === 'workspace' &&
|
||||
@@ -152,21 +141,6 @@ function useBillingContextInternal(): BillingContext {
|
||||
false)
|
||||
)
|
||||
|
||||
// Plan identity, independent of subscription health: the per-credit Team plan
|
||||
// carries a credit stop, the retired seat-based ones a `team-` slug. Kept off
|
||||
// isActiveSubscription on purpose — paused and payment_failed both force
|
||||
// is_active=false, which is exactly when callers still need to know this is a
|
||||
// team plan.
|
||||
const isTeamPlan = computed(
|
||||
() =>
|
||||
type.value === 'workspace' &&
|
||||
(currentTeamCreditStop.value !== null ||
|
||||
(currentPlanSlug.value
|
||||
?.toLowerCase()
|
||||
.startsWith(LEGACY_TEAM_PLAN_SLUG_PREFIX) ??
|
||||
false))
|
||||
)
|
||||
|
||||
const billingStatus = computed(() =>
|
||||
toValue(activeContext.value.billingStatus)
|
||||
)
|
||||
@@ -217,9 +191,9 @@ function useBillingContextInternal(): BillingContext {
|
||||
error.value = null
|
||||
}
|
||||
|
||||
// type flips when the team-workspaces or billing-control flag resolves from
|
||||
// authenticated config, swapping the active backend. Reset then reinit on
|
||||
// every workspace-id or type change.
|
||||
// type flips when the team-workspaces or consolidated-billing flag resolves
|
||||
// from authenticated config, swapping the active backend. Reset then reinit
|
||||
// on every workspace-id or type change.
|
||||
watch(
|
||||
[() => store.activeWorkspace?.id, () => type.value],
|
||||
async ([newWorkspaceId]) => {
|
||||
@@ -323,10 +297,8 @@ function useBillingContextInternal(): BillingContext {
|
||||
isLoading,
|
||||
error,
|
||||
isActiveSubscription,
|
||||
canRunWorkflows,
|
||||
isFreeTier,
|
||||
isLegacyTeamPlan,
|
||||
isTeamPlan,
|
||||
billingStatus,
|
||||
subscriptionStatus,
|
||||
tier,
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useBillingRouting } from './useBillingRouting'
|
||||
const { mockFlags, mockActiveWorkspace } = vi.hoisted(() => ({
|
||||
mockFlags: {
|
||||
teamWorkspacesEnabled: false,
|
||||
billingControlEnabled: false
|
||||
consolidatedBillingEnabled: false
|
||||
},
|
||||
mockActiveWorkspace: {
|
||||
value: null as { id: string; type: 'personal' | 'team' } | null
|
||||
@@ -30,7 +30,7 @@ const team = { id: 'w-team', type: 'team' as const }
|
||||
describe('useBillingRouting', () => {
|
||||
beforeEach(() => {
|
||||
mockFlags.teamWorkspacesEnabled = false
|
||||
mockFlags.billingControlEnabled = false
|
||||
mockFlags.consolidatedBillingEnabled = false
|
||||
mockActiveWorkspace.value = personal
|
||||
})
|
||||
|
||||
@@ -44,9 +44,9 @@ describe('useBillingRouting', () => {
|
||||
expect(shouldUseWorkspaceBilling.value).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps personal on legacy when billing control is disabled', () => {
|
||||
it('keeps personal on legacy when consolidated billing is disabled', () => {
|
||||
mockFlags.teamWorkspacesEnabled = true
|
||||
mockFlags.billingControlEnabled = false
|
||||
mockFlags.consolidatedBillingEnabled = false
|
||||
mockActiveWorkspace.value = personal
|
||||
|
||||
const { type } = useBillingRouting()
|
||||
@@ -54,9 +54,9 @@ describe('useBillingRouting', () => {
|
||||
expect(type.value).toBe('legacy')
|
||||
})
|
||||
|
||||
it('moves personal to workspace billing when billing control is enabled', () => {
|
||||
it('moves personal to workspace billing when consolidated billing is enabled', () => {
|
||||
mockFlags.teamWorkspacesEnabled = true
|
||||
mockFlags.billingControlEnabled = true
|
||||
mockFlags.consolidatedBillingEnabled = true
|
||||
mockActiveWorkspace.value = personal
|
||||
|
||||
const { type, shouldUseWorkspaceBilling } = useBillingRouting()
|
||||
@@ -65,9 +65,9 @@ describe('useBillingRouting', () => {
|
||||
expect(shouldUseWorkspaceBilling.value).toBe(true)
|
||||
})
|
||||
|
||||
it('uses workspace billing for team workspaces regardless of billing control', () => {
|
||||
it('uses workspace billing for team workspaces regardless of consolidated billing', () => {
|
||||
mockFlags.teamWorkspacesEnabled = true
|
||||
mockFlags.billingControlEnabled = false
|
||||
mockFlags.consolidatedBillingEnabled = false
|
||||
mockActiveWorkspace.value = team
|
||||
|
||||
const { type, shouldUseWorkspaceBilling } = useBillingRouting()
|
||||
@@ -76,9 +76,9 @@ describe('useBillingRouting', () => {
|
||||
expect(shouldUseWorkspaceBilling.value).toBe(true)
|
||||
})
|
||||
|
||||
it('uses workspace billing for team workspaces with billing control enabled', () => {
|
||||
it('uses workspace billing for team workspaces with consolidated billing enabled', () => {
|
||||
mockFlags.teamWorkspacesEnabled = true
|
||||
mockFlags.billingControlEnabled = true
|
||||
mockFlags.consolidatedBillingEnabled = true
|
||||
mockActiveWorkspace.value = team
|
||||
|
||||
const { type, shouldUseWorkspaceBilling } = useBillingRouting()
|
||||
@@ -89,7 +89,7 @@ describe('useBillingRouting', () => {
|
||||
|
||||
it('defaults to legacy while the workspace has not loaded', () => {
|
||||
mockFlags.teamWorkspacesEnabled = true
|
||||
mockFlags.billingControlEnabled = true
|
||||
mockFlags.consolidatedBillingEnabled = true
|
||||
mockActiveWorkspace.value = null
|
||||
|
||||
const { type } = useBillingRouting()
|
||||
|
||||
@@ -8,7 +8,7 @@ import type { BillingType } from './types'
|
||||
/**
|
||||
* Selects the billing backend for the active workspace: legacy user-scoped
|
||||
* (`/customers/*`) or workspace-scoped (`/api/billing/*`). Personal workspaces
|
||||
* stay legacy until `billingControlEnabled`; team workspaces are always
|
||||
* stay legacy until `consolidatedBillingEnabled`; team workspaces are always
|
||||
* workspace-scoped. The routing matrix is covered in useBillingRouting.test.ts.
|
||||
*/
|
||||
export function useBillingRouting() {
|
||||
@@ -23,7 +23,7 @@ export function useBillingRouting() {
|
||||
const workspaceType = workspaceStore.activeWorkspace?.type
|
||||
if (!workspaceType) return 'legacy'
|
||||
|
||||
if (workspaceType === 'personal' && !flags.billingControlEnabled) {
|
||||
if (workspaceType === 'personal' && !flags.consolidatedBillingEnabled) {
|
||||
return 'legacy'
|
||||
}
|
||||
|
||||
|
||||
@@ -1,19 +1,12 @@
|
||||
import { createSharedComposable } from '@vueuse/core'
|
||||
import { computed, toValue } from 'vue'
|
||||
|
||||
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { LGraphBadge } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
import { useVueNodeLifecycle } from '@/composables/graph/useVueNodeLifecycle'
|
||||
import { useNodePricing } from '@/composables/node/useNodePricing'
|
||||
import type { INodeInputSlot } from '@/lib/litegraph/src/interfaces'
|
||||
import type { SubgraphInput } from '@/lib/litegraph/src/subgraph/SubgraphInput'
|
||||
import { trackNodePrice } from '@/renderer/extensions/vueNodes/composables/usePartitionedBadges'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
|
||||
import { adjustColor } from '@/utils/colorUtil'
|
||||
import { mapAllNodes } from '@/utils/graphTraversalUtil'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
|
||||
type LinkedWidgetInput = INodeInputSlot & {
|
||||
_subgraphSlot?: SubgraphInput
|
||||
@@ -157,20 +150,3 @@ export const usePriceBadge = () => {
|
||||
updateSubgraphCredits
|
||||
}
|
||||
}
|
||||
export const useCreditsBadgesInGraph = createSharedComposable(() => {
|
||||
const { isCreditsBadge } = usePriceBadge()
|
||||
const vueNodeLifecycle = useVueNodeLifecycle()
|
||||
return computed(() => {
|
||||
void vueNodeLifecycle.nodeManager.value?.vueNodeData.size
|
||||
if (!app.graph) return []
|
||||
return mapAllNodes(app.graph, (node) => {
|
||||
if (node.isSubgraphNode()) return
|
||||
|
||||
const priceBadge = node.badges.find(isCreditsBadge)
|
||||
if (!priceBadge) return
|
||||
|
||||
trackNodePrice(node)
|
||||
return [node.title, toValue(priceBadge).text, node.id] as const
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
} from '@/composables/useFeatureFlags'
|
||||
import * as distributionTypes from '@/platform/distribution/types'
|
||||
import {
|
||||
cachedBillingControlEnabled,
|
||||
cachedConsolidatedBillingEnabled,
|
||||
cachedTeamWorkspacesEnabled,
|
||||
remoteConfig,
|
||||
remoteConfigState
|
||||
@@ -226,19 +226,19 @@ describe('useFeatureFlags', () => {
|
||||
expect(flags.teamWorkspacesEnabled).toBe(true)
|
||||
})
|
||||
|
||||
it('billingControlEnabled override bypasses isCloud and isAuthenticatedConfigLoaded guards', () => {
|
||||
it('consolidatedBillingEnabled override bypasses isCloud and isAuthenticatedConfigLoaded guards', () => {
|
||||
vi.mocked(distributionTypes).isCloud = false
|
||||
localStorage.setItem('ff:billing_control_enabled', 'true')
|
||||
localStorage.setItem('ff:consolidated_billing_enabled', 'true')
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
expect(flags.billingControlEnabled).toBe(true)
|
||||
expect(flags.consolidatedBillingEnabled).toBe(true)
|
||||
})
|
||||
|
||||
it('billingControlEnabled is false off-cloud even without an override', () => {
|
||||
it('consolidatedBillingEnabled is false off-cloud even without an override', () => {
|
||||
vi.mocked(distributionTypes).isCloud = false
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
expect(flags.billingControlEnabled).toBe(false)
|
||||
expect(flags.consolidatedBillingEnabled).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -248,7 +248,7 @@ describe('useFeatureFlags', () => {
|
||||
remoteConfigState.value = 'unloaded'
|
||||
remoteConfig.value = {}
|
||||
cachedTeamWorkspacesEnabled.value = undefined
|
||||
cachedBillingControlEnabled.value = undefined
|
||||
cachedConsolidatedBillingEnabled.value = undefined
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
@@ -257,36 +257,36 @@ describe('useFeatureFlags', () => {
|
||||
remoteConfigState.value = 'unloaded'
|
||||
remoteConfig.value = {}
|
||||
cachedTeamWorkspacesEnabled.value = undefined
|
||||
cachedBillingControlEnabled.value = undefined
|
||||
cachedConsolidatedBillingEnabled.value = undefined
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
it('returns the cached session value during the auth window', () => {
|
||||
cachedTeamWorkspacesEnabled.value = false
|
||||
cachedBillingControlEnabled.value = true
|
||||
cachedConsolidatedBillingEnabled.value = true
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
expect(flags.teamWorkspacesEnabled).toBe(false)
|
||||
expect(flags.billingControlEnabled).toBe(true)
|
||||
expect(flags.consolidatedBillingEnabled).toBe(true)
|
||||
})
|
||||
|
||||
it('defaults to false during the auth window when nothing is cached', () => {
|
||||
const { flags } = useFeatureFlags()
|
||||
expect(flags.teamWorkspacesEnabled).toBe(false)
|
||||
expect(flags.billingControlEnabled).toBe(false)
|
||||
expect(flags.consolidatedBillingEnabled).toBe(false)
|
||||
})
|
||||
|
||||
it('prefers authenticated remoteConfig over the server feature fallback', () => {
|
||||
remoteConfigState.value = 'authenticated'
|
||||
remoteConfig.value = {
|
||||
team_workspaces_enabled: true,
|
||||
billing_control_enabled: true
|
||||
consolidated_billing_enabled: true
|
||||
}
|
||||
vi.mocked(api.getServerFeature).mockReturnValue(false)
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
expect(flags.teamWorkspacesEnabled).toBe(true)
|
||||
expect(flags.billingControlEnabled).toBe(true)
|
||||
expect(flags.consolidatedBillingEnabled).toBe(true)
|
||||
})
|
||||
|
||||
it('falls back to api.getServerFeature when authenticated config omits the flag', () => {
|
||||
@@ -295,14 +295,15 @@ describe('useFeatureFlags', () => {
|
||||
vi.mocked(api.getServerFeature).mockImplementation(
|
||||
(path, defaultValue) => {
|
||||
if (path === ServerFeatureFlag.TEAM_WORKSPACES_ENABLED) return true
|
||||
if (path === ServerFeatureFlag.BILLING_CONTROL_ENABLED) return true
|
||||
if (path === ServerFeatureFlag.CONSOLIDATED_BILLING_ENABLED)
|
||||
return true
|
||||
return defaultValue
|
||||
}
|
||||
)
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
expect(flags.teamWorkspacesEnabled).toBe(true)
|
||||
expect(flags.billingControlEnabled).toBe(true)
|
||||
expect(flags.consolidatedBillingEnabled).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { Ref } from 'vue'
|
||||
|
||||
import { isCloud, isNightly } from '@/platform/distribution/types'
|
||||
import {
|
||||
cachedBillingControlEnabled,
|
||||
cachedConsolidatedBillingEnabled,
|
||||
cachedTeamWorkspacesEnabled,
|
||||
isAuthenticatedConfigLoaded,
|
||||
remoteConfig
|
||||
@@ -32,8 +32,7 @@ export enum ServerFeatureFlag {
|
||||
COMFYHUB_PROFILE_GATE_ENABLED = 'comfyhub_profile_gate_enabled',
|
||||
SHOW_SIGNIN_BUTTON = 'show_signin_button',
|
||||
UNIFIED_CLOUD_AUTH = 'unified_cloud_auth',
|
||||
BILLING_CONTROL_ENABLED = 'billing_control_enabled',
|
||||
FREE_TIER_JOB_ALLOWANCE_ENABLED = 'free_tier_job_allowance_enabled',
|
||||
CONSOLIDATED_BILLING_ENABLED = 'consolidated_billing_enabled',
|
||||
SIGNUP_TURNSTILE = 'signup_turnstile'
|
||||
}
|
||||
|
||||
@@ -192,25 +191,15 @@ export function useFeatureFlags() {
|
||||
)
|
||||
},
|
||||
/**
|
||||
* Whether personal workspaces use the workspace-scoped billing flow. While
|
||||
* false (default), personal workspaces stay on the legacy per-user billing
|
||||
* flow; team workspaces are unaffected.
|
||||
* Whether personal workspaces use the consolidated (workspace-scoped)
|
||||
* billing flow. While false (default), personal workspaces stay on the
|
||||
* legacy per-user billing flow; team workspaces are unaffected.
|
||||
*/
|
||||
get billingControlEnabled() {
|
||||
get consolidatedBillingEnabled() {
|
||||
return resolveAuthGatedFlag(
|
||||
ServerFeatureFlag.BILLING_CONTROL_ENABLED,
|
||||
remoteConfig.value.billing_control_enabled,
|
||||
cachedBillingControlEnabled
|
||||
)
|
||||
},
|
||||
get freeTierJobAllowanceEnabled() {
|
||||
const config = remoteConfig.value as typeof remoteConfig.value & {
|
||||
free_tier_job_allowance_enabled?: boolean
|
||||
}
|
||||
return resolveFlag(
|
||||
ServerFeatureFlag.FREE_TIER_JOB_ALLOWANCE_ENABLED,
|
||||
config.free_tier_job_allowance_enabled,
|
||||
false
|
||||
ServerFeatureFlag.CONSOLIDATED_BILLING_ENABLED,
|
||||
remoteConfig.value.consolidated_billing_enabled,
|
||||
cachedConsolidatedBillingEnabled
|
||||
)
|
||||
},
|
||||
get signupTurnstileMode() {
|
||||
|
||||
@@ -2473,11 +2473,6 @@
|
||||
},
|
||||
"credits": {
|
||||
"activity": "Activity",
|
||||
"insufficient": {
|
||||
"memberTitle": "This workspace is out of credits",
|
||||
"memberDescription": "Your team has used all its credits. Your workspace admins need to add more credits to run workflows.",
|
||||
"memberCta": "Ok, got it"
|
||||
},
|
||||
"credits": "Credits",
|
||||
"yourCreditBalance": "Your credit balance",
|
||||
"purchaseCredits": "Purchase Credits",
|
||||
@@ -2887,32 +2882,6 @@
|
||||
"updatePassword": "Update Password"
|
||||
},
|
||||
"workspacePanel": {
|
||||
"billingStatus": {
|
||||
"warning": {
|
||||
"title": "Payment declined",
|
||||
"body": "Your last payment didn't go through. Your subscription will pause on {date} unless payment is updated.",
|
||||
"bodyNoDate": "Your last payment didn't go through. Update payment to avoid a pause."
|
||||
},
|
||||
"paused": {
|
||||
"title": "Subscription paused",
|
||||
"body": "This workspace's subscription is paused. Update payment to resume.",
|
||||
"memberBody": "This workspace's subscription is paused. Your workspace admins need to update the payment method."
|
||||
},
|
||||
"outOfCredits": {
|
||||
"title": "Out of credits",
|
||||
"body": "Your team has used all its credits. Add more credits to continue generating or wait until credits refill on {date}.",
|
||||
"bodyNoDate": "Your team has used all its credits. Add more credits to continue generating.",
|
||||
"memberBody": "Your team has used all its credits. Your workspace admins need to add more credits to continue generating.",
|
||||
"addCredits": "Add credits",
|
||||
"dismiss": "Dismiss"
|
||||
},
|
||||
"ending": {
|
||||
"title": "Your team plan ends on {date}",
|
||||
"body": "Members keep full access until then. Reactivate to keep your shared credits and seats.",
|
||||
"reactivate": "Reactivate plan"
|
||||
},
|
||||
"updatePayment": "Update payment"
|
||||
},
|
||||
"invite": "Invite",
|
||||
"inviteMember": "Invite member",
|
||||
"inviteLimitReached": "You've reached the maximum of {count} members",
|
||||
@@ -3587,9 +3556,6 @@
|
||||
"dockToTop": "Dock to top",
|
||||
"feedback": "Feedback",
|
||||
"feedbackTooltip": "Feedback",
|
||||
"freeTierRuns": "{available} / {MAX_AVAILABLE} runs left",
|
||||
"freeTierRunsExhausted": "No runs left",
|
||||
"freeTierPartner": "Partner nodes need a paid plan",
|
||||
"share": "Share",
|
||||
"shareTooltip": "Share workflow"
|
||||
},
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { useFreeTierQuota } from '@/platform/cloud/subscription/composables/useFreeTierQuota'
|
||||
|
||||
const DOT_COLORS = [
|
||||
'bg-destructive-background',
|
||||
'bg-warning-background',
|
||||
'bg-success-background'
|
||||
]
|
||||
|
||||
const { showSubscriptionDialog } = useBillingContext()
|
||||
const { t } = useI18n()
|
||||
const { available, hasInvalidNodes, maxAvailable, quotaEnabled } =
|
||||
useFreeTierQuota()
|
||||
|
||||
const dotColor = computed(() => {
|
||||
const ratio = maxAvailable.value ? available.value / maxAvailable.value : 0
|
||||
return DOT_COLORS[
|
||||
Math.min(Math.floor(ratio * DOT_COLORS.length), DOT_COLORS.length - 1)
|
||||
]
|
||||
})
|
||||
const label = computed(() =>
|
||||
available.value === 0
|
||||
? t('actionbar.freeTierRunsExhausted')
|
||||
: t('actionbar.freeTierRuns', {
|
||||
available: available.value,
|
||||
MAX_AVAILABLE: maxAvailable.value
|
||||
})
|
||||
)
|
||||
</script>
|
||||
<template>
|
||||
<div
|
||||
v-if="quotaEnabled"
|
||||
class="mt-2 w-full cursor-pointer border-t border-border-subtle bg-comfy-menu-bg px-4 pt-2 select-none"
|
||||
data-testid="free-tier-quota"
|
||||
@click="showSubscriptionDialog({ reason: 'free_tier_quota' })"
|
||||
>
|
||||
<div
|
||||
v-if="hasInvalidNodes"
|
||||
class="flex w-full items-center justify-center gap-2"
|
||||
>
|
||||
<i class="icon-[comfy--credits] bg-amber-400" />
|
||||
{{ t('actionbar.freeTierPartner') }}
|
||||
</div>
|
||||
<div v-else class="flex w-full items-center justify-between">
|
||||
<div class="flex gap-2" :aria-label="label" role="img">
|
||||
<div
|
||||
v-for="index in maxAvailable"
|
||||
:key="index"
|
||||
:class="
|
||||
cn(
|
||||
'size-1.5 rounded-full',
|
||||
index > available ? 'bg-secondary-background-selected' : dotColor
|
||||
)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<div v-text="label" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -8,19 +8,10 @@ const mockDialogService = {
|
||||
showTopUpCreditsDialog: vi.fn()
|
||||
}
|
||||
|
||||
const mockBilling = {
|
||||
fetchStatus: vi.fn(),
|
||||
fetchBalance: vi.fn()
|
||||
}
|
||||
|
||||
vi.mock('@/services/dialogService', () => ({
|
||||
useDialogService: vi.fn(() => mockDialogService)
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/billing/useBillingContext', () => ({
|
||||
useBillingContext: vi.fn(() => mockBilling)
|
||||
}))
|
||||
|
||||
describe('useAccountPreconditionDialog', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@@ -64,18 +55,4 @@ describe('useAccountPreconditionDialog', () => {
|
||||
mockDialogService.showSubscriptionRequiredDialog
|
||||
).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refreshes the billing snapshot on a credit precondition so exhausted-state surfaces converge', () => {
|
||||
useAccountPreconditionDialog().open('credits')
|
||||
|
||||
expect(mockBilling.fetchStatus).toHaveBeenCalledTimes(1)
|
||||
expect(mockBilling.fetchBalance).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not touch billing state for non-credit preconditions', () => {
|
||||
useAccountPreconditionDialog().open('subscription')
|
||||
|
||||
expect(mockBilling.fetchStatus).not.toHaveBeenCalled()
|
||||
expect(mockBilling.fetchBalance).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import type { AccountPrecondition } from '@/platform/errorCatalog/accountPreconditionRouting'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
|
||||
@@ -29,19 +28,11 @@ export function useAccountPreconditionDialog() {
|
||||
reason: 'subscription_required'
|
||||
})
|
||||
return
|
||||
case 'credits': {
|
||||
// The server just declared the balance exhausted; there is no push or
|
||||
// polling for billing state, so refresh it here to converge
|
||||
// hasFunds-keyed surfaces such as the credits-exhausted banner. The
|
||||
// refresh is best-effort: allSettled keeps a flaky billing API from
|
||||
// surfacing as unhandled rejections.
|
||||
const { fetchStatus, fetchBalance } = useBillingContext()
|
||||
void Promise.allSettled([fetchStatus(), fetchBalance()])
|
||||
case 'credits':
|
||||
void dialogService.showTopUpCreditsDialog({
|
||||
isInsufficientCredits: true
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
import { createSharedComposable } from '@vueuse/core'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import { useCreditsBadgesInGraph } from '@/composables/node/usePriceBadge'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
|
||||
|
||||
export const useFreeTierQuota = createSharedComposable(function () {
|
||||
const { flags } = useFeatureFlags()
|
||||
const creditsBadges = useCreditsBadgesInGraph()
|
||||
|
||||
const available = ref(0)
|
||||
const maxAvailable = ref(0)
|
||||
watch(
|
||||
() => remoteConfig.value.free_tier_balance?.remaining,
|
||||
(val) => (available.value = val ?? 0),
|
||||
{ immediate: true }
|
||||
)
|
||||
watch(
|
||||
() => remoteConfig.value.free_tier_balance?.allowance,
|
||||
(val) => (maxAvailable.value = val ?? 0),
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const quotaEnabled = computed(
|
||||
() => flags.freeTierJobAllowanceEnabled && maxAvailable.value > 0
|
||||
)
|
||||
const hasInvalidNodes = computed(() => creditsBadges.value.length > 0)
|
||||
const freeTierExecutionPermitted = computed(
|
||||
() => !hasInvalidNodes.value && quotaEnabled.value && available.value > 0
|
||||
)
|
||||
|
||||
function trackRun() {
|
||||
if (available.value > 0) available.value--
|
||||
}
|
||||
|
||||
return {
|
||||
available,
|
||||
freeTierExecutionPermitted,
|
||||
hasInvalidNodes,
|
||||
maxAvailable,
|
||||
quotaEnabled,
|
||||
trackRun
|
||||
}
|
||||
})
|
||||
@@ -266,21 +266,6 @@ describe('useSubscriptionDialog', () => {
|
||||
expect(mockTrackSubscription).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows the read-only member dialog for out-of-credits too, not the pricing table', () => {
|
||||
mockShouldUseWorkspaceBilling.value = true
|
||||
mockIsInPersonalWorkspace.value = false
|
||||
mockCanManageSubscription.value = false
|
||||
const { showPricingTable } = useSubscriptionDialog()
|
||||
|
||||
showPricingTable({ reason: 'out_of_credits' })
|
||||
|
||||
expect(mockShowLayoutDialog).toHaveBeenCalledTimes(1)
|
||||
const props = mockShowLayoutDialog.mock.calls[0][0].props
|
||||
expect(props).toHaveProperty('onClose')
|
||||
expect(props).not.toHaveProperty('reason')
|
||||
expect(props).not.toHaveProperty('initialPlanMode')
|
||||
})
|
||||
|
||||
it('does not track on non-cloud', () => {
|
||||
mockIsCloud.value = false
|
||||
const { showPricingTable } = useSubscriptionDialog()
|
||||
|
||||
@@ -55,12 +55,12 @@ export const useSubscriptionDialog = () => {
|
||||
|
||||
// Members can't manage the workspace subscription, so a blocked run shows a
|
||||
// small read-only "ask your owner to reactivate" modal instead of the
|
||||
// pricing table — including out-of-credits, whose member recovery path is
|
||||
// also owner-only (FE-1246).
|
||||
// pricing table. Out-of-credits still routes everyone to the credits flow.
|
||||
if (
|
||||
shouldUseWorkspaceBilling.value &&
|
||||
!workspaceStore.isInPersonalWorkspace &&
|
||||
!permissions.value.canManageSubscription
|
||||
!permissions.value.canManageSubscription &&
|
||||
options?.reason !== 'out_of_credits'
|
||||
) {
|
||||
dialogService.showLayoutDialog({
|
||||
key: DIALOG_KEY,
|
||||
@@ -88,9 +88,9 @@ export const useSubscriptionDialog = () => {
|
||||
} as const
|
||||
|
||||
// Jun-5 model: a single unified pricing table (personal/team plan toggle on
|
||||
// one workspace) for workspaces on the workspace-scoped billing flow.
|
||||
// Replaces the old personal-vs-team workspace fork. Personal workspaces
|
||||
// still on the legacy flow (billing control disabled) get the legacy table.
|
||||
// one workspace) for workspaces on the consolidated billing flow. Replaces
|
||||
// the old personal-vs-team workspace fork. Personal workspaces still on the
|
||||
// legacy flow (consolidated billing disabled) get the legacy table.
|
||||
if (shouldUseWorkspaceBilling.value) {
|
||||
// Existing per-member (legacy) team subscribers keep the old tier-based
|
||||
// team table; the unified credit-slider table is for everyone else.
|
||||
|
||||
@@ -73,33 +73,6 @@ describe('resolveAccountPrecondition', () => {
|
||||
).toBe('credits')
|
||||
})
|
||||
|
||||
it('classifies the submit-time 402 body by its insufficient_credits type regardless of message', () => {
|
||||
expect(
|
||||
resolveAccountPrecondition({
|
||||
exceptionType: 'insufficient_credits',
|
||||
exceptionMessage: 'Workspace balance exhausted'
|
||||
})
|
||||
).toBe('credits')
|
||||
})
|
||||
|
||||
it('classifies the team submit-time 429 (PAYMENT_REQUIRED / insufficient credits) as a credits precondition', () => {
|
||||
expect(
|
||||
resolveAccountPrecondition({
|
||||
exceptionType: 'PAYMENT_REQUIRED',
|
||||
exceptionMessage: 'Insufficient credits to queue workflows'
|
||||
})
|
||||
).toBe('credits')
|
||||
})
|
||||
|
||||
it('keeps the team submit-time 429 for an inactive subscription on the subscription precondition', () => {
|
||||
expect(
|
||||
resolveAccountPrecondition({
|
||||
exceptionType: 'PAYMENT_REQUIRED',
|
||||
exceptionMessage: 'Subscription required to queue workflows'
|
||||
})
|
||||
).toBe('subscription')
|
||||
})
|
||||
|
||||
it('returns undefined for an ordinary workflow error', () => {
|
||||
expect(
|
||||
resolveAccountPrecondition({
|
||||
|
||||
@@ -33,13 +33,7 @@ const INSUFFICIENT_CREDITS_MESSAGES = new Set([
|
||||
'Payment Required: Please add credits to your account to use this node.'
|
||||
])
|
||||
const WORKSPACE_INSUFFICIENT_CREDITS_MESSAGES = new Set([
|
||||
// Execution-time (pre-GPU) WebSocket failure for a queued team job.
|
||||
'Payment Required: Please add credits to your workspace to continue.',
|
||||
// Submit-time 429 rejection for a team workspace out of credits
|
||||
// (checkTeamWorkspaceSubscription). It shares the PAYMENT_REQUIRED error type
|
||||
// with the subscription-required rejection, so it can only be told apart by
|
||||
// message.
|
||||
'Insufficient credits to queue workflows'
|
||||
'Payment Required: Please add credits to your workspace to continue.'
|
||||
])
|
||||
const SUBSCRIPTION_REQUIRED_MESSAGES = new Set([
|
||||
'Workspace has no active subscription. Please subscribe to a plan to continue.',
|
||||
@@ -249,12 +243,8 @@ const RUNTIME_MATCH_RULES: RuntimeMatchRule[] = [
|
||||
resolve: () => catalogMatch(WORKSPACE_INSUFFICIENT_CREDITS_CATALOG_ID)
|
||||
},
|
||||
{
|
||||
// 'insufficient_credits' is the error.type BE-2866 will emit on the
|
||||
// personal submit-time 402; the team submit path is a 429 matched by
|
||||
// message in WORKSPACE_INSUFFICIENT_CREDITS_MESSAGES above.
|
||||
matches: (info, message) =>
|
||||
info.exceptionType === 'InsufficientFundsError' ||
|
||||
info.exceptionType === 'insufficient_credits' ||
|
||||
INSUFFICIENT_CREDITS_MESSAGES.has(message),
|
||||
resolve: () => catalogMatch(INSUFFICIENT_CREDITS_CATALOG_ID)
|
||||
},
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {
|
||||
cachedBillingControlEnabled,
|
||||
cachedConsolidatedBillingEnabled,
|
||||
cachedTeamWorkspacesEnabled,
|
||||
remoteConfig,
|
||||
remoteConfigState
|
||||
@@ -60,8 +60,8 @@ export async function refreshRemoteConfig(
|
||||
cachedTeamWorkspacesEnabled.value = Boolean(
|
||||
config.team_workspaces_enabled
|
||||
)
|
||||
cachedBillingControlEnabled.value = Boolean(
|
||||
config.billing_control_enabled
|
||||
cachedConsolidatedBillingEnabled.value = Boolean(
|
||||
config.consolidated_billing_enabled
|
||||
)
|
||||
}
|
||||
return
|
||||
|
||||
@@ -60,7 +60,7 @@ export const cachedTeamWorkspacesEnabled = useStorage<boolean | undefined>(
|
||||
undefined
|
||||
)
|
||||
|
||||
export const cachedBillingControlEnabled = useStorage<boolean | undefined>(
|
||||
'billing_control_enabled' satisfies `${ServerFeatureFlag.BILLING_CONTROL_ENABLED}`,
|
||||
export const cachedConsolidatedBillingEnabled = useStorage<boolean | undefined>(
|
||||
'consolidated_billing_enabled' satisfies `${ServerFeatureFlag.CONSOLIDATED_BILLING_ENABLED}`,
|
||||
undefined
|
||||
)
|
||||
|
||||
@@ -110,17 +110,12 @@ export type RemoteConfig = {
|
||||
user_secrets_enabled?: boolean
|
||||
node_library_essentials_enabled?: boolean
|
||||
free_tier_credits?: number
|
||||
free_tier_balance?: {
|
||||
allowance: number
|
||||
used: number
|
||||
remaining: number
|
||||
}
|
||||
new_free_tier_subscriptions?: boolean
|
||||
workflow_sharing_enabled?: boolean
|
||||
comfyhub_upload_enabled?: boolean
|
||||
comfyhub_profile_gate_enabled?: boolean
|
||||
unified_cloud_auth?: boolean
|
||||
billing_control_enabled?: boolean
|
||||
consolidated_billing_enabled?: boolean
|
||||
sentry_dsn?: string
|
||||
turnstile_sitekey?: string
|
||||
// Raw, unvalidated wire value (a server typo like 'enfroce' is possible).
|
||||
|
||||
@@ -28,8 +28,7 @@ export async function initTelemetry(): Promise<void> {
|
||||
{ PostHogTelemetryProvider },
|
||||
{ ClickHouseTelemetryProvider },
|
||||
{ SyftTelemetryProvider },
|
||||
{ CustomerIoTelemetryProvider },
|
||||
{ DatadogRumTelemetryProvider }
|
||||
{ CustomerIoTelemetryProvider }
|
||||
] = await Promise.all([
|
||||
import('./TelemetryRegistry'),
|
||||
import('./providers/cloud/MixpanelTelemetryProvider'),
|
||||
@@ -38,8 +37,7 @@ export async function initTelemetry(): Promise<void> {
|
||||
import('./providers/cloud/PostHogTelemetryProvider'),
|
||||
import('./providers/cloud/ClickHouseTelemetryProvider'),
|
||||
import('./providers/cloud/SyftTelemetryProvider'),
|
||||
import('./providers/cloud/CustomerIoTelemetryProvider'),
|
||||
import('./providers/cloud/DatadogRumTelemetryProvider')
|
||||
import('./providers/cloud/CustomerIoTelemetryProvider')
|
||||
])
|
||||
|
||||
const registry = new TelemetryRegistry()
|
||||
@@ -50,7 +48,6 @@ export async function initTelemetry(): Promise<void> {
|
||||
registry.registerProvider(new ClickHouseTelemetryProvider())
|
||||
registry.registerProvider(new SyftTelemetryProvider())
|
||||
registry.registerProvider(new CustomerIoTelemetryProvider())
|
||||
registry.registerProvider(new DatadogRumTelemetryProvider())
|
||||
|
||||
setTelemetryRegistry(registry)
|
||||
})()
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { DatadogRumTelemetryProvider } from './DatadogRumTelemetryProvider'
|
||||
|
||||
const setViewName = vi.fn()
|
||||
|
||||
function installDatadogRum(): void {
|
||||
Object.defineProperty(window, 'DD_RUM', {
|
||||
configurable: true,
|
||||
value: { setViewName }
|
||||
})
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
setViewName.mockReset()
|
||||
Reflect.deleteProperty(window, 'DD_RUM')
|
||||
})
|
||||
|
||||
describe('DatadogRumTelemetryProvider', () => {
|
||||
it.for([
|
||||
{ expected: 'workspace', path: 'https://cloud.comfy.org/' },
|
||||
{ expected: 'account_access', path: 'https://cloud.comfy.org/cloud/' },
|
||||
{ expected: 'account_access', path: 'https://cloud.comfy.org/cloud/login' },
|
||||
{
|
||||
expected: 'account_access',
|
||||
path: 'https://cloud.comfy.org/cloud/subscribe?plan=creator'
|
||||
},
|
||||
{
|
||||
expected: 'oauth_consent',
|
||||
path: 'https://cloud.comfy.org/cloud/oauth/consent?oauth_request_id=redacted'
|
||||
},
|
||||
{
|
||||
expected: 'support_recovery',
|
||||
path: 'https://cloud.comfy.org/cloud/forgot-password'
|
||||
},
|
||||
{
|
||||
expected: 'support_recovery',
|
||||
path: 'https://cloud.comfy.org/cloud/sorry-contact-support'
|
||||
},
|
||||
{
|
||||
expected: 'support_recovery',
|
||||
path: 'https://cloud.comfy.org/cloud/auth-timeout'
|
||||
}
|
||||
] as const)('names the current view $expected', ({ expected, path }) => {
|
||||
installDatadogRum()
|
||||
|
||||
new DatadogRumTelemetryProvider().trackPageView('ignored', { path })
|
||||
|
||||
expect(setViewName).toHaveBeenCalledWith(expected)
|
||||
})
|
||||
|
||||
it('does nothing when Datadog RUM is unavailable', () => {
|
||||
expect(() =>
|
||||
new DatadogRumTelemetryProvider().trackPageView('ignored')
|
||||
).not.toThrow()
|
||||
})
|
||||
})
|
||||
@@ -1,40 +0,0 @@
|
||||
import type { PageViewMetadata, TelemetryProvider } from '../../types'
|
||||
|
||||
interface DatadogRumClient {
|
||||
setViewName(name: string): void
|
||||
}
|
||||
|
||||
interface WindowWithDatadogRum extends Window {
|
||||
DD_RUM?: DatadogRumClient
|
||||
}
|
||||
|
||||
type ViewName =
|
||||
| 'account_access'
|
||||
| 'oauth_consent'
|
||||
| 'support_recovery'
|
||||
| 'workspace'
|
||||
|
||||
const SUPPORT_RECOVERY_PATHS = new Set([
|
||||
'/cloud/auth-timeout',
|
||||
'/cloud/forgot-password',
|
||||
'/cloud/sorry-contact-support'
|
||||
])
|
||||
|
||||
function getViewName(path = window.location.href): ViewName {
|
||||
const pathname = new URL(path, window.location.origin).pathname.replace(
|
||||
/\/$/,
|
||||
''
|
||||
)
|
||||
if (pathname === '/cloud/oauth/consent') return 'oauth_consent'
|
||||
if (SUPPORT_RECOVERY_PATHS.has(pathname)) return 'support_recovery'
|
||||
if (pathname === '/cloud' || pathname.startsWith('/cloud/'))
|
||||
return 'account_access'
|
||||
return 'workspace'
|
||||
}
|
||||
|
||||
export class DatadogRumTelemetryProvider implements TelemetryProvider {
|
||||
trackPageView(_pageName: string, properties?: PageViewMetadata): void {
|
||||
const rum = (window as WindowWithDatadogRum).DD_RUM
|
||||
rum?.setViewName(getViewName(properties?.path))
|
||||
}
|
||||
}
|
||||
@@ -33,7 +33,6 @@ export type PaymentIntentSource =
|
||||
| 'invite_member_upsell'
|
||||
| 'upload_model_upgrade'
|
||||
| 'team_upgrade_resume'
|
||||
| 'free_tier_quota'
|
||||
|
||||
export type SubscriptionCheckoutType = 'new' | 'change'
|
||||
export type SubscriptionCheckoutTier = TierKey | 'team'
|
||||
|
||||
@@ -250,9 +250,6 @@ export type BillingStatus =
|
||||
| 'pending_payment'
|
||||
| 'paid'
|
||||
| 'payment_failed'
|
||||
// A Stripe-paused subscription stays `active` on the activity axis; the pause
|
||||
// is a payment-lifecycle fact. Not emitted until cloud#5075 ships.
|
||||
| 'paused'
|
||||
| 'inactive'
|
||||
|
||||
export interface CurrentTeamCreditStop {
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col overflow-hidden rounded-2xl border border-border-default bg-base-background"
|
||||
data-testid="insufficient-credits-member-message"
|
||||
>
|
||||
<div
|
||||
class="flex h-12 items-center gap-2 border-b border-border-default p-4"
|
||||
>
|
||||
<p class="m-0 min-w-0 flex-1 font-inter text-sm text-base-foreground">
|
||||
{{ $t('credits.insufficient.memberTitle') }}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
:aria-label="$t('g.close')"
|
||||
class="flex size-4 shrink-0 cursor-pointer items-center justify-center border-none bg-transparent text-base-foreground"
|
||||
@click="onClose"
|
||||
>
|
||||
<i class="icon-[lucide--x] size-3" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="p-4">
|
||||
<p class="m-0 font-inter text-sm text-muted-foreground">
|
||||
{{ $t('credits.insufficient.memberDescription') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end p-4">
|
||||
<Button variant="secondary" size="lg" @click="onClose">
|
||||
{{ $t('credits.insufficient.memberCta') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
const { onClose } = defineProps<{
|
||||
onClose: () => void
|
||||
}>()
|
||||
</script>
|
||||
@@ -1,178 +0,0 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import type { SubscriptionInfo } from '@/composables/billing/types'
|
||||
import { i18n } from '@/i18n'
|
||||
import type { BillingContextMockState } from '@/storybook/mocks/useBillingContext'
|
||||
import { setBillingContextMock } from '@/storybook/mocks/useBillingContext'
|
||||
import type { WorkspaceUIMockState } from '@/storybook/mocks/useWorkspaceUI'
|
||||
import { setWorkspaceUIMock } from '@/storybook/mocks/useWorkspaceUI'
|
||||
|
||||
import BillingStatusBanner from './BillingStatusBanner.vue'
|
||||
|
||||
/**
|
||||
* The single billing banner slot for team workspaces (FE-1246), rendered in
|
||||
* priority order: paused > payment declined > out of credits > ending. At most
|
||||
* one state shows at a time. Each story drives the real `deriveBillingBanner`
|
||||
* through the stubbed billing context, so these are the states the backend can
|
||||
* actually emit — not hand-set banner kinds.
|
||||
*
|
||||
* The amber triangle-alert marks action-needed states; the muted circle-alert is
|
||||
* reserved for the informational "plan ends" notice (DES-380 severity rule).
|
||||
*
|
||||
* RUN WITH `DISTRIBUTION=cloud pnpm storybook`. The banner is cloud-only, and
|
||||
* `isCloud` is compile-time, so under a plain `pnpm storybook` every story below
|
||||
* renders empty.
|
||||
*/
|
||||
const meta: Meta<typeof BillingStatusBanner> = {
|
||||
title: 'Platform/Workspace/BillingStatusBanner',
|
||||
component: BillingStatusBanner,
|
||||
parameters: { layout: 'fullscreen' }
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof BillingStatusBanner>
|
||||
|
||||
const RENEWAL_DATE = '2026-08-01T00:00:00Z'
|
||||
const PLAN_END_DATE = '2026-08-01T00:00:00Z'
|
||||
|
||||
const teamSubscription: SubscriptionInfo = {
|
||||
isActive: true,
|
||||
tier: null,
|
||||
duration: 'MONTHLY',
|
||||
planSlug: 'team-monthly',
|
||||
renewalDate: RENEWAL_DATE,
|
||||
endDate: null,
|
||||
isCancelled: false,
|
||||
hasFunds: true
|
||||
}
|
||||
|
||||
const funded = teamSubscription
|
||||
const exhausted: SubscriptionInfo = { ...teamSubscription, hasFunds: false }
|
||||
const cancelled: SubscriptionInfo = {
|
||||
...teamSubscription,
|
||||
isCancelled: true,
|
||||
endDate: PLAN_END_DATE
|
||||
}
|
||||
|
||||
const owner: Partial<WorkspaceUIMockState> = {}
|
||||
const member: Partial<WorkspaceUIMockState> = {
|
||||
canManageSubscription: false,
|
||||
canManageSubscriptionLifecycle: false,
|
||||
canTopUp: false
|
||||
}
|
||||
|
||||
function story(
|
||||
billing: Partial<BillingContextMockState>,
|
||||
workspace: Partial<WorkspaceUIMockState>
|
||||
): Story {
|
||||
return {
|
||||
beforeEach() {
|
||||
// Dates in the copy go through vue-i18n's `d()`, so pin the locale rather
|
||||
// than inherit the developer's.
|
||||
i18n.global.locale.value = 'en'
|
||||
setBillingContextMock({ isTeamPlan: true, ...billing })
|
||||
setWorkspaceUIMock(workspace)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stripe suspended the subscription. The backend folds billing_status into
|
||||
* is_active, so a paused workspace always reports `is_active: false` — the
|
||||
* pairing these stories pin.
|
||||
*/
|
||||
export const PausedOwner: Story = story(
|
||||
{
|
||||
subscription: funded,
|
||||
isActiveSubscription: false,
|
||||
billingStatus: 'paused',
|
||||
subscriptionStatus: 'active'
|
||||
},
|
||||
owner
|
||||
)
|
||||
|
||||
/** Members get an admin-directed notice and no action. */
|
||||
export const PausedMember: Story = story(
|
||||
{
|
||||
subscription: funded,
|
||||
isActiveSubscription: false,
|
||||
billingStatus: 'paused',
|
||||
subscriptionStatus: 'active'
|
||||
},
|
||||
member
|
||||
)
|
||||
|
||||
/**
|
||||
* A failed charge with Stripe still retrying. Owner-only, and `is_active: false`
|
||||
* for the same reason paused is — payment_failed also denies spend.
|
||||
*/
|
||||
export const PaymentDeclined: Story = story(
|
||||
{
|
||||
subscription: funded,
|
||||
isActiveSubscription: false,
|
||||
billingStatus: 'payment_failed',
|
||||
subscriptionStatus: 'active',
|
||||
renewalDate: RENEWAL_DATE
|
||||
},
|
||||
owner
|
||||
)
|
||||
|
||||
/** Payment declined before a renewal date is known. */
|
||||
export const PaymentDeclinedNoDate: Story = story(
|
||||
{
|
||||
subscription: funded,
|
||||
isActiveSubscription: false,
|
||||
billingStatus: 'payment_failed',
|
||||
subscriptionStatus: 'active'
|
||||
},
|
||||
owner
|
||||
)
|
||||
|
||||
/** Shared team credits exhausted. Session-dismissible. */
|
||||
export const OutOfCreditsOwner: Story = story(
|
||||
{
|
||||
subscription: exhausted,
|
||||
isActiveSubscription: true,
|
||||
billingStatus: 'paid',
|
||||
subscriptionStatus: 'active',
|
||||
renewalDate: RENEWAL_DATE
|
||||
},
|
||||
owner
|
||||
)
|
||||
|
||||
/** Members can't top up, so they get contact-admin copy and no Add credits. */
|
||||
export const OutOfCreditsMember: Story = story(
|
||||
{
|
||||
subscription: exhausted,
|
||||
isActiveSubscription: true,
|
||||
billingStatus: 'paid',
|
||||
subscriptionStatus: 'active',
|
||||
renewalDate: RENEWAL_DATE
|
||||
},
|
||||
member
|
||||
)
|
||||
|
||||
/** Cancelled but still active until the period end. Informational. */
|
||||
export const EndingOwner: Story = story(
|
||||
{
|
||||
subscription: cancelled,
|
||||
isActiveSubscription: true,
|
||||
billingStatus: 'paid',
|
||||
subscriptionStatus: 'canceled'
|
||||
},
|
||||
owner
|
||||
)
|
||||
|
||||
/**
|
||||
* Reactivate is lifecycle-gated to the original owner, so a non-original owner
|
||||
* sees the notice read-only.
|
||||
*/
|
||||
export const EndingNonOriginalOwner: Story = story(
|
||||
{
|
||||
subscription: cancelled,
|
||||
isActiveSubscription: true,
|
||||
billingStatus: 'paid',
|
||||
subscriptionStatus: 'canceled'
|
||||
},
|
||||
{ canManageSubscriptionLifecycle: false }
|
||||
)
|
||||
@@ -1,317 +0,0 @@
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { computed } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import type {
|
||||
BillingStatus,
|
||||
WorkspaceType
|
||||
} from '@/platform/workspace/api/workspaceApi'
|
||||
import BillingStatusBanner from '@/platform/workspace/components/dialogs/settings/BillingStatusBanner.vue'
|
||||
|
||||
interface Subscription {
|
||||
hasFunds: boolean
|
||||
isCancelled: boolean
|
||||
endDate: string | null
|
||||
}
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
billingControlEnabled: true,
|
||||
isActiveSubscription: true,
|
||||
isTeamPlan: true,
|
||||
billingStatus: 'paid' as string | null,
|
||||
subscription: {
|
||||
hasFunds: true,
|
||||
isCancelled: false,
|
||||
endDate: null
|
||||
} as Subscription | null,
|
||||
renewalDate: null as string | null,
|
||||
workspaceType: 'team' as string,
|
||||
canManageSubscription: true,
|
||||
canManageSubscriptionLifecycle: true,
|
||||
canTopUp: true,
|
||||
showTopUpCreditsDialog: vi.fn(),
|
||||
manageSubscription: vi.fn(),
|
||||
handleResubscribe: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({ isCloud: true }))
|
||||
|
||||
vi.mock('@/composables/useFeatureFlags', () => ({
|
||||
useFeatureFlags: () => ({
|
||||
flags: {
|
||||
get billingControlEnabled() {
|
||||
return state.billingControlEnabled
|
||||
}
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/billing/useBillingContext', () => ({
|
||||
useBillingContext: () => ({
|
||||
isActiveSubscription: computed(() => state.isActiveSubscription),
|
||||
isTeamPlan: computed(() => state.isTeamPlan),
|
||||
billingStatus: computed(() => state.billingStatus as BillingStatus | null),
|
||||
subscription: computed(() => state.subscription),
|
||||
renewalDate: computed(() => state.renewalDate),
|
||||
manageSubscription: state.manageSubscription
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workspace/composables/useWorkspaceUI', () => ({
|
||||
useWorkspaceUI: () => ({
|
||||
permissions: computed(() => ({
|
||||
canManageSubscription: state.canManageSubscription,
|
||||
canManageSubscriptionLifecycle: state.canManageSubscriptionLifecycle,
|
||||
canTopUp: state.canTopUp
|
||||
})),
|
||||
workspaceType: computed(() => state.workspaceType as WorkspaceType)
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workspace/composables/useResubscribe', () => ({
|
||||
useResubscribe: () => ({
|
||||
isResubscribing: computed(() => false),
|
||||
handleResubscribe: state.handleResubscribe
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/services/dialogService', () => ({
|
||||
useDialogService: () => ({
|
||||
showTopUpCreditsDialog: state.showTopUpCreditsDialog
|
||||
})
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
workspacePanel: {
|
||||
billingStatus: {
|
||||
warning: {
|
||||
title: 'Payment declined',
|
||||
body: "Your last payment didn't go through. Your subscription will pause on {date} unless payment is updated.",
|
||||
bodyNoDate:
|
||||
"Your last payment didn't go through. Update payment to avoid a pause."
|
||||
},
|
||||
paused: {
|
||||
title: 'Subscription paused',
|
||||
body: "This workspace's subscription is paused. Update payment to resume.",
|
||||
memberBody:
|
||||
"This workspace's subscription is paused. Your workspace admins need to update the payment method."
|
||||
},
|
||||
outOfCredits: {
|
||||
title: 'Out of credits',
|
||||
body: 'Your team has used all its credits. Add more credits to continue generating or wait until credits refill on {date}.',
|
||||
bodyNoDate:
|
||||
'Your team has used all its credits. Add more credits to continue generating.',
|
||||
memberBody:
|
||||
'Your team has used all its credits. Your workspace admins need to add more credits to continue generating.',
|
||||
addCredits: 'Add credits',
|
||||
dismiss: 'Dismiss'
|
||||
},
|
||||
ending: {
|
||||
title: 'Your team plan ends on {date}',
|
||||
body: 'Members keep full access until then. Reactivate to keep your shared credits and seats.',
|
||||
reactivate: 'Reactivate plan'
|
||||
},
|
||||
updatePayment: 'Update payment'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const globalOptions = {
|
||||
plugins: [i18n],
|
||||
stubs: {
|
||||
Button: {
|
||||
template:
|
||||
'<button v-bind="$attrs" @click="$emit(\'click\')"><slot/></button>',
|
||||
props: ['variant', 'size', 'loading'],
|
||||
emits: ['click']
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function renderBanner() {
|
||||
return render(BillingStatusBanner, { global: globalOptions })
|
||||
}
|
||||
|
||||
function exhausted() {
|
||||
state.subscription = { hasFunds: false, isCancelled: false, endDate: null }
|
||||
}
|
||||
|
||||
// The spend gate folds billing_status into is_active, so the backend never emits
|
||||
// paused alongside an active subscription.
|
||||
function pausedState() {
|
||||
state.billingStatus = 'paused'
|
||||
state.isActiveSubscription = false
|
||||
}
|
||||
|
||||
function paymentFailedState() {
|
||||
state.billingStatus = 'payment_failed'
|
||||
state.isActiveSubscription = false
|
||||
}
|
||||
|
||||
describe('BillingStatusBanner', () => {
|
||||
beforeEach(() => {
|
||||
state.billingControlEnabled = true
|
||||
state.isActiveSubscription = true
|
||||
state.isTeamPlan = true
|
||||
state.billingStatus = 'paid'
|
||||
state.subscription = { hasFunds: true, isCancelled: false, endDate: null }
|
||||
state.renewalDate = null
|
||||
state.workspaceType = 'team'
|
||||
state.canManageSubscription = true
|
||||
state.canManageSubscriptionLifecycle = true
|
||||
state.canTopUp = true
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('renders nothing for a healthy funded team', () => {
|
||||
renderBanner()
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders nothing when billing control is rolled back, even out of credits', () => {
|
||||
state.billingControlEnabled = false
|
||||
state.subscription = { hasFunds: false, isCancelled: false, endDate: null }
|
||||
renderBanner()
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows out-of-credits with an Add credits action for owners', async () => {
|
||||
state.subscription = { hasFunds: false, isCancelled: false, endDate: null }
|
||||
renderBanner()
|
||||
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Out of credits')
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Add credits' }))
|
||||
expect(state.showTopUpCreditsDialog).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('shows out-of-credits contact-admin copy without an Add credits action for members', () => {
|
||||
state.subscription = { hasFunds: false, isCancelled: false, endDate: null }
|
||||
state.canManageSubscription = false
|
||||
state.canTopUp = false
|
||||
renderBanner()
|
||||
|
||||
expect(screen.getByRole('status')).toHaveTextContent(
|
||||
'Your workspace admins need to add more credits'
|
||||
)
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Add credits' })
|
||||
).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Dismiss' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('dismisses the out-of-credits banner for the session', async () => {
|
||||
exhausted()
|
||||
renderBanner()
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Dismiss' }))
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shares one dismiss across instances rather than tracking it per mount', async () => {
|
||||
exhausted()
|
||||
render(
|
||||
{
|
||||
components: { BillingStatusBanner },
|
||||
template: '<div><BillingStatusBanner /><BillingStatusBanner /></div>'
|
||||
},
|
||||
{ global: globalOptions }
|
||||
)
|
||||
|
||||
expect(screen.getAllByRole('status')).toHaveLength(2)
|
||||
await userEvent.click(screen.getAllByRole('button', { name: 'Dismiss' })[0])
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the paused banner with Update payment for owners', async () => {
|
||||
pausedState()
|
||||
renderBanner()
|
||||
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Subscription paused')
|
||||
expect(screen.getByRole('status')).toHaveTextContent(
|
||||
'Update payment to resume'
|
||||
)
|
||||
await userEvent.click(
|
||||
screen.getByRole('button', { name: 'Update payment' })
|
||||
)
|
||||
expect(state.manageSubscription).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('shows the paused member notice without an action', () => {
|
||||
pausedState()
|
||||
state.canManageSubscription = false
|
||||
state.canTopUp = false
|
||||
renderBanner()
|
||||
|
||||
expect(screen.getByRole('status')).toHaveTextContent(
|
||||
'Your workspace admins need to update the payment method'
|
||||
)
|
||||
expect(screen.queryByRole('button')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the payment-declined banner with Update payment for owners', () => {
|
||||
paymentFailedState()
|
||||
state.renewalDate = '2026-08-01T00:00:00Z'
|
||||
renderBanner()
|
||||
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Payment declined')
|
||||
expect(screen.getByRole('status')).toHaveTextContent(/will pause on \S+/)
|
||||
expect(screen.getByRole('status')).not.toHaveTextContent('{date}')
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Update payment' })
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('falls back to the no-date payment-declined copy when there is no renewal date', () => {
|
||||
paymentFailedState()
|
||||
state.renewalDate = null
|
||||
renderBanner()
|
||||
|
||||
expect(screen.getByRole('status')).toHaveTextContent(
|
||||
'Update payment to avoid a pause'
|
||||
)
|
||||
expect(screen.getByRole('status')).not.toHaveTextContent('will pause on')
|
||||
expect(screen.getByRole('status')).not.toHaveTextContent('{date}')
|
||||
})
|
||||
|
||||
it('shows the ending banner with a Reactivate action', async () => {
|
||||
state.subscription = {
|
||||
hasFunds: true,
|
||||
isCancelled: true,
|
||||
endDate: '2026-08-01T00:00:00Z'
|
||||
}
|
||||
renderBanner()
|
||||
|
||||
expect(screen.getByRole('status')).toHaveTextContent(
|
||||
'Your team plan ends on'
|
||||
)
|
||||
await userEvent.click(
|
||||
screen.getByRole('button', { name: 'Reactivate plan' })
|
||||
)
|
||||
expect(state.handleResubscribe).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('shows the ending banner read-only to a non-original owner (Reactivate is lifecycle-gated)', () => {
|
||||
state.subscription = {
|
||||
hasFunds: true,
|
||||
isCancelled: true,
|
||||
endDate: '2026-08-01T00:00:00Z'
|
||||
}
|
||||
state.canManageSubscriptionLifecycle = false
|
||||
renderBanner()
|
||||
|
||||
expect(screen.getByRole('status')).toHaveTextContent(
|
||||
'Your team plan ends on'
|
||||
)
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Reactivate plan' })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1,165 +0,0 @@
|
||||
<template>
|
||||
<div v-if="banner" class="@container">
|
||||
<div
|
||||
role="status"
|
||||
class="flex flex-col gap-3 rounded-2xl border border-interface-stroke/60 bg-base-background p-4 @2xl:flex-row @2xl:items-center @2xl:gap-2"
|
||||
>
|
||||
<div class="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<i
|
||||
:class="
|
||||
cn(
|
||||
'size-4 shrink-0',
|
||||
banner.muted
|
||||
? 'icon-[lucide--circle-alert] text-muted-foreground'
|
||||
: 'icon-[lucide--triangle-alert] text-warning-background'
|
||||
)
|
||||
"
|
||||
/>
|
||||
<span class="text-sm text-base-foreground">{{ banner.title }}</span>
|
||||
</div>
|
||||
<p class="m-0 pl-6 text-sm text-muted-foreground">{{ banner.body }}</p>
|
||||
</div>
|
||||
<div
|
||||
v-if="banner.dismissible || banner.action"
|
||||
class="flex shrink-0 flex-wrap items-center gap-2 pl-6 @2xl:pl-0"
|
||||
>
|
||||
<Button
|
||||
v-if="banner.dismissible"
|
||||
variant="textonly"
|
||||
size="lg"
|
||||
@click="dismiss"
|
||||
>
|
||||
{{ $t('workspacePanel.billingStatus.outOfCredits.dismiss') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="banner.action === 'addCredits'"
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
@click="handleAddCredits"
|
||||
>
|
||||
{{ $t('workspacePanel.billingStatus.outOfCredits.addCredits') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-else-if="banner.action === 'reactivate'"
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
:loading="isResubscribing"
|
||||
@click="handleResubscribe"
|
||||
>
|
||||
{{ $t('workspacePanel.billingStatus.ending.reactivate') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-else-if="banner.action === 'updatePayment'"
|
||||
variant="inverted"
|
||||
size="lg"
|
||||
@click="handleUpdatePayment"
|
||||
>
|
||||
{{ $t('workspacePanel.billingStatus.updatePayment') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { useBillingBanner } from '@/platform/workspace/composables/useBillingBanner'
|
||||
import { useResubscribe } from '@/platform/workspace/composables/useResubscribe'
|
||||
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
|
||||
type BannerAction = 'addCredits' | 'reactivate' | 'updatePayment'
|
||||
|
||||
const { t, d } = useI18n()
|
||||
const { renewalDate, subscription, manageSubscription } = useBillingContext()
|
||||
const { permissions } = useWorkspaceUI()
|
||||
const { kind, dismiss } = useBillingBanner()
|
||||
const { isResubscribing, handleResubscribe } = useResubscribe()
|
||||
const dialogService = useDialogService()
|
||||
|
||||
const canManage = computed(() => permissions.value.canManageSubscription)
|
||||
const canManageLifecycle = computed(
|
||||
() => permissions.value.canManageSubscriptionLifecycle
|
||||
)
|
||||
const canTopUp = computed(() => permissions.value.canTopUp)
|
||||
|
||||
const cycleResetDate = computed(() => {
|
||||
const raw = renewalDate.value
|
||||
return raw ? d(new Date(raw), { month: 'short', day: 'numeric' }) : ''
|
||||
})
|
||||
const planEndDate = computed(() => {
|
||||
const raw = subscription.value?.endDate
|
||||
return raw
|
||||
? d(new Date(raw), { year: 'numeric', month: 'long', day: 'numeric' })
|
||||
: ''
|
||||
})
|
||||
|
||||
interface BannerView {
|
||||
muted: boolean
|
||||
title: string
|
||||
body: string
|
||||
action: BannerAction | null
|
||||
dismissible: boolean
|
||||
}
|
||||
|
||||
const banner = computed<BannerView | null>(() => {
|
||||
const bs = 'workspacePanel.billingStatus'
|
||||
switch (kind.value) {
|
||||
case 'paused':
|
||||
return {
|
||||
muted: false,
|
||||
title: t(`${bs}.paused.title`),
|
||||
body: canManage.value
|
||||
? t(`${bs}.paused.body`)
|
||||
: t(`${bs}.paused.memberBody`),
|
||||
action: canManage.value ? 'updatePayment' : null,
|
||||
dismissible: false
|
||||
}
|
||||
case 'paymentFailed':
|
||||
return {
|
||||
muted: false,
|
||||
title: t(`${bs}.warning.title`),
|
||||
body: cycleResetDate.value
|
||||
? t(`${bs}.warning.body`, { date: cycleResetDate.value })
|
||||
: t(`${bs}.warning.bodyNoDate`),
|
||||
action: 'updatePayment',
|
||||
dismissible: false
|
||||
}
|
||||
case 'outOfCredits':
|
||||
return {
|
||||
muted: false,
|
||||
title: t(`${bs}.outOfCredits.title`),
|
||||
body: canTopUp.value
|
||||
? cycleResetDate.value
|
||||
? t(`${bs}.outOfCredits.body`, { date: cycleResetDate.value })
|
||||
: t(`${bs}.outOfCredits.bodyNoDate`)
|
||||
: t(`${bs}.outOfCredits.memberBody`),
|
||||
action: canTopUp.value ? 'addCredits' : null,
|
||||
dismissible: true
|
||||
}
|
||||
case 'ending':
|
||||
return {
|
||||
muted: true,
|
||||
title: t(`${bs}.ending.title`, { date: planEndDate.value }),
|
||||
body: t(`${bs}.ending.body`),
|
||||
action: canManageLifecycle.value ? 'reactivate' : null,
|
||||
dismissible: false
|
||||
}
|
||||
default:
|
||||
return null
|
||||
}
|
||||
})
|
||||
|
||||
function handleAddCredits() {
|
||||
void dialogService.showTopUpCreditsDialog()
|
||||
}
|
||||
function handleUpdatePayment() {
|
||||
void manageSubscription()
|
||||
}
|
||||
</script>
|
||||
@@ -65,16 +65,6 @@ vi.mock(
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock(
|
||||
'@/platform/workspace/components/dialogs/settings/BillingStatusBanner.vue',
|
||||
() => ({
|
||||
default: {
|
||||
name: 'BillingStatusBanner',
|
||||
template: '<div data-testid="billing-banner" />'
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
@@ -103,25 +93,6 @@ function renderComponent() {
|
||||
})
|
||||
}
|
||||
|
||||
describe('WorkspacePanelContent billing banner', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockMembers.value = []
|
||||
mockWorkspaceType.value = 'team'
|
||||
})
|
||||
|
||||
it('hosts a single banner slot above the tab content, so it shows on every tab', () => {
|
||||
renderComponent()
|
||||
|
||||
const banner = screen.getByTestId('billing-banner')
|
||||
const planPanel = screen.getByText('workspacePanel.tabs.planCredits')
|
||||
expect(
|
||||
planPanel.compareDocumentPosition(banner) &
|
||||
Node.DOCUMENT_POSITION_FOLLOWING
|
||||
).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('WorkspacePanelContent members tab label', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
@@ -41,8 +41,6 @@
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<BillingStatusBanner class="mt-4" />
|
||||
|
||||
<TabsContent value="plan" class="mt-4">
|
||||
<SubscriptionPanelContentWorkspace />
|
||||
</TabsContent>
|
||||
@@ -60,7 +58,6 @@ import { computed, onMounted, ref } from 'vue'
|
||||
import { TabsContent, TabsList, TabsRoot, TabsTrigger } from 'reka-ui'
|
||||
|
||||
import WorkspaceProfilePic from '@/platform/workspace/components/WorkspaceProfilePic.vue'
|
||||
import BillingStatusBanner from '@/platform/workspace/components/dialogs/settings/BillingStatusBanner.vue'
|
||||
import MembersPanelContent from '@/platform/workspace/components/dialogs/settings/MembersPanelContent.vue'
|
||||
import SubscriptionPanelContentWorkspace from '@/platform/workspace/components/SubscriptionPanelContentWorkspace.vue'
|
||||
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { BillingBannerInputs } from './useBillingBanner'
|
||||
import { deriveBillingBanner } from './useBillingBanner'
|
||||
|
||||
const funded: BillingBannerInputs = {
|
||||
billingControlEnabled: true,
|
||||
isTeamPlan: true,
|
||||
isLoaded: true,
|
||||
isActiveSubscription: true,
|
||||
billingStatus: 'paid',
|
||||
hasFunds: true,
|
||||
isCancelled: false,
|
||||
endDate: null,
|
||||
canManage: true,
|
||||
outOfCreditsDismissed: false
|
||||
}
|
||||
|
||||
// The backend folds billing_status into is_active, so every spend-denying status
|
||||
// arrives paired with is_active=false. Pinning that pairing is what keeps these
|
||||
// cases honest — spreading `funded` instead would assert an input the backend
|
||||
// cannot emit, and pass no matter where the check sits.
|
||||
const paused: Partial<BillingBannerInputs> = {
|
||||
billingStatus: 'paused',
|
||||
isActiveSubscription: false
|
||||
}
|
||||
|
||||
const paymentFailed: Partial<BillingBannerInputs> = {
|
||||
billingStatus: 'payment_failed',
|
||||
isActiveSubscription: false
|
||||
}
|
||||
|
||||
function derive(overrides: Partial<BillingBannerInputs>) {
|
||||
return deriveBillingBanner({ ...funded, ...overrides })
|
||||
}
|
||||
|
||||
describe('deriveBillingBanner', () => {
|
||||
it('shows no banner for a healthy funded team', () => {
|
||||
expect(derive({})).toBeNull()
|
||||
})
|
||||
|
||||
it('shows no banner outside a team plan', () => {
|
||||
expect(derive({ isTeamPlan: false, hasFunds: false })).toBeNull()
|
||||
})
|
||||
|
||||
it('shows no banner when billing control is rolled back, even out of credits', () => {
|
||||
expect(derive({ billingControlEnabled: false, hasFunds: false })).toBeNull()
|
||||
})
|
||||
|
||||
it('shows no banner until the subscription snapshot has loaded', () => {
|
||||
expect(derive({ isLoaded: false, hasFunds: false })).toBeNull()
|
||||
})
|
||||
|
||||
it('surfaces out of credits when the balance is exhausted', () => {
|
||||
expect(derive({ hasFunds: false })).toBe('outOfCredits')
|
||||
})
|
||||
|
||||
it('shows out of credits to members too', () => {
|
||||
expect(derive({ hasFunds: false, canManage: false })).toBe('outOfCredits')
|
||||
})
|
||||
|
||||
it('hides out of credits once dismissed', () => {
|
||||
expect(derive({ hasFunds: false, outOfCreditsDismissed: true })).toBeNull()
|
||||
})
|
||||
|
||||
it('shows payment failed to owners even though the backend reports the plan inactive', () => {
|
||||
expect(derive(paymentFailed)).toBe('paymentFailed')
|
||||
})
|
||||
|
||||
it('prioritizes payment failure over out of credits for owners', () => {
|
||||
expect(derive({ ...paymentFailed, hasFunds: false })).toBe('paymentFailed')
|
||||
})
|
||||
|
||||
it('hides payment failed from members, who get the run-lock modal instead', () => {
|
||||
expect(derive({ ...paymentFailed, canManage: false })).toBeNull()
|
||||
})
|
||||
|
||||
it('prioritizes paused above everything, for owners and members', () => {
|
||||
expect(derive({ ...paused, hasFunds: false })).toBe('paused')
|
||||
expect(derive({ ...paused, canManage: false })).toBe('paused')
|
||||
})
|
||||
|
||||
it('shows paused even though the backend reports the workspace inactive', () => {
|
||||
expect(derive(paused)).toBe('paused')
|
||||
})
|
||||
|
||||
it('surfaces the ending banner for a cancelled-but-active owner', () => {
|
||||
expect(
|
||||
derive({
|
||||
isCancelled: true,
|
||||
endDate: '2026-08-01T00:00:00Z'
|
||||
})
|
||||
).toBe('ending')
|
||||
})
|
||||
|
||||
it('does not show the ending banner until the end date is populated', () => {
|
||||
expect(
|
||||
derive({
|
||||
isCancelled: true,
|
||||
endDate: null
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('hides the ending banner from members', () => {
|
||||
expect(
|
||||
derive({
|
||||
isCancelled: true,
|
||||
endDate: '2026-08-01T00:00:00Z',
|
||||
canManage: false
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('shows no banner for an inactive subscription (that is a run-lock modal)', () => {
|
||||
expect(
|
||||
derive({ isActiveSubscription: false, billingStatus: 'inactive' })
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -1,99 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
billing: null as {
|
||||
isActiveSubscription: { value: boolean }
|
||||
isTeamPlan: { value: boolean }
|
||||
billingStatus: { value: string | null }
|
||||
subscription: { value: { hasFunds: boolean } | null }
|
||||
} | null,
|
||||
billingControlEnabled: null as { value: boolean } | null
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({ isCloud: true }))
|
||||
|
||||
vi.mock('@/composables/useFeatureFlags', async () => {
|
||||
const { ref } = await import('vue')
|
||||
const billingControlEnabled = ref(true)
|
||||
mocks.billingControlEnabled = billingControlEnabled
|
||||
return {
|
||||
useFeatureFlags: () => ({
|
||||
flags: {
|
||||
get billingControlEnabled() {
|
||||
return billingControlEnabled.value
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/composables/billing/useBillingContext', async () => {
|
||||
const { ref } = await import('vue')
|
||||
const billing = {
|
||||
isActiveSubscription: ref(true),
|
||||
isTeamPlan: ref(true),
|
||||
billingStatus: ref<string | null>('paid'),
|
||||
subscription: ref<{ hasFunds: boolean } | null>({ hasFunds: true })
|
||||
}
|
||||
mocks.billing = billing
|
||||
return { useBillingContext: () => billing }
|
||||
})
|
||||
|
||||
vi.mock('@/platform/workspace/composables/useWorkspaceUI', async () => {
|
||||
const { computed } = await import('vue')
|
||||
return {
|
||||
useWorkspaceUI: () => ({
|
||||
permissions: computed(() => ({
|
||||
canManageSubscription: true,
|
||||
canManageSubscriptionLifecycle: true,
|
||||
canTopUp: true
|
||||
}))
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
import { useBillingBanner } from './useBillingBanner'
|
||||
|
||||
describe('useBillingBanner', () => {
|
||||
beforeEach(() => {
|
||||
const b = mocks.billing!
|
||||
b.isActiveSubscription.value = true
|
||||
b.isTeamPlan.value = true
|
||||
b.billingStatus.value = 'paid'
|
||||
b.subscription.value = { hasFunds: true }
|
||||
mocks.billingControlEnabled!.value = true
|
||||
})
|
||||
|
||||
it('suppresses the banner entirely when billing control is rolled back', async () => {
|
||||
const b = mocks.billing!
|
||||
const { kind } = useBillingBanner()
|
||||
|
||||
b.subscription.value = { hasFunds: false }
|
||||
await nextTick()
|
||||
expect(kind.value).toBe('outOfCredits')
|
||||
|
||||
mocks.billingControlEnabled!.value = false
|
||||
await nextTick()
|
||||
expect(kind.value).toBeNull()
|
||||
})
|
||||
|
||||
it('re-shows the out-of-credits banner after a top-up and a later exhaustion', async () => {
|
||||
const b = mocks.billing!
|
||||
const { kind, dismiss } = useBillingBanner()
|
||||
|
||||
b.subscription.value = { hasFunds: false }
|
||||
await nextTick()
|
||||
expect(kind.value).toBe('outOfCredits')
|
||||
|
||||
dismiss()
|
||||
await nextTick()
|
||||
expect(kind.value).toBeNull()
|
||||
|
||||
b.subscription.value = { hasFunds: true }
|
||||
await nextTick()
|
||||
b.subscription.value = { hasFunds: false }
|
||||
await nextTick()
|
||||
expect(kind.value).toBe('outOfCredits')
|
||||
})
|
||||
})
|
||||
@@ -1,106 +0,0 @@
|
||||
import { createSharedComposable } from '@vueuse/core'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import type { BillingStatus } from '@/platform/workspace/api/workspaceApi'
|
||||
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
|
||||
|
||||
export type BillingBannerKind =
|
||||
| 'paused'
|
||||
| 'paymentFailed'
|
||||
| 'outOfCredits'
|
||||
| 'ending'
|
||||
|
||||
export interface BillingBannerInputs {
|
||||
billingControlEnabled: boolean
|
||||
isTeamPlan: boolean
|
||||
isLoaded: boolean
|
||||
isActiveSubscription: boolean
|
||||
billingStatus: BillingStatus | null
|
||||
hasFunds: boolean | null
|
||||
isCancelled: boolean
|
||||
endDate: string | null
|
||||
canManage: boolean
|
||||
outOfCreditsDismissed: boolean
|
||||
}
|
||||
|
||||
// The single billing banner slot, in priority order: paused > paymentFailed >
|
||||
// outOfCredits > ending. billingControlEnabled is the FE-1246 kill switch: the
|
||||
// whole banner is behind it so a PostHog rollback hides it for everyone. Then
|
||||
// gated on the team PLAN rather than the workspace type, because personal
|
||||
// workspaces are due to gain team plans (BE-1526) — a workspace-type gate would
|
||||
// then hide the banner from real team subscribers.
|
||||
export function deriveBillingBanner(
|
||||
inputs: BillingBannerInputs
|
||||
): BillingBannerKind | null {
|
||||
if (!inputs.billingControlEnabled || !inputs.isTeamPlan || !inputs.isLoaded) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Both sit above the isActiveSubscription gate because the backend folds
|
||||
// billing_status into is_active: paused and payment_failed each report
|
||||
// is_active=false, so either check would be dead code below it.
|
||||
if (inputs.billingStatus === 'paused') return 'paused'
|
||||
if (inputs.billingStatus === 'payment_failed' && inputs.canManage) {
|
||||
return 'paymentFailed'
|
||||
}
|
||||
|
||||
// Inactive workspaces surface a run-lock modal, not this banner. Members hit
|
||||
// this on payment_failed, which is per design — only billing managers see it.
|
||||
if (!inputs.isActiveSubscription) return null
|
||||
|
||||
if (inputs.hasFunds === false && !inputs.outOfCreditsDismissed) {
|
||||
return 'outOfCredits'
|
||||
}
|
||||
if (inputs.isCancelled && inputs.endDate && inputs.canManage) {
|
||||
return 'ending'
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function useBillingBannerInternal() {
|
||||
const { isActiveSubscription, billingStatus, subscription, isTeamPlan } =
|
||||
useBillingContext()
|
||||
const { permissions } = useWorkspaceUI()
|
||||
const { flags } = useFeatureFlags()
|
||||
|
||||
const dismissed = ref(false)
|
||||
|
||||
const kind = computed<BillingBannerKind | null>(() => {
|
||||
if (!isCloud) return null
|
||||
return deriveBillingBanner({
|
||||
billingControlEnabled: flags.billingControlEnabled,
|
||||
isTeamPlan: isTeamPlan.value,
|
||||
isLoaded: subscription.value !== null,
|
||||
isActiveSubscription: isActiveSubscription.value,
|
||||
billingStatus: billingStatus.value,
|
||||
hasFunds: subscription.value?.hasFunds ?? null,
|
||||
isCancelled: subscription.value?.isCancelled ?? false,
|
||||
endDate: subscription.value?.endDate ?? null,
|
||||
canManage: permissions.value.canManageSubscription,
|
||||
outOfCreditsDismissed: dismissed.value
|
||||
})
|
||||
})
|
||||
|
||||
// Dismiss silences only the out-of-credits banner, and only for the current
|
||||
// exhaustion episode: reset once the workspace is funded again so a later
|
||||
// exhaustion re-shows. Shared state, so it survives the settings panel
|
||||
// unmounting when the dialog closes.
|
||||
const hasExhaustedFunds = computed(
|
||||
() => subscription.value?.hasFunds === false
|
||||
)
|
||||
watch(hasExhaustedFunds, (exhausted) => {
|
||||
if (!exhausted) dismissed.value = false
|
||||
})
|
||||
|
||||
function dismiss() {
|
||||
dismissed.value = true
|
||||
}
|
||||
|
||||
return { kind, dismiss }
|
||||
}
|
||||
|
||||
export const useBillingBanner = createSharedComposable(useBillingBannerInternal)
|
||||
@@ -12,7 +12,7 @@ import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
|
||||
const billingMock = vi.hoisted(() => ({
|
||||
canRunWorkflows: true
|
||||
isActiveSubscription: true
|
||||
}))
|
||||
|
||||
const overlayMock = vi.hoisted(() => ({
|
||||
@@ -22,7 +22,7 @@ const overlayMock = vi.hoisted(() => ({
|
||||
|
||||
vi.mock('@/composables/billing/useBillingContext', () => ({
|
||||
useBillingContext: () => ({
|
||||
canRunWorkflows: billingMock.canRunWorkflows
|
||||
isActiveSubscription: billingMock.isActiveSubscription
|
||||
})
|
||||
}))
|
||||
|
||||
@@ -77,14 +77,14 @@ const nodeErrors: Record<string, NodeError> = {
|
||||
|
||||
function renderControls({
|
||||
hasError = false,
|
||||
canRunWorkflows = true,
|
||||
isActiveSubscription = true,
|
||||
mobile = false
|
||||
}: {
|
||||
hasError?: boolean
|
||||
canRunWorkflows?: boolean
|
||||
isActiveSubscription?: boolean
|
||||
mobile?: boolean
|
||||
} = {}) {
|
||||
billingMock.canRunWorkflows = canRunWorkflows
|
||||
billingMock.isActiveSubscription = isActiveSubscription
|
||||
|
||||
const pinia = createTestingPinia({
|
||||
createSpy: vi.fn,
|
||||
@@ -120,7 +120,7 @@ function renderControls({
|
||||
describe('LinearControls', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
billingMock.canRunWorkflows = true
|
||||
billingMock.isActiveSubscription = true
|
||||
overlayMock.overlayMessage = 'KSampler is missing a required input: model'
|
||||
overlayMock.overlayTitle = 'Required input missing'
|
||||
})
|
||||
@@ -187,7 +187,7 @@ describe('LinearControls', () => {
|
||||
({ mobile }) => {
|
||||
renderControls({
|
||||
hasError: true,
|
||||
canRunWorkflows: false,
|
||||
isActiveSubscription: false,
|
||||
mobile
|
||||
})
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ import ScrubableNumberInput from '@/components/common/ScrubableNumberInput.vue'
|
||||
import Popover from '@/components/ui/Popover.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import FreeTierQuota from '@/platform/cloud/subscription/components/FreeTierQuota.vue'
|
||||
import SubscribeToRunButton from '@/platform/cloud/subscription/components/SubscribeToRun.vue'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
@@ -29,7 +28,7 @@ const { t } = useI18n()
|
||||
const commandStore = useCommandStore()
|
||||
const { batchCount } = storeToRefs(useQueueSettingsStore())
|
||||
const settingStore = useSettingStore()
|
||||
const { canRunWorkflows } = useBillingContext()
|
||||
const { isActiveSubscription } = useBillingContext()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const { isBuilderMode } = useAppMode()
|
||||
const appModeStore = useAppModeStore()
|
||||
@@ -55,7 +54,7 @@ const linearRunButtonTestId = 'linear-run-button'
|
||||
const showRunErrorWarning = computed(
|
||||
() =>
|
||||
hasAnyError.value &&
|
||||
toValue(canRunWorkflows) &&
|
||||
toValue(isActiveSubscription) &&
|
||||
toValue(overlayMessage).trim().length > 0
|
||||
)
|
||||
|
||||
@@ -153,7 +152,10 @@ function handleDragDrop() {
|
||||
class="border-t border-node-component-border p-4 pb-6"
|
||||
>
|
||||
<LinearRunErrorWarning v-if="showRunErrorWarning" />
|
||||
<SubscribeToRunButton v-if="!canRunWorkflows" class="mt-4 w-full" />
|
||||
<SubscribeToRunButton
|
||||
v-if="!isActiveSubscription"
|
||||
class="mt-4 w-full"
|
||||
/>
|
||||
<div v-else class="mt-4 flex">
|
||||
<PartnerNodesList mobile />
|
||||
<Popover side="top" @open-auto-focus.prevent>
|
||||
@@ -208,7 +210,10 @@ function handleDragDrop() {
|
||||
:max="settingStore.get('Comfy.QueueButton.BatchCountLimit')"
|
||||
class="h-7 min-w-40"
|
||||
/>
|
||||
<SubscribeToRunButton v-if="!canRunWorkflows" class="mt-4 w-full" />
|
||||
<SubscribeToRunButton
|
||||
v-if="!isActiveSubscription"
|
||||
class="mt-4 w-full"
|
||||
/>
|
||||
<Button
|
||||
v-else
|
||||
variant="primary"
|
||||
@@ -224,7 +229,6 @@ function handleDragDrop() {
|
||||
<i aria-hidden="true" class="icon-[lucide--play]" />
|
||||
{{ t('menu.run') }}
|
||||
</Button>
|
||||
<FreeTierQuota />
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,17 +4,33 @@ import {
|
||||
CollapsibleRoot,
|
||||
CollapsibleTrigger
|
||||
} from 'reka-ui'
|
||||
import { computed, toValue } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import Popover from '@/components/ui/Popover.vue'
|
||||
import { useCreditsBadgesInGraph } from '@/composables/node/usePriceBadge'
|
||||
import { usePriceBadge } from '@/composables/node/usePriceBadge'
|
||||
import PartnerNodeItem from '@/renderer/extensions/linearMode/PartnerNodeItem.vue'
|
||||
import { trackNodePrice } from '@/renderer/extensions/vueNodes/composables/usePartitionedBadges'
|
||||
import { app } from '@/scripts/app'
|
||||
import { mapAllNodes } from '@/utils/graphTraversalUtil'
|
||||
|
||||
defineProps<{ mobile?: boolean }>()
|
||||
|
||||
const creditsBadges = useCreditsBadgesInGraph()
|
||||
const { isCreditsBadge } = usePriceBadge()
|
||||
const { t } = useI18n()
|
||||
|
||||
const creditsBadges = computed(() =>
|
||||
mapAllNodes(app.graph, (node) => {
|
||||
if (node.isSubgraphNode()) return
|
||||
|
||||
const priceBadge = node.badges.find(isCreditsBadge)
|
||||
if (!priceBadge) return
|
||||
|
||||
trackNodePrice(node)
|
||||
return [node.title, toValue(priceBadge).text, node.id] as const
|
||||
})
|
||||
)
|
||||
</script>
|
||||
<template>
|
||||
<Popover v-if="mobile && creditsBadges.length" side="top">
|
||||
|
||||
@@ -24,7 +24,6 @@ import { snapPoint } from '@/lib/litegraph/src/measure'
|
||||
import type { Vector2 } from '@/lib/litegraph/src/litegraph'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
|
||||
import { useFreeTierQuota } from '@/platform/cloud/subscription/composables/useFreeTierQuota'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
@@ -1814,7 +1813,6 @@ export class ComfyApp {
|
||||
isPartialExecution
|
||||
})
|
||||
}
|
||||
useFreeTierQuota().trackRun()
|
||||
this.canvas.draw(true, true)
|
||||
await this.ui.queue.update()
|
||||
}
|
||||
|
||||
@@ -1,123 +0,0 @@
|
||||
/**
|
||||
* showTopUpCreditsDialog must route team members (who cannot top up) to the
|
||||
* read-only contact-admin notice instead of the purchase dialog, while
|
||||
* owners/personal/legacy users keep the purchase flow.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const showDialog = vi.hoisted(() => vi.fn())
|
||||
const closeDialog = vi.hoisted(() => vi.fn())
|
||||
const state = vi.hoisted(() => ({
|
||||
isActiveSubscription: true,
|
||||
isFreeTier: false,
|
||||
type: 'workspace' as 'workspace' | 'legacy',
|
||||
canTopUp: true
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/dialogStore', () => ({
|
||||
useDialogStore: () => ({ showDialog, closeDialog })
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n', () => ({
|
||||
t: (key: string) => key
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: () => ({ trackEvent: vi.fn() })
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
isCloud: true
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/billing/useBillingContext', () => ({
|
||||
useBillingContext: () => ({
|
||||
isActiveSubscription: { value: state.isActiveSubscription },
|
||||
isFreeTier: { value: state.isFreeTier },
|
||||
type: { value: state.type }
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workspace/composables/useWorkspaceUI', () => ({
|
||||
useWorkspaceUI: () => ({
|
||||
permissions: { value: { canTopUp: state.canTopUp } }
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/updates/common/toastStore', () => ({
|
||||
useToastStore: () => ({ add: vi.fn() })
|
||||
}))
|
||||
|
||||
const showSubscriptionDialog = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock(
|
||||
'@/platform/cloud/subscription/composables/useSubscriptionDialog',
|
||||
() => ({
|
||||
useSubscriptionDialog: () => ({ show: showSubscriptionDialog })
|
||||
})
|
||||
)
|
||||
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
|
||||
describe('showTopUpCreditsDialog', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
state.isActiveSubscription = true
|
||||
state.isFreeTier = false
|
||||
state.type = 'workspace'
|
||||
state.canTopUp = true
|
||||
})
|
||||
|
||||
it('shows the purchase dialog to users who can top up', async () => {
|
||||
await useDialogService().showTopUpCreditsDialog({
|
||||
isInsufficientCredits: true
|
||||
})
|
||||
|
||||
const [args] = showDialog.mock.calls[0]
|
||||
expect(args.key).toBe('top-up-credits')
|
||||
})
|
||||
|
||||
it('shows the contact-admin notice to team members instead of the purchase dialog', async () => {
|
||||
state.canTopUp = false
|
||||
|
||||
await useDialogService().showTopUpCreditsDialog({
|
||||
isInsufficientCredits: true
|
||||
})
|
||||
|
||||
const [args] = showDialog.mock.calls[0]
|
||||
expect(args.key).toBe('insufficient-credits-member')
|
||||
// The member notice draws its own header + close button, so it must open
|
||||
// headless or Reka wraps it in duplicate chrome.
|
||||
expect(args.dialogComponentProps.headless).toBe(true)
|
||||
expect(args.dialogComponentProps.renderer).toBe('reka')
|
||||
|
||||
args.props.onClose()
|
||||
expect(closeDialog).toHaveBeenCalledWith({
|
||||
key: 'insufficient-credits-member'
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores workspace permissions on legacy billing', async () => {
|
||||
state.type = 'legacy'
|
||||
state.canTopUp = false
|
||||
|
||||
await useDialogService().showTopUpCreditsDialog()
|
||||
|
||||
const [args] = showDialog.mock.calls[0]
|
||||
expect(args.key).toBe('top-up-credits')
|
||||
})
|
||||
|
||||
it('routes a member of an inactive team to the subscription-required flow, not the credits notice', async () => {
|
||||
state.isActiveSubscription = false
|
||||
state.canTopUp = false
|
||||
|
||||
await useDialogService().showTopUpCreditsDialog({
|
||||
isInsufficientCredits: true
|
||||
})
|
||||
|
||||
expect(showSubscriptionDialog).toHaveBeenCalledWith({
|
||||
reason: 'out_of_credits'
|
||||
})
|
||||
expect(showDialog).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -5,9 +5,7 @@ import ConfirmationDialogContent from '@/components/dialog/content/ConfirmationD
|
||||
import ErrorDialogContent from '@/components/dialog/content/ErrorDialogContent.vue'
|
||||
import PromptDialogContent from '@/components/dialog/content/PromptDialogContent.vue'
|
||||
import TopUpCreditsDialogContentLegacy from '@/components/dialog/content/TopUpCreditsDialogContentLegacy.vue'
|
||||
import InsufficientCreditsMemberDialog from '@/platform/workspace/components/InsufficientCreditsMemberDialog.vue'
|
||||
import TopUpCreditsDialogContentWorkspace from '@/platform/workspace/components/TopUpCreditsDialogContentWorkspace.vue'
|
||||
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
|
||||
import { t } from '@/i18n'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
@@ -341,28 +339,6 @@ export const useDialogService = () => {
|
||||
return
|
||||
}
|
||||
|
||||
// Members can't top up a team workspace, so they get a read-only
|
||||
// "ask your workspace admins" notice instead of the purchase dialog.
|
||||
if (
|
||||
type.value === 'workspace' &&
|
||||
!useWorkspaceUI().permissions.value.canTopUp
|
||||
) {
|
||||
return dialogStore.showDialog({
|
||||
key: 'insufficient-credits-member',
|
||||
component: InsufficientCreditsMemberDialog,
|
||||
props: {
|
||||
onClose: () =>
|
||||
dialogStore.closeDialog({ key: 'insufficient-credits-member' })
|
||||
},
|
||||
dialogComponentProps: {
|
||||
renderer: 'reka',
|
||||
headless: true,
|
||||
contentClass:
|
||||
'w-[min(360px,95vw)] max-w-[min(360px,95vw)] sm:max-w-[min(360px,95vw)] border-0 bg-transparent shadow-none'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const component =
|
||||
type.value === 'workspace'
|
||||
? TopUpCreditsDialogContentWorkspace
|
||||
|
||||
@@ -2,42 +2,14 @@ import { computed, ref } from 'vue'
|
||||
|
||||
import type { BillingContext } from '@/composables/billing/types'
|
||||
|
||||
type Subscription = BillingContext['subscription']['value']
|
||||
|
||||
/** The billing state a story wants the stub to report. */
|
||||
export interface BillingContextMockState {
|
||||
subscription: Subscription
|
||||
isActiveSubscription: boolean
|
||||
isTeamPlan: boolean
|
||||
billingStatus: BillingContext['billingStatus']['value']
|
||||
subscriptionStatus: BillingContext['subscriptionStatus']['value']
|
||||
renewalDate: string | null
|
||||
}
|
||||
|
||||
const defaultState: BillingContextMockState = {
|
||||
subscription: null,
|
||||
isActiveSubscription: false,
|
||||
isTeamPlan: false,
|
||||
billingStatus: null,
|
||||
subscriptionStatus: null,
|
||||
renewalDate: null
|
||||
}
|
||||
|
||||
const state = ref<BillingContextMockState>({ ...defaultState })
|
||||
|
||||
/** Drives the stub from a story's `beforeEach`. */
|
||||
export function setBillingContextMock(next: Partial<BillingContextMockState>) {
|
||||
state.value = { ...defaultState, ...next }
|
||||
}
|
||||
|
||||
/**
|
||||
* Storybook mock for `useBillingContext`.
|
||||
*
|
||||
* The real facade lazily instantiates the legacy billing adapter, which pulls
|
||||
* in Firebase auth (`setPersistence`) and crashes in the Storybook environment
|
||||
* (no Firebase). This stub lets billing components — e.g. UnifiedPricingTable,
|
||||
* BillingStatusBanner — render without any network or auth. It defaults to the
|
||||
* unsubscribed state; call `setBillingContextMock` to drive a specific one.
|
||||
* (no Firebase). This static stub lets presentational billing components — e.g.
|
||||
* UnifiedPricingTable — render against their `TIER_PRICING` / DES-197 fallbacks
|
||||
* without any network or auth.
|
||||
*
|
||||
* Typed against `BillingContext` so the stub stays in lockstep with the real
|
||||
* composable's return shape: drifted or removed keys fail to compile.
|
||||
@@ -46,7 +18,7 @@ export function useBillingContext(): BillingContext {
|
||||
return {
|
||||
type: computed(() => 'legacy' as const),
|
||||
isInitialized: ref(true),
|
||||
subscription: computed(() => state.value.subscription),
|
||||
subscription: computed(() => null),
|
||||
balance: computed(() => null),
|
||||
plans: computed(() => []),
|
||||
currentPlanSlug: computed(() => null),
|
||||
@@ -54,15 +26,13 @@ export function useBillingContext(): BillingContext {
|
||||
currentTeamCreditStop: computed(() => null),
|
||||
isLoading: ref(false),
|
||||
error: ref<string | null>(null),
|
||||
isActiveSubscription: computed(() => state.value.isActiveSubscription),
|
||||
canRunWorkflows: computed(() => state.value.isActiveSubscription),
|
||||
isActiveSubscription: computed(() => false),
|
||||
isFreeTier: computed(() => false),
|
||||
isLegacyTeamPlan: computed(() => false),
|
||||
isTeamPlan: computed(() => state.value.isTeamPlan),
|
||||
billingStatus: computed(() => state.value.billingStatus),
|
||||
subscriptionStatus: computed(() => state.value.subscriptionStatus),
|
||||
billingStatus: computed(() => null),
|
||||
subscriptionStatus: computed(() => null),
|
||||
tier: computed(() => null),
|
||||
renewalDate: computed(() => state.value.renewalDate),
|
||||
renewalDate: computed(() => null),
|
||||
getMaxSeats: (tierKey: string) => ({ creator: 5, pro: 20 })[tierKey] ?? 1,
|
||||
initialize: async () => {},
|
||||
fetchStatus: async () => {},
|
||||
|
||||
@@ -26,14 +26,14 @@ export enum ServerFeatureFlag {
|
||||
COMFYHUB_PROFILE_GATE_ENABLED = 'comfyhub_profile_gate_enabled',
|
||||
SHOW_SIGNIN_BUTTON = 'show_signin_button',
|
||||
UNIFIED_CLOUD_AUTH = 'unified_cloud_auth',
|
||||
BILLING_CONTROL_ENABLED = 'billing_control_enabled'
|
||||
CONSOLIDATED_BILLING_ENABLED = 'consolidated_billing_enabled'
|
||||
}
|
||||
|
||||
export function useFeatureFlags() {
|
||||
return {
|
||||
flags: {
|
||||
teamWorkspacesEnabled: true,
|
||||
billingControlEnabled: true
|
||||
consolidatedBillingEnabled: true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import type { WorkspaceType } from '@/platform/workspace/api/workspaceApi'
|
||||
|
||||
/** The workspace role/permission state a story wants the stub to report. */
|
||||
export interface WorkspaceUIMockState {
|
||||
workspaceType: WorkspaceType
|
||||
canManageSubscription: boolean
|
||||
canManageSubscriptionLifecycle: boolean
|
||||
canTopUp: boolean
|
||||
}
|
||||
|
||||
const defaultState: WorkspaceUIMockState = {
|
||||
workspaceType: 'team',
|
||||
canManageSubscription: true,
|
||||
canManageSubscriptionLifecycle: true,
|
||||
canTopUp: true
|
||||
}
|
||||
|
||||
const state = ref<WorkspaceUIMockState>({ ...defaultState })
|
||||
|
||||
/** Drives the stub from a story's `beforeEach`. */
|
||||
export function setWorkspaceUIMock(next: Partial<WorkspaceUIMockState>) {
|
||||
state.value = { ...defaultState, ...next }
|
||||
}
|
||||
|
||||
/**
|
||||
* Storybook mock for `useWorkspaceUI`.
|
||||
*
|
||||
* The real composable derives permissions from `useCurrentUser` (Firebase auth)
|
||||
* and the team workspace store, neither of which is available in Storybook. This
|
||||
* stub exposes only the role surface the billing banner reads; add keys here as
|
||||
* other stories need them.
|
||||
*/
|
||||
export function useWorkspaceUI() {
|
||||
return {
|
||||
workspaceType: computed(() => state.value.workspaceType),
|
||||
permissions: computed(() => ({
|
||||
canManageSubscription: state.value.canManageSubscription,
|
||||
canManageSubscriptionLifecycle:
|
||||
state.value.canManageSubscriptionLifecycle,
|
||||
canTopUp: state.value.canTopUp
|
||||
}))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user