Compare commits
8 Commits
codex/code
...
bl/recursi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
248a65b975 | ||
|
|
684b0b08b0 | ||
|
|
95b121bed9 | ||
|
|
baeb6df662 | ||
|
|
e58b231664 | ||
|
|
545b48ee5b | ||
|
|
22ea53fb56 | ||
|
|
9fe5dd51b8 |
@@ -63,62 +63,3 @@ reviews:
|
||||
Pass if none of these patterns are found in the diff.
|
||||
|
||||
When warning, reference the specific ADR by number and link to `docs/adr/` for context. Frame findings as directional guidance since ADR 0003 and 0008 are in Proposed status.
|
||||
|
||||
- name: App queue credential cleanup on rejection
|
||||
mode: warning
|
||||
instructions: |
|
||||
Use only PR metadata already available in the review context: the changed-file list relative to the PR base, the PR description, and the diff content. Do not rely on shell commands.
|
||||
|
||||
This check applies ONLY when the PR changes `src/scripts/app.ts` and that diff assigns `api.authToken` or `api.apiKey` before awaiting `api.queuePrompt`.
|
||||
|
||||
When applicable, require a changed app test file such as `src/scripts/app.core.test.ts` or `src/scripts/app.test.ts` to include rejected-queue coverage that:
|
||||
1. Populates both credential sources (`authToken` and API key, or the stores that feed them) with non-empty values.
|
||||
2. Makes `api.queuePrompt` reject.
|
||||
3. Calls `app.queuePrompt`.
|
||||
4. Asserts after the rejection path that both `api.authToken` and `api.apiKey` are cleared, deleted, or `undefined`.
|
||||
|
||||
Warn if rejection is tested without those cleanup assertions, or if cleanup is asserted only on the successful `api.queuePrompt` path. Mention that success-path cleanup alone does not prove credentials are cleared after a failed queue request.
|
||||
|
||||
path_instructions:
|
||||
- path: '**/*.test.ts'
|
||||
instructions: |
|
||||
Treat `.agents/checks/test-quality.md`, `docs/testing/README.md`, and `docs/guidance/vitest.md` as required review context for every changed Vitest test file.
|
||||
Flag missing behavioral coverage for changed behavior, change-detector tests, mock-heavy tests, snapshot abuse, fragile assertions, missing edge cases, unclear setup, and unrestored global mutations.
|
||||
Prefer colocated behavioral tests named after the source file.
|
||||
Build partial mocks with fromPartial<T>() from @total-typescript/shoehorn; flag `as unknown as` double assertions and fromAny().
|
||||
Mock only at seams (Pinia stores, settings, third-party libs); flag mocked type guards or sibling composables.
|
||||
Use a real createI18n instance rather than vi.mock('vue-i18n').
|
||||
Flag bare expect(fn).not.toThrow() as a sole assertion, assertions that echo stub return values, and .mock.results assertions.
|
||||
For rejected promises, thrown errors, and failed async calls, require assertions for post-error state cleanup and side-effect rollback, especially auth tokens, API keys, globals, listeners, timers, subscriptions, and caches that production mutates before awaiting.
|
||||
Tests for production changes must exercise the changed runtime/public entrypoint directly; flag helper-only coverage when the changed branch runs through a higher-level entrypoint.
|
||||
Use @testing-library/vue for component tests, not @vue/test-utils.
|
||||
For platform-owned types (Response, CustomEvent, DOM events), require real instances (new Response(), Response.json(), new CustomEvent()) instead of fromPartial or casts.
|
||||
When a fixture cast hides a too-wide production signature, suggest narrowing the production type instead of casting the fixture.
|
||||
Flag process-level listeners (process.on('unhandledRejection')) in tests; assert the rejected promise directly.
|
||||
Tests should import the module under test from its public entrypoint, not deep internal paths.
|
||||
- path: 'src/scripts/app*.test.ts'
|
||||
instructions: |
|
||||
For app tests that mock or spy on `api.queuePrompt`, require rejected-queue coverage whenever the code temporarily populates `api.authToken` or `api.apiKey`.
|
||||
The rejected-path test must populate both values, make `api.queuePrompt` reject, and assert both fields are cleared afterward; success-path cleanup alone is insufficient.
|
||||
If `api.queuePrompt` rejection is tested without asserting `api.authToken` and `api.apiKey` cleanup afterward, flag the missing assertion even when a success-path test already covers cleanup.
|
||||
- path: 'src/scripts/app.ts'
|
||||
instructions: |
|
||||
When app queuing code temporarily assigns `api.authToken` or `api.apiKey` before awaiting `api.queuePrompt`, require app tests for both resolved and rejected `api.queuePrompt` paths.
|
||||
The rejected-path test must prove both credential fields are cleared after `api.queuePrompt` rejects; a rejection assertion without cleanup checks is insufficient.
|
||||
- path: 'src/lib/litegraph/src/LGraph.ts'
|
||||
instructions: |
|
||||
When changes touch `LGraph.configure()`, graph deserialization, link loading, or nullable serialized links, require a direct regression test that constructs an `LGraph` and calls `configure()` with the changed serialized shape.
|
||||
Helper-only coverage is insufficient for these changes. For sparse legacy links, expect a v0.4 workflow with `links: [null, validLink]` to not throw and still create the valid link.
|
||||
- path: 'src/lib/litegraph/**/*.test.ts'
|
||||
instructions: |
|
||||
Treat `.agents/checks/test-quality.md`, `docs/testing/README.md`, and `docs/guidance/vitest.md` as required review context for every changed litegraph Vitest test file.
|
||||
Reuse shared factories in `src/utils/__tests__/litegraphTestUtils.ts` instead of hand-rolling litegraph mock builders.
|
||||
Flag mocked litegraph classes when a real instance or shared factory would exercise behavior directly.
|
||||
- path: '{browser_tests,apps/website/e2e}/**/*.spec.ts'
|
||||
instructions: |
|
||||
Treat `.agents/checks/test-quality.md` and `docs/testing/README.md` as required review context for every changed Playwright test file.
|
||||
Flag missing behavioral coverage, change-detector tests, mock-heavy tests, snapshot abuse, fragile assertions, missing edge cases, unclear setup, and test isolation problems.
|
||||
Every route.fulfill() body must be typed with generated types or schemas from packages/ingest-types, packages/registry-types, src/workbench/extensions/manager/types/generatedManagerTypes.ts, or src/schemas/; flag untyped inline JSON objects.
|
||||
Never use waitForTimeout; use Locator actions and auto-retrying assertions instead.
|
||||
Restrict page.evaluate() to reading internal state or fixture setup; flag any page.evaluate() that drives UI actions when a Playwright action method exists.
|
||||
New shared test helpers must be Playwright fixtures via base.extend(), not properties added to ComfyPage.
|
||||
|
||||
4
.github/workflows/pr-cursor-review.yaml
vendored
@@ -29,7 +29,7 @@ jobs:
|
||||
# SHA-pinned per zizmor `unpinned-uses: hash-pin`. Bump this SHA to pick up
|
||||
# upstream changes; keep `workflows_ref` matching so prompts/scripts load
|
||||
# from the same commit as the workflow definition.
|
||||
uses: Comfy-Org/github-workflows/.github/workflows/cursor-review.yml@047ca48febe3a6647608ed2e0c4331b491cb9d6a # github-workflows#9
|
||||
uses: Comfy-Org/github-workflows/.github/workflows/cursor-review.yml@df507e6bae179c567ad3849370f99dae588985dc # github-workflows main (df507e6)
|
||||
with:
|
||||
# Overriding diff_excludes replaces the reusable default wholesale, so
|
||||
# this restates the generated/vendored defaults and adds this repo's heavy
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
:!**/*-snapshots/**
|
||||
:!src/workbench/extensions/manager/types/generatedManagerTypes.ts
|
||||
# Load the prompts/scripts from the same ref as `uses:`.
|
||||
workflows_ref: 047ca48febe3a6647608ed2e0c4331b491cb9d6a
|
||||
workflows_ref: df507e6bae179c567ad3849370f99dae588985dc
|
||||
secrets:
|
||||
CURSOR_API_KEY: ${{ secrets.CURSOR_API_KEY }}
|
||||
# Optional — enables start/complete Slack DMs to the triggerer.
|
||||
|
||||
@@ -40,7 +40,7 @@ test.describe('Cloud page @smoke', () => {
|
||||
}
|
||||
})
|
||||
|
||||
test('AIModelsSection heading and 5 model cards are visible', async ({
|
||||
test('AIModelsSection heading and 6 model cards are visible', async ({
|
||||
page
|
||||
}) => {
|
||||
const heading = page.getByRole('heading', { name: /leading AI models/i })
|
||||
@@ -49,7 +49,7 @@ test.describe('Cloud page @smoke', () => {
|
||||
const section = heading.locator('xpath=ancestor::section')
|
||||
const grid = section.locator('.grid')
|
||||
const modelCards = grid.locator('a[href="https://comfy.org/workflows"]')
|
||||
await expect(modelCards).toHaveCount(5)
|
||||
await expect(modelCards).toHaveCount(6)
|
||||
})
|
||||
|
||||
test('AIModelsSection CTA links to workflows', async ({ page }) => {
|
||||
|
||||
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 87 KiB After Width: | Height: | Size: 88 KiB |
|
Before Width: | Height: | Size: 87 KiB After Width: | Height: | Size: 88 KiB |
@@ -30,7 +30,12 @@ const { title, description, cta, href, bg } = defineProps<{
|
||||
<p class="text-sm text-white/70">
|
||||
{{ description }}
|
||||
</p>
|
||||
<Button as="span" variant="default" size="sm" class="mt-4">
|
||||
<Button
|
||||
as="span"
|
||||
variant="default"
|
||||
size="sm"
|
||||
class="mt-4 h-auto whitespace-normal"
|
||||
>
|
||||
{{ cta }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -12,6 +12,11 @@ export type {
|
||||
AddAssetTagsErrors,
|
||||
AddAssetTagsResponse,
|
||||
AddAssetTagsResponses,
|
||||
AdminDeleteHubWorkflowData,
|
||||
AdminDeleteHubWorkflowError,
|
||||
AdminDeleteHubWorkflowErrors,
|
||||
AdminDeleteHubWorkflowResponse,
|
||||
AdminDeleteHubWorkflowResponses,
|
||||
Asset,
|
||||
AssetCreated,
|
||||
AssetCreatedWritable,
|
||||
@@ -42,6 +47,11 @@ export type {
|
||||
CancelJobErrors,
|
||||
CancelJobResponse,
|
||||
CancelJobResponses,
|
||||
CancelJobsData,
|
||||
CancelJobsError,
|
||||
CancelJobsErrors,
|
||||
CancelJobsResponse,
|
||||
CancelJobsResponses,
|
||||
CancelSubscriptionData,
|
||||
CancelSubscriptionError,
|
||||
CancelSubscriptionErrors,
|
||||
@@ -84,6 +94,11 @@ export type {
|
||||
CreateDeletionRequestErrors,
|
||||
CreateDeletionRequestResponse,
|
||||
CreateDeletionRequestResponses,
|
||||
CreateDesktopLoginCodeData,
|
||||
CreateDesktopLoginCodeError,
|
||||
CreateDesktopLoginCodeErrors,
|
||||
CreateDesktopLoginCodeResponse,
|
||||
CreateDesktopLoginCodeResponses,
|
||||
CreateHubAssetUploadUrlData,
|
||||
CreateHubAssetUploadUrlError,
|
||||
CreateHubAssetUploadUrlErrors,
|
||||
@@ -186,12 +201,31 @@ export type {
|
||||
DeleteWorkspaceResponses,
|
||||
DeletionRequest,
|
||||
DeletionStatus,
|
||||
DesktopLoginCodeCreateRequest,
|
||||
DesktopLoginCodeCreateResponse,
|
||||
DesktopLoginCodeExchangeRequest,
|
||||
DesktopLoginCodeExchangeResponse,
|
||||
DesktopLoginCodeRedeemRequest,
|
||||
DesktopLoginCodeRedeemResponse,
|
||||
DownloadExportData,
|
||||
DownloadExportError,
|
||||
DownloadExportErrors,
|
||||
DownloadExportResponse,
|
||||
DownloadExportResponses,
|
||||
EnsureWorkspaceBillingLegacySnapshot,
|
||||
EnsureWorkspaceBillingProvisionedData,
|
||||
EnsureWorkspaceBillingProvisionedError,
|
||||
EnsureWorkspaceBillingProvisionedErrors,
|
||||
EnsureWorkspaceBillingProvisionedRequest,
|
||||
EnsureWorkspaceBillingProvisionedResponse,
|
||||
EnsureWorkspaceBillingProvisionedResponse2,
|
||||
EnsureWorkspaceBillingProvisionedResponses,
|
||||
ErrorResponse,
|
||||
ExchangeDesktopLoginCodeData,
|
||||
ExchangeDesktopLoginCodeError,
|
||||
ExchangeDesktopLoginCodeErrors,
|
||||
ExchangeDesktopLoginCodeResponse,
|
||||
ExchangeDesktopLoginCodeResponses,
|
||||
ExchangeTokenData,
|
||||
ExchangeTokenError,
|
||||
ExchangeTokenErrors,
|
||||
@@ -230,6 +264,11 @@ export type {
|
||||
GetAssetByIdErrors,
|
||||
GetAssetByIdResponse,
|
||||
GetAssetByIdResponses,
|
||||
GetAssetContentData,
|
||||
GetAssetContentError,
|
||||
GetAssetContentErrors,
|
||||
GetAssetContentResponse,
|
||||
GetAssetContentResponses,
|
||||
GetAssetSeedStatusData,
|
||||
GetAssetSeedStatusResponse,
|
||||
GetAssetSeedStatusResponses,
|
||||
@@ -303,6 +342,11 @@ export type {
|
||||
GetHistoryData,
|
||||
GetHistoryError,
|
||||
GetHistoryErrors,
|
||||
GetHistoryEventsData,
|
||||
GetHistoryEventsError,
|
||||
GetHistoryEventsErrors,
|
||||
GetHistoryEventsResponse,
|
||||
GetHistoryEventsResponses,
|
||||
GetHistoryForPromptData,
|
||||
GetHistoryForPromptError,
|
||||
GetHistoryForPromptErrors,
|
||||
@@ -345,8 +389,6 @@ export type {
|
||||
GetJwksData,
|
||||
GetJwksResponse,
|
||||
GetJwksResponses,
|
||||
GetLegacyAssetContentData,
|
||||
GetLegacyAssetContentErrors,
|
||||
GetLegacyHistoryByIdData,
|
||||
GetLegacyHistoryByIdErrors,
|
||||
GetLegacyHistoryData,
|
||||
@@ -556,6 +598,7 @@ export type {
|
||||
HistoryDetailEntry,
|
||||
HistoryDetailResponse,
|
||||
HistoryEntry,
|
||||
HistoryEventRequest,
|
||||
HistoryManageRequest,
|
||||
HistoryResponse,
|
||||
HubAssetUploadUrlRequest,
|
||||
@@ -589,6 +632,8 @@ export type {
|
||||
JobCancelResponse,
|
||||
JobDetailResponse,
|
||||
JobEntry,
|
||||
JobsCancelRequest,
|
||||
JobsCancelResponse,
|
||||
JobsListResponse,
|
||||
JobStatusResponse,
|
||||
JwkKey,
|
||||
@@ -627,7 +672,19 @@ export type {
|
||||
ListJobsErrors,
|
||||
ListJobsResponse,
|
||||
ListJobsResponses,
|
||||
ListLinkedFirebaseUidsData,
|
||||
ListLinkedFirebaseUidsError,
|
||||
ListLinkedFirebaseUidsErrors,
|
||||
ListLinkedFirebaseUidsRequest,
|
||||
ListLinkedFirebaseUidsResponse,
|
||||
ListLinkedFirebaseUidsResponse2,
|
||||
ListLinkedFirebaseUidsResponses,
|
||||
ListMembersResponse,
|
||||
ListSecretProvidersData,
|
||||
ListSecretProvidersError,
|
||||
ListSecretProvidersErrors,
|
||||
ListSecretProvidersResponse,
|
||||
ListSecretProvidersResponses,
|
||||
ListSecretsData,
|
||||
ListSecretsError,
|
||||
ListSecretsErrors,
|
||||
@@ -775,6 +832,17 @@ export type {
|
||||
QueueInfo,
|
||||
QueueManageRequest,
|
||||
QueueManageResponse,
|
||||
RedeemDesktopLoginCodeData,
|
||||
RedeemDesktopLoginCodeError,
|
||||
RedeemDesktopLoginCodeErrors,
|
||||
RedeemDesktopLoginCodeResponse,
|
||||
RedeemDesktopLoginCodeResponses,
|
||||
ReleaseDeletionHoldData,
|
||||
ReleaseDeletionHoldError,
|
||||
ReleaseDeletionHoldErrors,
|
||||
ReleaseDeletionHoldResponse,
|
||||
ReleaseDeletionHoldResponses,
|
||||
ReleaseHoldResponse,
|
||||
RemoveAssetTagsData,
|
||||
RemoveAssetTagsError,
|
||||
RemoveAssetTagsErrors,
|
||||
@@ -785,6 +853,11 @@ export type {
|
||||
RemoveWorkspaceMemberErrors,
|
||||
RemoveWorkspaceMemberResponse,
|
||||
RemoveWorkspaceMemberResponses,
|
||||
ReportHistoryEventData,
|
||||
ReportHistoryEventError,
|
||||
ReportHistoryEventErrors,
|
||||
ReportHistoryEventResponse,
|
||||
ReportHistoryEventResponses,
|
||||
ReportPartnerUsageData,
|
||||
ReportPartnerUsageError,
|
||||
ReportPartnerUsageErrors,
|
||||
@@ -808,6 +881,8 @@ export type {
|
||||
RevokeWorkspaceInviteResponse,
|
||||
RevokeWorkspaceInviteResponses,
|
||||
SecretListResponse,
|
||||
SecretProvider,
|
||||
SecretProvidersResponse,
|
||||
SecretResponse,
|
||||
SeedAssetsData,
|
||||
SeedAssetsResponse,
|
||||
@@ -819,6 +894,8 @@ export type {
|
||||
SetReviewStatusResponse,
|
||||
SetReviewStatusResponse2,
|
||||
SetReviewStatusResponses,
|
||||
ShortLinkRedirectData,
|
||||
ShortLinkRedirectErrors,
|
||||
SubmitFeedbackData,
|
||||
SubmitFeedbackError,
|
||||
SubmitFeedbackErrors,
|
||||
@@ -848,6 +925,10 @@ export type {
|
||||
TaskEntry,
|
||||
TaskResponse,
|
||||
TasksListResponse,
|
||||
TeamCreditStop,
|
||||
TeamCreditStopPrice,
|
||||
TeamCreditStops,
|
||||
TeamCreditStopSummary,
|
||||
UpdateAssetData,
|
||||
UpdateAssetError,
|
||||
UpdateAssetErrors,
|
||||
@@ -865,6 +946,7 @@ export type {
|
||||
UpdateHubWorkflowRequest,
|
||||
UpdateHubWorkflowResponse,
|
||||
UpdateHubWorkflowResponses,
|
||||
UpdateMemberRoleRequest,
|
||||
UpdateMultipleSettingsData,
|
||||
UpdateMultipleSettingsError,
|
||||
UpdateMultipleSettingsErrors,
|
||||
@@ -895,6 +977,11 @@ export type {
|
||||
UpdateWorkspaceData,
|
||||
UpdateWorkspaceError,
|
||||
UpdateWorkspaceErrors,
|
||||
UpdateWorkspaceMemberRoleData,
|
||||
UpdateWorkspaceMemberRoleError,
|
||||
UpdateWorkspaceMemberRoleErrors,
|
||||
UpdateWorkspaceMemberRoleResponse,
|
||||
UpdateWorkspaceMemberRoleResponses,
|
||||
UpdateWorkspaceRequest,
|
||||
UpdateWorkspaceResponse,
|
||||
UpdateWorkspaceResponses,
|
||||
|
||||
1031
packages/ingest-types/src/types.gen.ts
generated
452
packages/ingest-types/src/zod.gen.ts
generated
@@ -465,6 +465,20 @@ export const zCreateWorkflowRequest = z.object({
|
||||
forked_from_workflow_version_id: z.string().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Request body for forwarding a comfy-api audit/history event. Identify the target workspace by either user_id (cloud resolves the user's personal workspace via the converged identity, BE-1047) or an explicit workspace_id. At least one must be provided; workspace_id wins when both are set.
|
||||
*/
|
||||
export const zHistoryEventRequest = z.object({
|
||||
user_id: z.string().optional(),
|
||||
workspace_id: z.string().optional(),
|
||||
event_type: z.string().min(1),
|
||||
event_id: z.string().min(1),
|
||||
params: z.record(z.unknown()).optional(),
|
||||
auth_method: z.enum(['api_key', 'bearer_token']).optional(),
|
||||
customer_ref: z.string().optional(),
|
||||
timestamp: z.string().datetime().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Response after recording partner usage data.
|
||||
*/
|
||||
@@ -540,11 +554,11 @@ export const zPaymentPortalRequest = z.object({
|
||||
})
|
||||
|
||||
/**
|
||||
* Response after successfully resubscribing to a billing plan.
|
||||
* Response after accepting a resubscribe request.
|
||||
*/
|
||||
export const zResubscribeResponse = z.object({
|
||||
billing_op_id: z.string(),
|
||||
status: z.enum(['active']),
|
||||
status: z.enum(['active', 'pending']),
|
||||
message: z.string().optional()
|
||||
})
|
||||
|
||||
@@ -585,6 +599,8 @@ export const zSubscribeResponse = z.object({
|
||||
*/
|
||||
export const zSubscribeRequest = z.object({
|
||||
plan_slug: z.string(),
|
||||
team_credit_stop_id: z.string().optional(),
|
||||
billing_cycle: z.enum(['monthly', 'yearly']).optional(),
|
||||
idempotency_key: z.string().optional(),
|
||||
return_url: z.string().optional(),
|
||||
cancel_url: z.string().optional()
|
||||
@@ -626,7 +642,8 @@ export const zSubscriptionTier = z.enum([
|
||||
'STANDARD',
|
||||
'CREATOR',
|
||||
'PRO',
|
||||
'FOUNDERS_EDITION'
|
||||
'FOUNDERS_EDITION',
|
||||
'TEAM'
|
||||
])
|
||||
|
||||
/**
|
||||
@@ -714,6 +731,57 @@ export const zPreviewSubscribeRequest = z.object({
|
||||
plan_slug: z.string()
|
||||
})
|
||||
|
||||
/**
|
||||
* Pre/post-discount price for a team credit stop, in cents.
|
||||
*/
|
||||
export const zTeamCreditStopPrice = z.object({
|
||||
list_price_cents: z.coerce
|
||||
.bigint()
|
||||
.min(BigInt('-9223372036854775808'), {
|
||||
message: 'Invalid value: Expected int64 to be >= -9223372036854775808'
|
||||
})
|
||||
.max(BigInt('9223372036854775807'), {
|
||||
message: 'Invalid value: Expected int64 to be <= 9223372036854775807'
|
||||
}),
|
||||
price_cents: z.coerce
|
||||
.bigint()
|
||||
.min(BigInt('-9223372036854775808'), {
|
||||
message: 'Invalid value: Expected int64 to be >= -9223372036854775808'
|
||||
})
|
||||
.max(BigInt('9223372036854775807'), {
|
||||
message: 'Invalid value: Expected int64 to be <= 9223372036854775807'
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* A selectable preset on the team pricing slider. Echoed on subscribe via
|
||||
* team_credit_stop_id; the backend owns the resolved amounts. credits is a
|
||||
* RAW monthly credit count (not cents). Save% is derived by the FE as
|
||||
* (list_price_cents - price_cents) / list_price_cents.
|
||||
*
|
||||
*/
|
||||
export const zTeamCreditStop = z.object({
|
||||
id: z.string(),
|
||||
credits: z.coerce
|
||||
.bigint()
|
||||
.min(BigInt('-9223372036854775808'), {
|
||||
message: 'Invalid value: Expected int64 to be >= -9223372036854775808'
|
||||
})
|
||||
.max(BigInt('9223372036854775807'), {
|
||||
message: 'Invalid value: Expected int64 to be <= 9223372036854775807'
|
||||
}),
|
||||
monthly: zTeamCreditStopPrice,
|
||||
yearly: zTeamCreditStopPrice
|
||||
})
|
||||
|
||||
/**
|
||||
* Credit-stop ladder for the pricing slider (BE-1254). Returned by GET /api/billing/plans for every workspace regardless of the caller's token or workspace type (the personal/team distinction was removed); omitted only when the catalog defines no stops.
|
||||
*/
|
||||
export const zTeamCreditStops = z.object({
|
||||
default_stop_index: z.number().int(),
|
||||
stops: z.array(zTeamCreditStop)
|
||||
})
|
||||
|
||||
/**
|
||||
* Reason why a plan is unavailable
|
||||
*/
|
||||
@@ -773,7 +841,50 @@ export const zPlan = z.object({
|
||||
*/
|
||||
export const zBillingPlansResponse = z.object({
|
||||
current_plan_slug: z.string().optional(),
|
||||
plans: z.array(zPlan)
|
||||
plans: z.array(zPlan),
|
||||
team_credit_stops: zTeamCreditStops.optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* The team credit stop a workspace is currently subscribed to: the
|
||||
* per-workspace slider choice recorded at subscribe time
|
||||
* (workspace_subscriptions.team_credit_stop_id). Amounts are owned by the
|
||||
* catalog, not the subscription row. Returned on GET /api/billing/status
|
||||
* for per-credit Team plans (BE-1254).
|
||||
*
|
||||
*/
|
||||
export const zTeamCreditStopSummary = z.object({
|
||||
id: z.string(),
|
||||
credits_monthly: z.coerce
|
||||
.bigint()
|
||||
.min(BigInt('-9223372036854775808'), {
|
||||
message: 'Invalid value: Expected int64 to be >= -9223372036854775808'
|
||||
})
|
||||
.max(BigInt('9223372036854775807'), {
|
||||
message: 'Invalid value: Expected int64 to be <= 9223372036854775807'
|
||||
}),
|
||||
stop_usd: z.coerce
|
||||
.bigint()
|
||||
.min(BigInt('-9223372036854775808'), {
|
||||
message: 'Invalid value: Expected int64 to be >= -9223372036854775808'
|
||||
})
|
||||
.max(BigInt('9223372036854775807'), {
|
||||
message: 'Invalid value: Expected int64 to be <= 9223372036854775807'
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* A provider the user may configure a secret for. The shape is deliberately minimal (identifier only) and reserved for future per-provider fields such as sub-keys.
|
||||
*/
|
||||
export const zSecretProvider = z.object({
|
||||
id: z.string()
|
||||
})
|
||||
|
||||
/**
|
||||
* The providers available to the authenticated user in the current workspace.
|
||||
*/
|
||||
export const zSecretProvidersResponse = z.object({
|
||||
data: z.array(zSecretProvider)
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -813,7 +924,7 @@ export const zCreateSecretRequest = z.object({
|
||||
})
|
||||
|
||||
/**
|
||||
* A single billing event such as a charge, credit, or adjustment.
|
||||
* A single history event. The cloud history-events store is the single source of truth for both billing events (charges, credits, adjustments) and user-facing usage events.
|
||||
*/
|
||||
export const zBillingEvent = z.object({
|
||||
event_type: z.string(),
|
||||
@@ -868,7 +979,8 @@ export const zBillingStatusResponse = z.object({
|
||||
billing_status: zBillingStatus.optional(),
|
||||
has_funds: z.boolean(),
|
||||
cancel_at: z.string().datetime().optional(),
|
||||
renewal_date: z.string().datetime().optional()
|
||||
renewal_date: z.string().datetime().optional(),
|
||||
team_credit_stop: zTeamCreditStopSummary.nullable()
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -930,6 +1042,7 @@ export const zOAuthConsentChallenge = z.object({
|
||||
csrf_token: z.string(),
|
||||
client_display_name: z.string(),
|
||||
resource_display_name: z.string(),
|
||||
redirect_uri: z.string().url(),
|
||||
scopes: z.array(z.string()),
|
||||
workspaces: z.array(zOAuthConsentChallengeWorkspace)
|
||||
})
|
||||
@@ -1056,6 +1169,66 @@ export const zSyncApiKeyRequest = z.object({
|
||||
customer_id: z.string().min(1)
|
||||
})
|
||||
|
||||
/**
|
||||
* The personal workspace's provisioned billing identity.
|
||||
*/
|
||||
export const zEnsureWorkspaceBillingProvisionedResponse = z.object({
|
||||
workspace_id: z.string(),
|
||||
stripe_customer_id: z.string(),
|
||||
metronome_customer_id: z.string(),
|
||||
metronome_contract_id: z.string()
|
||||
})
|
||||
|
||||
/**
|
||||
* The caller's already-resolved legacy (comfy-api) customer identity. When
|
||||
* present and carrying provider IDs, provisioning ATTACHES this identity to
|
||||
* the personal workspace (sharing the existing balance and subscription)
|
||||
* instead of minting a net-new empty customer. Omit (or send with no
|
||||
* provider IDs) for a free user with nothing to attach — provisioning then
|
||||
* creates net-new. This closes the create-new-before-attach gap: a caller
|
||||
* that already knows the legacy identity hands it over so the very first
|
||||
* provisioning is an attach.
|
||||
*
|
||||
*/
|
||||
export const zEnsureWorkspaceBillingLegacySnapshot = z.object({
|
||||
stripe_customer_id: z.string().optional(),
|
||||
metronome_customer_id: z.string().optional(),
|
||||
metronome_contract_id: z.string().optional(),
|
||||
has_funds: z.boolean().optional(),
|
||||
subscription_tier: z.string().optional(),
|
||||
legacy_stripe_subscription_id: z.string().optional(),
|
||||
legacy_comfy_user_id: z.string().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Request body for ensuring a user's personal workspace carries a fully
|
||||
* provisioned billing identity. Sent by comfy-api's CreateCustomer (BE-1047)
|
||||
* with the already canonical-resolved user identity.
|
||||
*
|
||||
*/
|
||||
export const zEnsureWorkspaceBillingProvisionedRequest = z.object({
|
||||
user_id: z.string().min(1),
|
||||
email: z.string().email().min(1),
|
||||
snapshot: zEnsureWorkspaceBillingLegacySnapshot.optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Firebase UIDs linked to the canonical comfy_user_id. Empty list when
|
||||
* no mappings exist (not an error — callers can treat empty as "unknown
|
||||
* canonical").
|
||||
*
|
||||
*/
|
||||
export const zListLinkedFirebaseUidsResponse = z.object({
|
||||
firebase_uids: z.array(z.string())
|
||||
})
|
||||
|
||||
/**
|
||||
* Request body for reverse-looking-up Firebase UIDs linked to a canonical comfy_user_id.
|
||||
*/
|
||||
export const zListLinkedFirebaseUidsRequest = z.object({
|
||||
comfy_user_id: z.string().min(1)
|
||||
})
|
||||
|
||||
/**
|
||||
* Response confirming the validity and scope of a workspace API key.
|
||||
*/
|
||||
@@ -1172,7 +1345,8 @@ export const zMember = z.object({
|
||||
name: z.string(),
|
||||
email: z.string().email(),
|
||||
role: z.enum(['owner', 'member']),
|
||||
joined_at: z.string().datetime()
|
||||
joined_at: z.string().datetime(),
|
||||
is_original_owner: z.boolean()
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -1183,6 +1357,13 @@ export const zListMembersResponse = z.object({
|
||||
pagination: zPaginationInfo
|
||||
})
|
||||
|
||||
/**
|
||||
* Request body for changing a workspace member's role.
|
||||
*/
|
||||
export const zUpdateMemberRoleRequest = z.object({
|
||||
role: z.enum(['owner', 'member'])
|
||||
})
|
||||
|
||||
/**
|
||||
* Request body for updating an existing workspace's settings.
|
||||
*/
|
||||
@@ -1227,6 +1408,60 @@ export const zWorkspace = z.object({
|
||||
created_at: z.string().datetime()
|
||||
})
|
||||
|
||||
/**
|
||||
* Exchange poll result. Pending until the code is redeemed in the browser.
|
||||
*/
|
||||
export const zDesktopLoginCodeExchangeResponse = z.object({
|
||||
status: z.enum(['pending', 'complete']),
|
||||
custom_token: z.string().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Request to exchange a redeemed login code for a custom token.
|
||||
*/
|
||||
export const zDesktopLoginCodeExchangeRequest = z.object({
|
||||
code: z.string(),
|
||||
code_verifier: z.string().min(43).max(128)
|
||||
})
|
||||
|
||||
/**
|
||||
* Result of redeeming a desktop login code.
|
||||
*/
|
||||
export const zDesktopLoginCodeRedeemResponse = z.object({
|
||||
status: z.enum(['redeemed'])
|
||||
})
|
||||
|
||||
/**
|
||||
* Request to claim a desktop login code for the authenticated user.
|
||||
*/
|
||||
export const zDesktopLoginCodeRedeemRequest = z.object({
|
||||
code: z.string()
|
||||
})
|
||||
|
||||
/**
|
||||
* A freshly minted desktop login code and its polling parameters.
|
||||
*/
|
||||
export const zDesktopLoginCodeCreateResponse = z.object({
|
||||
code: z.string(),
|
||||
expires_in: z.number().int(),
|
||||
poll_interval: z.number().int()
|
||||
})
|
||||
|
||||
/**
|
||||
* Request to mint a desktop login code.
|
||||
*/
|
||||
export const zDesktopLoginCodeCreateRequest = z.object({
|
||||
installation_id: z
|
||||
.string()
|
||||
.min(8)
|
||||
.max(128)
|
||||
.regex(/^[A-Za-z0-9._-]+$/)
|
||||
.optional(),
|
||||
platform: z.string().min(1).max(32),
|
||||
app_version: z.string().min(1).max(64),
|
||||
code_challenge: z.string().min(43).max(128)
|
||||
})
|
||||
|
||||
/**
|
||||
* Abbreviated workspace metadata used in list responses.
|
||||
*/
|
||||
@@ -1294,6 +1529,15 @@ export const zTasksListResponse = z.object({
|
||||
pagination: zPaginationInfo
|
||||
})
|
||||
|
||||
/**
|
||||
* Result of authorizing a legal-hold release on a user's deletion.
|
||||
*/
|
||||
export const zReleaseHoldResponse = z.object({
|
||||
firebase_id: z.string(),
|
||||
released: z.boolean(),
|
||||
message: z.string()
|
||||
})
|
||||
|
||||
/**
|
||||
* Current status of a user data deletion request.
|
||||
*/
|
||||
@@ -1363,6 +1607,20 @@ export const zJobDetailResponse = z.object({
|
||||
execution_meta: z.record(z.unknown()).optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Response for POST /api/jobs/cancel.
|
||||
*/
|
||||
export const zJobsCancelResponse = z.object({
|
||||
cancelled: z.array(z.string())
|
||||
})
|
||||
|
||||
/**
|
||||
* Request to cancel multiple jobs by ID.
|
||||
*/
|
||||
export const zJobsCancelRequest = z.object({
|
||||
job_ids: z.array(z.string().uuid()).min(1).max(100)
|
||||
})
|
||||
|
||||
/**
|
||||
* Response for POST /api/jobs/{job_id}/cancel. Returned on both fresh cancels and idempotent no-ops.
|
||||
*/
|
||||
@@ -1529,6 +1787,7 @@ export const zAsset = z.object({
|
||||
user_metadata: z.record(z.unknown()).optional(),
|
||||
metadata: z.record(z.unknown()).readonly().optional(),
|
||||
preview_url: z.string().url().optional(),
|
||||
short_url: z.string().nullish(),
|
||||
preview_id: z.string().uuid().nullish(),
|
||||
job_id: z.string().uuid().nullish(),
|
||||
created_at: z.string().datetime(),
|
||||
@@ -1624,6 +1883,7 @@ export const zSystemStatsResponse = z.object({
|
||||
python_version: z.string(),
|
||||
embedded_python: z.boolean(),
|
||||
comfyui_version: z.string(),
|
||||
deploy_environment: z.string().optional(),
|
||||
comfyui_frontend_version: z.string().optional(),
|
||||
workflow_templates_version: z.string().optional(),
|
||||
cloud_version: z.string().optional(),
|
||||
@@ -1962,6 +2222,7 @@ export const zAssetWritable = z.object({
|
||||
tags: z.array(z.string()).optional(),
|
||||
user_metadata: z.record(z.unknown()).optional(),
|
||||
preview_url: z.string().url().optional(),
|
||||
short_url: z.string().nullish(),
|
||||
preview_id: z.string().uuid().nullish(),
|
||||
job_id: z.string().uuid().nullish(),
|
||||
created_at: z.string().datetime(),
|
||||
@@ -2180,7 +2441,11 @@ export const zGetJobDetailData = z.object({
|
||||
path: z.object({
|
||||
job_id: z.string().uuid()
|
||||
}),
|
||||
query: z.never().optional()
|
||||
query: z
|
||||
.object({
|
||||
short_link: z.enum(['ephemeral_tool_chain', 'default']).optional()
|
||||
})
|
||||
.optional()
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -2201,6 +2466,17 @@ export const zCancelJobData = z.object({
|
||||
*/
|
||||
export const zCancelJobResponse = zJobCancelResponse
|
||||
|
||||
export const zCancelJobsData = z.object({
|
||||
body: zJobsCancelRequest,
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Success - cancel requests dispatched (or jobs were already terminal)
|
||||
*/
|
||||
export const zCancelJobsResponse = zJobsCancelResponse
|
||||
|
||||
export const zViewFileData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
@@ -2580,6 +2856,17 @@ export const zCreateSecretData = z.object({
|
||||
*/
|
||||
export const zCreateSecretResponse = zSecretResponse
|
||||
|
||||
export const zListSecretProvidersData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Success
|
||||
*/
|
||||
export const zListSecretProvidersResponse = zSecretProvidersResponse
|
||||
|
||||
export const zDeleteSecretData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.object({
|
||||
@@ -2881,6 +3168,40 @@ export const zExchangeTokenData = z.object({
|
||||
*/
|
||||
export const zExchangeTokenResponse2 = zExchangeTokenResponse
|
||||
|
||||
export const zCreateDesktopLoginCodeData = z.object({
|
||||
body: zDesktopLoginCodeCreateRequest,
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Login code created
|
||||
*/
|
||||
export const zCreateDesktopLoginCodeResponse = zDesktopLoginCodeCreateResponse
|
||||
|
||||
export const zRedeemDesktopLoginCodeData = z.object({
|
||||
body: zDesktopLoginCodeRedeemRequest,
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Code redeemed (or already redeemed by the same user)
|
||||
*/
|
||||
export const zRedeemDesktopLoginCodeResponse = zDesktopLoginCodeRedeemResponse
|
||||
|
||||
export const zExchangeDesktopLoginCodeData = z.object({
|
||||
body: zDesktopLoginCodeExchangeRequest,
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Pending (not yet redeemed) or complete with a custom token
|
||||
*/
|
||||
export const zExchangeDesktopLoginCodeResponse =
|
||||
zDesktopLoginCodeExchangeResponse
|
||||
|
||||
export const zGetJwksData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
@@ -3150,6 +3471,19 @@ export const zRemoveWorkspaceMemberData = z.object({
|
||||
*/
|
||||
export const zRemoveWorkspaceMemberResponse = z.void()
|
||||
|
||||
export const zUpdateWorkspaceMemberRoleData = z.object({
|
||||
body: zUpdateMemberRoleRequest,
|
||||
path: z.object({
|
||||
userId: z.string()
|
||||
}),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Member role updated
|
||||
*/
|
||||
export const zUpdateWorkspaceMemberRoleResponse = zMember
|
||||
|
||||
export const zListWorkspaceApiKeysData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
@@ -3236,6 +3570,19 @@ export const zSetReviewStatusData = z.object({
|
||||
*/
|
||||
export const zSetReviewStatusResponse2 = zSetReviewStatusResponse
|
||||
|
||||
export const zAdminDeleteHubWorkflowData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.object({
|
||||
share_id: z.string()
|
||||
}),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Successfully deleted
|
||||
*/
|
||||
export const zAdminDeleteHubWorkflowResponse = z.void()
|
||||
|
||||
export const zUpdateHubWorkflowData = z.object({
|
||||
body: zUpdateHubWorkflowRequest,
|
||||
path: z.object({
|
||||
@@ -3277,6 +3624,19 @@ export const zCreateDeletionRequestResponse = z.object({
|
||||
user_found_in_cloud: z.boolean()
|
||||
})
|
||||
|
||||
export const zReleaseDeletionHoldData = z.object({
|
||||
body: z.object({
|
||||
firebase_id: z.string()
|
||||
}),
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Release authorized; the deletion workflow will proceed
|
||||
*/
|
||||
export const zReleaseDeletionHoldResponse = zReleaseHoldResponse
|
||||
|
||||
export const zReportPartnerUsageData = z.object({
|
||||
body: zPartnerUsageRequest,
|
||||
path: z.never().optional(),
|
||||
@@ -3288,6 +3648,38 @@ export const zReportPartnerUsageData = z.object({
|
||||
*/
|
||||
export const zReportPartnerUsageResponse = zPartnerUsageResponse
|
||||
|
||||
export const zGetHistoryEventsData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
query: z
|
||||
.object({
|
||||
workspace_id: z.string().optional(),
|
||||
user_id: z.string().optional(),
|
||||
event_type: z.string().optional(),
|
||||
start_date: z.string().datetime().optional(),
|
||||
end_date: z.string().datetime().optional(),
|
||||
page: z.number().int().optional(),
|
||||
limit: z.number().int().optional()
|
||||
})
|
||||
.optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Paginated cloud history events for the workspace
|
||||
*/
|
||||
export const zGetHistoryEventsResponse = zBillingEventsResponse
|
||||
|
||||
export const zReportHistoryEventData = z.object({
|
||||
body: zHistoryEventRequest,
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* History event recorded successfully
|
||||
*/
|
||||
export const zReportHistoryEventResponse = zPartnerUsageResponse
|
||||
|
||||
export const zUpdateSubscriptionCacheData = z.object({
|
||||
body: z.object({
|
||||
user_id: z.string(),
|
||||
@@ -3305,6 +3697,29 @@ export const zUpdateSubscriptionCacheResponse = z.object({
|
||||
status: z.string().optional()
|
||||
})
|
||||
|
||||
export const zListLinkedFirebaseUidsData = z.object({
|
||||
body: zListLinkedFirebaseUidsRequest,
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Linked Firebase UIDs (possibly empty list)
|
||||
*/
|
||||
export const zListLinkedFirebaseUidsResponse2 = zListLinkedFirebaseUidsResponse
|
||||
|
||||
export const zEnsureWorkspaceBillingProvisionedData = z.object({
|
||||
body: zEnsureWorkspaceBillingProvisionedRequest,
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* The workspace's provisioned billing identity
|
||||
*/
|
||||
export const zEnsureWorkspaceBillingProvisionedResponse2 =
|
||||
zEnsureWorkspaceBillingProvisionedResponse
|
||||
|
||||
export const zInsertDynamicConfigData = z.object({
|
||||
body: z.record(z.unknown()),
|
||||
path: z.never().optional(),
|
||||
@@ -4010,6 +4425,14 @@ export const zGetModelPreviewData = z.object({
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
export const zShortLinkRedirectData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.object({
|
||||
id: z.string()
|
||||
}),
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
export const zGetLegacyPromptByIdData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.object({
|
||||
@@ -4070,14 +4493,23 @@ export const zGetLegacyUserdataV2Data = z.object({
|
||||
query: z.never().optional()
|
||||
})
|
||||
|
||||
export const zGetLegacyAssetContentData = z.object({
|
||||
export const zGetAssetContentData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.object({
|
||||
id: z.string()
|
||||
}),
|
||||
query: z.never().optional()
|
||||
query: z
|
||||
.object({
|
||||
disposition: z.enum(['inline', 'attachment']).optional()
|
||||
})
|
||||
.optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* Asset content stream (local runtime streams the bytes directly)
|
||||
*/
|
||||
export const zGetAssetContentResponse = z.string()
|
||||
|
||||
export const zGetLegacyViewMetadataData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.object({
|
||||
|
||||
@@ -4,5 +4,5 @@
|
||||
"rootDir": "src",
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src/**/*", "*.config.ts"]
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
|
||||
@@ -4,5 +4,5 @@
|
||||
"rootDir": "src",
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src/**/*", "vitest.config.ts"]
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
|
||||
@@ -35,10 +35,10 @@
|
||||
:class="
|
||||
sidebarLocation === 'left'
|
||||
? cn(
|
||||
'side-bar-panel pointer-events-auto bg-comfy-menu-bg',
|
||||
'side-bar-panel pointer-events-auto bg-comfy-menu-bg focus-visible:outline-hidden',
|
||||
sidebarPanelVisible && 'min-w-78'
|
||||
)
|
||||
: 'pointer-events-auto bg-comfy-menu-bg'
|
||||
: 'pointer-events-auto bg-comfy-menu-bg focus-visible:outline-hidden'
|
||||
"
|
||||
:min-size="
|
||||
sidebarLocation === 'left' ? SIDEBAR_MIN_SIZE : BUILDER_MIN_SIZE
|
||||
@@ -82,7 +82,7 @@
|
||||
</SplitterPanel>
|
||||
<SplitterPanel
|
||||
v-show="bottomPanelVisible && !focusMode"
|
||||
class="bottom-panel pointer-events-auto max-w-full overflow-x-auto rounded-lg border border-(--p-panel-border-color) bg-comfy-menu-bg"
|
||||
class="bottom-panel pointer-events-auto max-w-full overflow-x-auto rounded-lg border border-(--p-panel-border-color) bg-comfy-menu-bg focus-visible:outline-hidden"
|
||||
>
|
||||
<slot name="bottom-panel" />
|
||||
</SplitterPanel>
|
||||
@@ -95,10 +95,10 @@
|
||||
:class="
|
||||
sidebarLocation === 'right'
|
||||
? cn(
|
||||
'side-bar-panel pointer-events-auto bg-comfy-menu-bg',
|
||||
'side-bar-panel pointer-events-auto bg-comfy-menu-bg focus-visible:outline-hidden',
|
||||
sidebarPanelVisible && 'min-w-78'
|
||||
)
|
||||
: 'pointer-events-auto bg-comfy-menu-bg'
|
||||
: 'pointer-events-auto bg-comfy-menu-bg focus-visible:outline-hidden'
|
||||
"
|
||||
:min-size="
|
||||
sidebarLocation === 'right' ? SIDEBAR_MIN_SIZE : BUILDER_MIN_SIZE
|
||||
|
||||
@@ -7,7 +7,7 @@ import Password from 'primevue/password'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import ProgressSpinner from 'primevue/progressspinner'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent, h, nextTick, ref } from 'vue'
|
||||
import { computed, defineComponent, h, nextTick, ref } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import enMessages from '@/locales/en/main.json' with { type: 'json' }
|
||||
@@ -38,29 +38,45 @@ vi.mock('@/stores/authStore', () => ({
|
||||
}))
|
||||
|
||||
const mockTurnstileEnabled = ref(false)
|
||||
const mockTurnstileEnforced = ref(false)
|
||||
const mockTurnstileToken = ref('')
|
||||
const mockTurnstileUnavailable = ref(false)
|
||||
const mockReset = vi.fn()
|
||||
let emitTurnstileToken: ((token: string) => void) | undefined
|
||||
let emitTurnstileUnavailable: ((unavailable: boolean) => void) | undefined
|
||||
|
||||
// The reset-on-toggle behavior lives in useTurnstileGate itself (see
|
||||
// useTurnstile.test.ts); this fake just wires token/unavailable through to
|
||||
// `waiting` the same way so SignUpForm's submit gating can be exercised.
|
||||
vi.mock('@/composables/auth/useTurnstile', () => ({
|
||||
useTurnstile: () => ({
|
||||
enabled: mockTurnstileEnabled,
|
||||
enforced: mockTurnstileEnforced
|
||||
enabled: mockTurnstileEnabled
|
||||
}),
|
||||
useTurnstileGate: () => ({
|
||||
token: mockTurnstileToken,
|
||||
unavailable: mockTurnstileUnavailable,
|
||||
waiting: computed(
|
||||
() =>
|
||||
mockTurnstileEnabled.value &&
|
||||
!mockTurnstileToken.value &&
|
||||
!mockTurnstileUnavailable.value
|
||||
)
|
||||
})
|
||||
}))
|
||||
|
||||
// Stub the real widget (which loads the external Turnstile script) with one that
|
||||
// exposes a spyable reset() and lets a test drive the v-model token the way a
|
||||
// solved challenge would.
|
||||
// exposes a spyable reset() and lets a test drive the v-model token/unavailable
|
||||
// the way a solved challenge (or a broken/slow widget) would.
|
||||
vi.mock('./TurnstileWidget.vue', async () => {
|
||||
const { defineComponent: defineMock } = await import('vue')
|
||||
return {
|
||||
default: defineMock({
|
||||
name: 'TurnstileWidget',
|
||||
emits: ['update:token'],
|
||||
emits: ['update:token', 'update:unavailable'],
|
||||
setup(_, { expose, emit }) {
|
||||
expose({ reset: mockReset })
|
||||
emitTurnstileToken = (token: string) => emit('update:token', token)
|
||||
emitTurnstileUnavailable = (unavailable: boolean) =>
|
||||
emit('update:unavailable', unavailable)
|
||||
return () => null
|
||||
}
|
||||
})
|
||||
@@ -92,9 +108,11 @@ describe('SignUpForm', () => {
|
||||
beforeEach(() => {
|
||||
mockLoadingRef.value = false
|
||||
mockTurnstileEnabled.value = false
|
||||
mockTurnstileEnforced.value = false
|
||||
mockTurnstileToken.value = ''
|
||||
mockTurnstileUnavailable.value = false
|
||||
mockReset.mockClear()
|
||||
emitTurnstileToken = undefined
|
||||
emitTurnstileUnavailable = undefined
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@@ -211,43 +229,22 @@ describe('SignUpForm', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('Turnstile token hygiene', () => {
|
||||
it('clears the stale token when Turnstile becomes disabled', async () => {
|
||||
mockTurnstileEnabled.value = true
|
||||
mockTurnstileEnforced.value = true
|
||||
const { user } = renderComponent()
|
||||
await fillValidSignup(user)
|
||||
|
||||
emitTurnstileToken!('stale-token')
|
||||
await nextTick()
|
||||
expect(
|
||||
screen.getByRole('button', { name: signUpButton })
|
||||
).not.toBeDisabled()
|
||||
|
||||
mockTurnstileEnabled.value = false
|
||||
await nextTick()
|
||||
|
||||
// re-enable: the stale token must have been cleared so submit is blocked again
|
||||
mockTurnstileEnabled.value = true
|
||||
await nextTick()
|
||||
|
||||
expect(screen.getByRole('button', { name: signUpButton })).toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
// Regression coverage for the shadow-mode race: previously submit was only
|
||||
// gated in 'enforce' mode, so most real signups in 'shadow' mode raced
|
||||
// ahead of the async Cloudflare challenge and reached the backend with an
|
||||
// empty token. Gating now depends only on whether the widget is enabled
|
||||
// (shadow or enforce both render it), so both modes behave identically here.
|
||||
describe('Turnstile submit gating', () => {
|
||||
it('disables the submit button in enforce mode until a token is present', async () => {
|
||||
it('disables the submit button until a token is present', async () => {
|
||||
mockTurnstileEnabled.value = true
|
||||
mockTurnstileEnforced.value = true
|
||||
renderComponent()
|
||||
await nextTick()
|
||||
|
||||
expect(screen.getByRole('button', { name: signUpButton })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('does not emit submit in enforce mode while the token is empty', async () => {
|
||||
it('does not emit submit while the token is empty', async () => {
|
||||
mockTurnstileEnabled.value = true
|
||||
mockTurnstileEnforced.value = true
|
||||
const onSubmit = vi.fn()
|
||||
const { user } = renderComponent({ onSubmit })
|
||||
await fillValidSignup(user)
|
||||
@@ -257,9 +254,8 @@ describe('SignUpForm', () => {
|
||||
expect(onSubmit).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('emits submit with the token in enforce mode once the challenge is solved', async () => {
|
||||
it('emits submit with the token once the challenge is solved', async () => {
|
||||
mockTurnstileEnabled.value = true
|
||||
mockTurnstileEnforced.value = true
|
||||
const onSubmit = vi.fn()
|
||||
const { user } = renderComponent({ onSubmit })
|
||||
await fillValidSignup(user)
|
||||
@@ -271,13 +267,14 @@ describe('SignUpForm', () => {
|
||||
expect(onSubmit).toHaveBeenCalledWith(expectedValues, 'token-xyz')
|
||||
})
|
||||
|
||||
it('emits submit without a token in shadow mode (never blocks)', async () => {
|
||||
it('emits submit without a token once the widget reports itself unavailable (broken/slow load fallback)', async () => {
|
||||
mockTurnstileEnabled.value = true
|
||||
mockTurnstileEnforced.value = false
|
||||
const onSubmit = vi.fn()
|
||||
const { user } = renderComponent({ onSubmit })
|
||||
await fillValidSignup(user)
|
||||
|
||||
emitTurnstileUnavailable!(true)
|
||||
await nextTick()
|
||||
await user.click(screen.getByRole('button', { name: signUpButton }))
|
||||
|
||||
expect(onSubmit).toHaveBeenCalledWith(expectedValues, undefined)
|
||||
|
||||
@@ -33,10 +33,11 @@
|
||||
v-if="turnstileEnabled"
|
||||
ref="turnstileWidget"
|
||||
v-model:token="turnstileToken"
|
||||
v-model:unavailable="turnstileUnavailable"
|
||||
/>
|
||||
|
||||
<small
|
||||
v-show="submitBlockedByTurnstile"
|
||||
v-show="waitingForTurnstile"
|
||||
id="comfy-org-sign-up-turnstile-hint"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
@@ -51,11 +52,9 @@
|
||||
v-else
|
||||
type="submit"
|
||||
class="mt-4 h-10 font-medium"
|
||||
:disabled="!$form.valid || submitBlockedByTurnstile"
|
||||
:disabled="!$form.valid || waitingForTurnstile"
|
||||
:aria-describedby="
|
||||
submitBlockedByTurnstile
|
||||
? 'comfy-org-sign-up-turnstile-hint'
|
||||
: undefined
|
||||
waitingForTurnstile ? 'comfy-org-sign-up-turnstile-hint' : undefined
|
||||
"
|
||||
>
|
||||
{{ t('auth.signup.signUpButton') }}
|
||||
@@ -70,11 +69,11 @@ import { zodResolver } from '@primevue/forms/resolvers/zod'
|
||||
import { useThrottleFn } from '@vueuse/core'
|
||||
import InputText from 'primevue/inputtext'
|
||||
import ProgressSpinner from 'primevue/progressspinner'
|
||||
import { computed, ref, useTemplateRef, watch } from 'vue'
|
||||
import { computed, useTemplateRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useTurnstile } from '@/composables/auth/useTurnstile'
|
||||
import { useTurnstile, useTurnstileGate } from '@/composables/auth/useTurnstile'
|
||||
import { signUpSchema } from '@/schemas/signInSchema'
|
||||
import type { SignUpData } from '@/schemas/signInSchema'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
@@ -86,25 +85,21 @@ const { t } = useI18n()
|
||||
const authStore = useAuthStore()
|
||||
const loading = computed(() => authStore.loading)
|
||||
|
||||
const { enabled: turnstileEnabled, enforced: turnstileEnforced } =
|
||||
useTurnstile()
|
||||
const turnstileToken = ref('')
|
||||
const { enabled: turnstileEnabled } = useTurnstile()
|
||||
const {
|
||||
token: turnstileToken,
|
||||
unavailable: turnstileUnavailable,
|
||||
waiting: waitingForTurnstile
|
||||
} = useTurnstileGate(turnstileEnabled)
|
||||
const turnstileWidget =
|
||||
useTemplateRef<InstanceType<typeof TurnstileWidget>>('turnstileWidget')
|
||||
const submitBlockedByTurnstile = computed(
|
||||
() => turnstileEnforced.value && !turnstileToken.value
|
||||
)
|
||||
|
||||
watch(turnstileEnabled, (on) => {
|
||||
if (!on) turnstileToken.value = ''
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
submit: [values: SignUpData, turnstileToken?: string]
|
||||
}>()
|
||||
|
||||
const onSubmit = useThrottleFn((event: FormSubmitEvent) => {
|
||||
if (event.valid && !submitBlockedByTurnstile.value) {
|
||||
if (event.valid && !waitingForTurnstile.value) {
|
||||
emit(
|
||||
'submit',
|
||||
event.values as SignUpData,
|
||||
|
||||
@@ -261,4 +261,138 @@ describe('TurnstileWidget', () => {
|
||||
|
||||
expect(api.remove).toHaveBeenCalledWith('widget-id')
|
||||
})
|
||||
|
||||
// A widget that never resolves (broken script, ad-blocker, CDN outage, or a
|
||||
// hung challenge) must eventually tell the parent it cannot be relied on,
|
||||
// so submission can fall back instead of blocking a legitimate signup
|
||||
// forever.
|
||||
describe('unavailable fallback', () => {
|
||||
it('reports unavailable when the Turnstile script fails to load', async () => {
|
||||
mockLoadTurnstile.mockRejectedValue(new Error('script failed'))
|
||||
|
||||
const { emitted } = renderWidget()
|
||||
await flush()
|
||||
|
||||
expect(emitted()['update:unavailable']?.at(-1)).toEqual([true])
|
||||
})
|
||||
|
||||
it('reports unavailable on a challenge error', async () => {
|
||||
const { api, options } = fakeTurnstile()
|
||||
mockLoadTurnstile.mockResolvedValue(api)
|
||||
|
||||
const { emitted } = renderWidget()
|
||||
await flush()
|
||||
|
||||
options()!['error-callback']!()
|
||||
await flush()
|
||||
|
||||
expect(emitted()['update:unavailable']?.at(-1)).toEqual([true])
|
||||
})
|
||||
|
||||
it('clears the unavailable fallback once a token is solved', async () => {
|
||||
const { api, options } = fakeTurnstile()
|
||||
mockLoadTurnstile.mockResolvedValue(api)
|
||||
|
||||
const { emitted } = renderWidget()
|
||||
await flush()
|
||||
|
||||
options()!['error-callback']!()
|
||||
await flush()
|
||||
expect(emitted()['update:unavailable']?.at(-1)).toEqual([true])
|
||||
|
||||
options()!.callback!('token-abc')
|
||||
await flush()
|
||||
|
||||
expect(emitted()['update:unavailable']?.at(-1)).toEqual([false])
|
||||
})
|
||||
|
||||
it('falls back once the widget fails to resolve within the load timeout', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const { api, options } = fakeTurnstile()
|
||||
mockLoadTurnstile.mockResolvedValue(api)
|
||||
|
||||
const { emitted } = renderWidget()
|
||||
// Let the onMounted hook's `await loadTurnstile()` microtask settle
|
||||
// and render() run, without yet advancing to the timeout itself.
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(options()).toBeDefined()
|
||||
expect(emitted()['update:unavailable']).toBeUndefined()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(9_000)
|
||||
|
||||
expect(emitted()['update:unavailable']?.at(-1)).toEqual([true])
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not fall back once a token arrives before the load timeout', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const { api, options } = fakeTurnstile()
|
||||
mockLoadTurnstile.mockResolvedValue(api)
|
||||
|
||||
const { emitted } = renderWidget()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
options()!.callback!('token-abc')
|
||||
await vi.advanceTimersByTimeAsync(9_000)
|
||||
|
||||
expect(emitted()['update:unavailable']).toBeUndefined()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('resets the widget to fetch a fresh challenge on token expiry', async () => {
|
||||
const { api, options } = fakeTurnstile()
|
||||
mockLoadTurnstile.mockResolvedValue(api)
|
||||
window.turnstile = api as unknown as NonNullable<Window['turnstile']>
|
||||
|
||||
renderWidget()
|
||||
await flush()
|
||||
|
||||
options()!.callback!('token-abc')
|
||||
options()!['expired-callback']!()
|
||||
await flush()
|
||||
|
||||
expect(api.reset).toHaveBeenCalledWith('widget-id')
|
||||
})
|
||||
|
||||
it('falls back if a post-solve expiry is not followed by a fresh token within the load timeout', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const { api, options } = fakeTurnstile()
|
||||
mockLoadTurnstile.mockResolvedValue(api)
|
||||
window.turnstile = api as unknown as NonNullable<Window['turnstile']>
|
||||
|
||||
const { emitted } = renderWidget()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
// Establish a solved, available widget: an initial error marks it
|
||||
// unavailable, then solving a challenge clears that (the same
|
||||
// transition the existing "clears the unavailable fallback" test
|
||||
// verifies), so the expiry below is the only thing driving fallback.
|
||||
options()!['error-callback']!()
|
||||
options()!.callback!('token-abc')
|
||||
expect(emitted()['update:unavailable']?.at(-1)).toEqual([false])
|
||||
|
||||
// The token later expires (e.g. tab backgrounded past its ~300s
|
||||
// lifetime) without the widget itself erroring.
|
||||
options()!['expired-callback']!()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
// A fresh challenge was requested, but nothing solves it before the
|
||||
// re-armed load timeout elapses, so submission must eventually be
|
||||
// unblocked rather than staying stuck forever.
|
||||
expect(emitted()['update:unavailable']?.at(-1)).toEqual([false])
|
||||
await vi.advanceTimersByTimeAsync(9_000)
|
||||
|
||||
expect(emitted()['update:unavailable']?.at(-1)).toEqual([true])
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useTimeoutFn } from '@vueuse/core'
|
||||
import { onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
@@ -20,6 +21,14 @@ import { getTurnstileSiteKey } from '@/config/turnstile'
|
||||
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
|
||||
|
||||
const token = defineModel<string>('token', { default: '' })
|
||||
/**
|
||||
* Set true whenever the widget cannot be relied on to ever produce a token:
|
||||
* the Cloudflare script failed to load, the rendered challenge errored out,
|
||||
* or it simply hasn't resolved within `TURNSTILE_LOAD_TIMEOUT_MS`. The parent
|
||||
* uses this to stop waiting on a token so a broken/slow widget (network
|
||||
* issue, ad-blocker, CDN outage) can never permanently block signup.
|
||||
*/
|
||||
const unavailable = defineModel<boolean>('unavailable', { default: false })
|
||||
|
||||
const { t } = useI18n()
|
||||
const colorPaletteStore = useColorPaletteStore()
|
||||
@@ -28,6 +37,16 @@ const containerRef = ref<HTMLDivElement>()
|
||||
const errorMessage = ref('')
|
||||
let widgetId: string | undefined
|
||||
|
||||
/** How long to wait for the widget to resolve before falling back. */
|
||||
const TURNSTILE_LOAD_TIMEOUT_MS = 9_000
|
||||
const { start: armTimeout, stop: clearLoadTimeout } = useTimeoutFn(
|
||||
() => {
|
||||
unavailable.value = true
|
||||
},
|
||||
TURNSTILE_LOAD_TIMEOUT_MS,
|
||||
{ immediate: false }
|
||||
)
|
||||
|
||||
const clearToken = () => {
|
||||
token.value = ''
|
||||
}
|
||||
@@ -46,12 +65,18 @@ const reset = () => {
|
||||
errorMessage.value = ''
|
||||
if (widgetId && window.turnstile) {
|
||||
window.turnstile.reset(widgetId)
|
||||
// A widget that renders can request a fresh challenge, so give it
|
||||
// another chance before falling back again.
|
||||
unavailable.value = false
|
||||
armTimeout()
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ reset })
|
||||
|
||||
onMounted(async () => {
|
||||
armTimeout()
|
||||
|
||||
try {
|
||||
const turnstile = await loadTurnstile()
|
||||
if (!containerRef.value) return
|
||||
@@ -64,23 +89,37 @@ onMounted(async () => {
|
||||
sitekey: getTurnstileSiteKey(),
|
||||
theme,
|
||||
callback: (newToken: string) => {
|
||||
clearLoadTimeout()
|
||||
errorMessage.value = ''
|
||||
unavailable.value = false
|
||||
token.value = newToken
|
||||
},
|
||||
'expired-callback': () => {
|
||||
clearToken()
|
||||
errorMessage.value = t('auth.turnstile.expired')
|
||||
if (widgetId && window.turnstile) {
|
||||
window.turnstile.reset(widgetId)
|
||||
// A solved token can expire on its own (e.g. the tab was
|
||||
// backgrounded past the token's ~300s lifetime) without the widget
|
||||
// ever erroring, so proactively request a fresh challenge and
|
||||
// re-arm the load timeout in case it doesn't resolve in time.
|
||||
armTimeout()
|
||||
}
|
||||
},
|
||||
'error-callback': () => {
|
||||
clearToken()
|
||||
clearLoadTimeout()
|
||||
console.warn('Turnstile challenge failed')
|
||||
errorMessage.value = t('auth.turnstile.failed')
|
||||
unavailable.value = true
|
||||
if (widgetId && window.turnstile) window.turnstile.reset(widgetId)
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
clearLoadTimeout()
|
||||
console.warn('Turnstile failed to load', error)
|
||||
errorMessage.value = t('auth.turnstile.failed')
|
||||
unavailable.value = true
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick, ref } from 'vue'
|
||||
|
||||
import {
|
||||
isTurnstileEnabled,
|
||||
normalizeTurnstileMode,
|
||||
useTurnstile
|
||||
useTurnstile,
|
||||
useTurnstileGate
|
||||
} from '@/composables/auth/useTurnstile'
|
||||
import { getTurnstileSiteKey } from '@/config/turnstile'
|
||||
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
|
||||
@@ -137,3 +139,63 @@ describe('useTurnstile', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('useTurnstileGate', () => {
|
||||
it('waits while enabled with no token yet', () => {
|
||||
const { waiting } = useTurnstileGate(ref(true))
|
||||
expect(waiting.value).toBe(true)
|
||||
})
|
||||
|
||||
it('never waits while disabled', () => {
|
||||
const { waiting } = useTurnstileGate(ref(false))
|
||||
expect(waiting.value).toBe(false)
|
||||
})
|
||||
|
||||
it('stops waiting once a token arrives', () => {
|
||||
const { token, waiting } = useTurnstileGate(ref(true))
|
||||
|
||||
token.value = 'token-abc'
|
||||
|
||||
expect(waiting.value).toBe(false)
|
||||
})
|
||||
|
||||
it('stops waiting once the widget reports itself unavailable', () => {
|
||||
const { unavailable, waiting } = useTurnstileGate(ref(true))
|
||||
|
||||
unavailable.value = true
|
||||
|
||||
expect(waiting.value).toBe(false)
|
||||
})
|
||||
|
||||
it('clears stale token/unavailable state when enabled turns off', async () => {
|
||||
const enabled = ref(true)
|
||||
const { token, unavailable } = useTurnstileGate(enabled)
|
||||
token.value = 'stale-token'
|
||||
unavailable.value = true
|
||||
|
||||
enabled.value = false
|
||||
await nextTick()
|
||||
|
||||
expect(token.value).toBe('')
|
||||
expect(unavailable.value).toBe(false)
|
||||
})
|
||||
|
||||
// Regression coverage: the reset used to only run on the enabled->disabled
|
||||
// transition, so state written while the widget was briefly disabled could
|
||||
// survive into the next enabled widget instance.
|
||||
it('clears stale token/unavailable state when enabled turns back on', async () => {
|
||||
const enabled = ref(true)
|
||||
const { token, unavailable } = useTurnstileGate(enabled)
|
||||
|
||||
enabled.value = false
|
||||
await nextTick()
|
||||
token.value = 'stale-token'
|
||||
unavailable.value = true
|
||||
|
||||
enabled.value = true
|
||||
await nextTick()
|
||||
|
||||
expect(token.value).toBe('')
|
||||
expect(unavailable.value).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { computed } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
import { getTurnstileSiteKey } from '@/config/turnstile'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
@@ -42,3 +43,31 @@ export function useTurnstile() {
|
||||
|
||||
return { mode, siteKey, enabled, enforced }
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit-gating state for the signup form's Turnstile widget: a token/
|
||||
* unavailable pair, plus `waiting`, which is true while a real token is still
|
||||
* needed. Waits in both shadow and enforce mode (`enabled`), not just
|
||||
* `enforced`, so shadow mode's token can't race the async Cloudflare
|
||||
* challenge; falls back open once the widget reports `unavailable` so a
|
||||
* broken/slow load can never permanently block signup.
|
||||
*
|
||||
* `token`/`unavailable` reset on every `enabled` transition, in either
|
||||
* direction, so state from a previous widget instance can never leak into a
|
||||
* freshly (re-)rendered one.
|
||||
*/
|
||||
export function useTurnstileGate(enabled: Ref<boolean>) {
|
||||
const token = ref('')
|
||||
const unavailable = ref(false)
|
||||
|
||||
const waiting = computed(
|
||||
() => enabled.value && !token.value && !unavailable.value
|
||||
)
|
||||
|
||||
watch(enabled, () => {
|
||||
token.value = ''
|
||||
unavailable.value = false
|
||||
})
|
||||
|
||||
return { token, unavailable, waiting }
|
||||
}
|
||||
|
||||
@@ -77,6 +77,28 @@ function createProvider(
|
||||
return provider
|
||||
}
|
||||
|
||||
type BeforeSendEvent = Record<string, unknown> & {
|
||||
properties?: Record<string, unknown>
|
||||
}
|
||||
|
||||
function runBeforeSend(event: BeforeSendEvent): BeforeSendEvent {
|
||||
const { before_send } = hoisted.mockInit.mock.calls[0][1]
|
||||
const chain: Array<(e: BeforeSendEvent) => BeforeSendEvent> = Array.isArray(
|
||||
before_send
|
||||
)
|
||||
? before_send
|
||||
: [before_send]
|
||||
return chain.reduce((acc, fn) => fn(acc), event)
|
||||
}
|
||||
|
||||
function setLocation(search: string): void {
|
||||
Object.defineProperty(window.location, 'search', {
|
||||
configurable: true,
|
||||
value: search,
|
||||
writable: true
|
||||
})
|
||||
}
|
||||
|
||||
describe('PostHogTelemetryProvider', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@@ -195,15 +217,57 @@ describe('PostHogTelemetryProvider', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('desktop entry capture', () => {
|
||||
function setLocation(search: string): void {
|
||||
Object.defineProperty(window.location, 'search', {
|
||||
configurable: true,
|
||||
value: search,
|
||||
writable: true
|
||||
})
|
||||
}
|
||||
describe('platform axes (client / deployment)', () => {
|
||||
afterEach(() => {
|
||||
delete window.__comfyDesktop2
|
||||
})
|
||||
|
||||
it('stamps client=web and deployment=cloud on events in a plain browser', async () => {
|
||||
createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
const result = runBeforeSend({ event: 'test', properties: {} })
|
||||
|
||||
expect(result.properties).toMatchObject({
|
||||
client: 'web',
|
||||
deployment: 'cloud'
|
||||
})
|
||||
})
|
||||
|
||||
it('stamps client=desktop when the desktop preload bridge is present', async () => {
|
||||
window.__comfyDesktop2 = {
|
||||
isRemote: () => false,
|
||||
Telemetry: { capture: vi.fn() }
|
||||
}
|
||||
createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
const result = runBeforeSend({ event: 'test', properties: {} })
|
||||
|
||||
expect(result.properties).toMatchObject({
|
||||
client: 'desktop',
|
||||
deployment: 'cloud'
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps stamping platform axes after logout wipes super properties', async () => {
|
||||
createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
const logout = hoisted.mockOnUserLogout.mock.calls[0][0]
|
||||
logout()
|
||||
|
||||
const result = runBeforeSend({ event: 'test', properties: {} })
|
||||
|
||||
expect(hoisted.mockReset).toHaveBeenCalledWith(true)
|
||||
expect(result.properties).toMatchObject({
|
||||
client: 'web',
|
||||
deployment: 'cloud'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('desktop entry capture', () => {
|
||||
afterEach(() => {
|
||||
setLocation('')
|
||||
})
|
||||
@@ -684,6 +748,10 @@ describe('PostHogTelemetryProvider', () => {
|
||||
})
|
||||
|
||||
describe('logout', () => {
|
||||
afterEach(() => {
|
||||
setLocation('')
|
||||
})
|
||||
|
||||
it('registers onUserLogout watcher after init', async () => {
|
||||
createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
@@ -701,6 +769,34 @@ describe('PostHogTelemetryProvider', () => {
|
||||
expect(hoisted.mockReset).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('re-registers desktop entry props after reset wipes super properties', async () => {
|
||||
setLocation('?utm_source=comfy.desktop&desktop_device_id=device-abc')
|
||||
createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
hoisted.mockRegister.mockClear()
|
||||
const callback = hoisted.mockOnUserLogout.mock.calls[0][0]
|
||||
callback()
|
||||
|
||||
expect(hoisted.mockRegister).toHaveBeenCalledWith({
|
||||
source_app: 'desktop',
|
||||
desktop_device_id: 'device-abc'
|
||||
})
|
||||
expect(hoisted.mockReset.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
hoisted.mockRegister.mock.invocationCallOrder[0]
|
||||
)
|
||||
})
|
||||
|
||||
it('does not register anything on logout for non-desktop visitors', async () => {
|
||||
createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
const callback = hoisted.mockOnUserLogout.mock.calls[0][0]
|
||||
callback()
|
||||
|
||||
expect(hoisted.mockRegister).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not register the watcher before init resolves', () => {
|
||||
createProvider()
|
||||
|
||||
@@ -760,8 +856,6 @@ describe('PostHogTelemetryProvider', () => {
|
||||
createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
const { before_send } = hoisted.mockInit.mock.calls[0][1]
|
||||
|
||||
const event = {
|
||||
event: 'test',
|
||||
properties: {
|
||||
@@ -783,7 +877,7 @@ describe('PostHogTelemetryProvider', () => {
|
||||
}
|
||||
}
|
||||
|
||||
const result = before_send(event)
|
||||
const result = runBeforeSend(event)
|
||||
|
||||
// event.properties — all four PII keys stripped, non-PII preserved
|
||||
expect(result.properties).not.toHaveProperty('email')
|
||||
@@ -819,6 +913,7 @@ describe('PostHogTelemetryProvider', () => {
|
||||
const initConfig = hoisted.mockInit.mock.calls[0][1]
|
||||
|
||||
expect(initConfig.before_send).not.toBe(remoteBefore_send)
|
||||
expect(initConfig.before_send).not.toContain(remoteBefore_send)
|
||||
expect(initConfig.person_profiles).toBe('identified_only')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { PostHog } from 'posthog-js'
|
||||
import type { CaptureResult, PostHog } from 'posthog-js'
|
||||
import { watch } from 'vue'
|
||||
import type { WatchStopHandle } from 'vue'
|
||||
|
||||
@@ -78,6 +78,15 @@ interface DesktopEntryProps {
|
||||
desktop_device_id?: string
|
||||
}
|
||||
|
||||
// Stamped via before_send rather than posthog.register() so the axes
|
||||
// survive the posthog.reset(true) on logout, which wipes super properties.
|
||||
function stampPlatformAxes(event: CaptureResult | null): CaptureResult | null {
|
||||
if (!event) return null
|
||||
event.properties.client = window.__comfyDesktop2 ? 'desktop' : 'web'
|
||||
event.properties.deployment = 'cloud'
|
||||
return event
|
||||
}
|
||||
|
||||
function readDesktopEntryProps(): DesktopEntryProps | null {
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
if (params.get('utm_source') !== 'comfy.desktop') return null
|
||||
@@ -140,10 +149,11 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
|
||||
// automatically when persistence includes 'cookie' (the default).
|
||||
// Explicit override interacts badly with posthog-js#3578 where reset() fails
|
||||
// to clear localStorage on other subdomains, causing identity bleed on logout.
|
||||
before_send: createPostHogBeforeSend()
|
||||
before_send: [stampPlatformAxes, createPostHogBeforeSend()]
|
||||
})
|
||||
this.isInitialized = true
|
||||
this.flushEventQueue()
|
||||
this.desktopEntryProps = readDesktopEntryProps()
|
||||
this.registerDesktopEntryProps()
|
||||
|
||||
const currentUser = useCurrentUser()
|
||||
@@ -165,6 +175,9 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
|
||||
// pre-init logout handling would defeat the simplification.
|
||||
currentUser.onUserLogout(() => {
|
||||
this.posthog?.reset(true)
|
||||
// reset(true) wipes super properties; restore desktop entry
|
||||
// attribution for the rest of the SPA session.
|
||||
this.registerDesktopEntryProps()
|
||||
})
|
||||
})
|
||||
.catch((error) => {
|
||||
@@ -286,12 +299,9 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
|
||||
}
|
||||
|
||||
private registerDesktopEntryProps(): void {
|
||||
if (!this.posthog) return
|
||||
const props = readDesktopEntryProps()
|
||||
if (!props) return
|
||||
this.desktopEntryProps = props
|
||||
if (!this.posthog || !this.desktopEntryProps) return
|
||||
try {
|
||||
this.posthog.register(props)
|
||||
this.posthog.register(this.desktopEntryProps)
|
||||
} catch (error) {
|
||||
console.error('Failed to register desktop entry props:', error)
|
||||
}
|
||||
|
||||