Compare commits

..

21 Commits

Author SHA1 Message Date
bymyself
d537370533 test(fixtures): drop unused subscription fixtures and helpers
knip flagged 9 unused exports introduced during test refactors. Remove the
dead fixture constants and unexport helpers only used within their module.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 19:27:13 -07:00
GitHub Action
ee0efca2a5 [automated] Apply ESLint and Oxfmt fixes 2026-07-13 19:17:29 -07:00
bymyself
233a296203 fix(test): use dispatchEvent in openUserPopover to bypass backdrop blocking 2026-07-13 19:17:29 -07:00
bymyself
5f390be100 fix(test): wait for currentUserButton after reload before yielding to test body 2026-07-13 19:17:29 -07:00
bymyself
5911a3cc88 fix(test): replace waitFor+catch with targeted expect to address flakiness concern
Per reviewer feedback, the previous waitFor+try/catch pattern swallows all
errors rather than just timeout errors, hiding genuine test failures.

Replace with expect().toBeVisible().then().catch() that only discards
TimeoutError, re-throwing any other unexpected exception. This preserves the
"dismiss if present" semantics needed for the asynchronous subscription dialog
while making error handling explicit.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-13 19:17:29 -07:00
bymyself
afac440b25 fix(test): use specific locator to avoid strict-mode violation in cloud tests
getByRole('dialog') matched 2 elements when the GlobalDialog stack had both
a subscription-required dialog and a second dialog open simultaneously.
Use page.locator('[aria-labelledby="subscription-required"]') to precisely
target the subscription dialog by its GlobalDialog key in all three usages
(dismissSubscriptionDialogIfOpen, SubscribeToRun click test, user popover
subscribe click test, and Cleanup prevents stale state test).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-13 19:17:29 -07:00
bymyself
e99a27c4c3 fix(test): use dispatchEvent and disable subscription extension
oxlint forbids click({ force: true }). Use dispatchEvent('click')
instead to bypass the PrimeVue Dialog backdrop that briefly intercepts
pointer events between the popover button click and the dialog opening.
The button is already known-visible from openUserPopover, so a
synthetic click is safe here.

Also disable the Comfy.Cloud.Subscription extension via setupSettings
so its requireActiveSubscription watcher does not auto-open the
subscription dialog on app boot. The dialog mask would otherwise
intercept pointer events on every topbar button these tests interact
with. Dialog dismissal is kept as defense-in-depth in case a different
code path surfaces it.
2026-07-13 19:17:29 -07:00
bymyself
f7215fcae7 fix(test): dismiss auto-opened subscription modal in unsubscribed tests
With `subscription_required` enabled and an unsubscribed status, the
cloud subscription extension auto-opens `SubscriptionRequiredDialogContent`
on app boot. Its PrimeVue overlay mask intercepts pointer events on the
topbar, blocking clicks on the buttons under test.

Add `dismissSubscriptionDialogIfOpen` and call it after the post-mock
`reload()` so subsequent assertions can interact with the topbar
`subscribe-to-run-button` and user popover. Tests that exercise the
button -> dialog flow still verify it explicitly by clicking the button
and asserting the dialog re-appears.
2026-07-13 19:17:29 -07:00
bymyself
34ac5f1c36 fix(test): mock /api/features so subscription_required survives boot
In the cloud build, `main.ts` awaits `refreshRemoteConfig({ useAuth: false })`
which fetches `/api/features` and assigns the result *wholesale* to
`window.__CONFIG__` (it does not merge). That means the previous
`addInitScript` setting `subscription_required: true` was clobbered
by the boot fetch, leaving the flag undefined when
`ComfyRunButton/index.ts` evaluated and `SubscribeToRun` was
tree-shaken away.

Add a route handler for `**/api/features` returning
`{ subscription_required: true }` so the flag survives the
boot-time refresh. The init-script remains as a defense-in-depth
fallback for any code path that reads `__CONFIG__` before the
fetch resolves.
2026-07-13 19:17:29 -07:00
bymyself
7abe01de06 fix(test): repair cloud playwright fixture for unsubscribed scenario
Two CI failures on the cloud project for the unsubscribed describe block
had a single root cause and a related selector bug:

1. `CloudRunButtonWrapper` (which renders `SubscribeToRun`) is gated
   on `isCloud && window.__CONFIG__?.subscription_required` at module
   evaluation time. The previous auto-fixture installed
   `addInitScript` for that flag, but Playwright does not guarantee
   ordering between auto fixtures and `comfyPage` when neither
   declares a dependency. In practice the script was applied after the
   first navigation, so `subscription_required` was undefined when
   `ComfyRunButton/index.ts` evaluated and the queue button was
   imported instead — causing all `subscribe-to-run-button`
   assertions to time out.

   Make `subscriptionHelper` an auto fixture that depends on
   `comfyPage`, install mocks, then `page.reload()` so route mocks
   and the init script are both in place when modules re-evaluate.

2. With `subscription_required` enabled and an unsubscribed status,
   the `Comfy.Cloud.Subscription` extension auto-opens the
   subscription-required modal, blocking pointer events on the topbar
   buttons under test. Disable the extension via
   `Comfy.Extension.Disabled` for these tests; we still test the
   button -> dialog flow by clicking explicitly.

3. Use the new `current-user-button` testid for the topbar trigger
   instead of `current-user-indicator`, which actually belongs to
   `CurrentUserMessage.vue` in the settings dialog.
2026-07-13 19:17:29 -07:00
bymyself
440d001a8a fix: add data-testid to topbar CurrentUserButton
The subscription E2E tests targeted 'current-user-indicator', which is
the testid on CurrentUserMessage.vue (settings-dialog content), not the
topbar user button. Add a dedicated 'current-user-button' testid for
the topbar trigger so popover-based tests can locate it reliably.
2026-07-13 19:17:29 -07:00
Glary-Bot
0b8c5593be fix: wait for popover visibility in openUserPopover helper 2026-07-13 19:17:29 -07:00
Glary-Bot
ce3c078ab3 refactor: remove redundant inline comments per review feedback 2026-07-13 19:17:29 -07:00
Glary-Bot
7c78d0e93a refactor: extract popover interaction helpers to reduce duplication 2026-07-13 19:17:29 -07:00
Glary-Bot
201b667b9e refactor: replace CSS class locator with data-testid for popover
Add data-testid='current-user-popover' to both popover components
and use TestIds.user.currentUserPopover in tests instead of
locator('.current-user-popover').
2026-07-13 19:17:29 -07:00
Glary-Bot
206539734a fix: remove all manual expect timeouts per review feedback
Default expect timeout (5s) is sufficient. The cloud project's 15s
test timeout handles overall duration.
2026-07-13 19:17:10 -07:00
Glary-Bot
9fa69ac099 fix: remove @mobile tag from responsive test to avoid non-cloud project
The @mobile tag causes the test to also run in the mobile-chrome
Playwright project which lacks cloud auth setup. Without cloud mode,
isActiveSubscription defaults to true and SubscribeToRun never renders.
Use setViewportSize instead to test the responsive label.
2026-07-13 19:17:10 -07:00
Glary-Bot
28d0c3cfe8 refactor: address CodeRabbit review feedback
- Add subscribeToRunButton to TestIds and use throughout spec
- Import PENDING_SUBSCRIPTION_CHECKOUT_STORAGE_KEY from source
2026-07-13 19:17:10 -07:00
Glary-Bot
ec86f067a6 fix: address code review — mock lifecycle and pending checkout seeding
- Use auto-fixture factory (createSubscriptionTest) to install
  subscription mocks BEFORE comfyPage's first navigation
- Add seedPendingCheckout() and triggerSubscriptionRefetch() to
  SubscriptionHelper for state transition tests
- Restructure tests by initial subscription state to avoid
  double-setup anti-pattern
2026-07-13 19:17:09 -07:00
Glary-Bot
e3d74f1266 fix: address review feedback for subscription E2E tests
- Scope popover subscribe button selectors to .current-user-popover element
- Target subscription dialog via aria-labelledby instead of generic role=dialog
- Widen checkout route mock to cover tiered checkout paths

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-07-13 19:17:09 -07:00
Glary-Bot
4dccfb1556 test: add subscription E2E Playwright test fixtures and specs
Add typed fixture factories, stateful route-mocking helper, and 10 @cloud test cases for subscription UI components (SubscribeToRun, TopbarSubscribeButton, user popover subscribe flow).

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-07-13 19:17:09 -07:00
77 changed files with 937 additions and 3804 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 195 KiB

View File

@@ -93,16 +93,6 @@ const config: StorybookConfig = {
replacement:
process.cwd() + '/src/storybook/mocks/teamWorkspaceStore.ts'
},
{
find: '@/composables/auth/useCurrentUser',
replacement:
process.cwd() + '/src/storybook/mocks/useCurrentUser.ts'
},
{
find: '@/platform/workspace/composables/useWorkspaceUI',
replacement:
process.cwd() + '/src/storybook/mocks/useWorkspaceUI.ts'
},
{
find: '@/utils/formatUtil',
replacement:

View File

@@ -0,0 +1,49 @@
import type { operations } from '@comfyorg/registry-types'
export type SubscriptionStatusResponse =
operations['GetCloudSubscriptionStatus']['responses']['200']['content']['application/json']
export type BalanceResponse =
operations['GetCustomerBalance']['responses']['200']['content']['application/json']
export function createSubscriptionStatus(
overrides: Partial<SubscriptionStatusResponse> = {}
): SubscriptionStatusResponse {
return {
is_active: false,
subscription_id: null,
subscription_tier: 'FREE',
subscription_duration: null,
has_fund: false,
renewal_date: null,
end_date: null,
...overrides
}
}
export function createBalance(
overrides: Partial<BalanceResponse> = {}
): BalanceResponse {
return {
amount_micros: 0,
prepaid_balance_micros: 0,
cloud_credit_balance_micros: 0,
pending_charges_micros: 0,
effective_balance_micros: 0,
currency: 'USD',
...overrides
}
}
export const UNSUBSCRIBED: SubscriptionStatusResponse =
createSubscriptionStatus({
is_active: false,
subscription_id: null,
subscription_tier: 'FREE',
end_date: null
})
export const ZERO_BALANCE: BalanceResponse = createBalance({
amount_micros: 0,
effective_balance_micros: 0
})

View File

@@ -0,0 +1,222 @@
import type { Page, Route } from '@playwright/test'
import { PENDING_SUBSCRIPTION_CHECKOUT_STORAGE_KEY } from '@/platform/cloud/subscription/utils/subscriptionCheckoutTracker'
import {
createBalance,
createSubscriptionStatus,
UNSUBSCRIBED,
ZERO_BALANCE
} from '@e2e/fixtures/data/subscriptionFixtures'
import type {
BalanceResponse,
SubscriptionStatusResponse
} from '@e2e/fixtures/data/subscriptionFixtures'
export interface SubscriptionConfig {
status: SubscriptionStatusResponse
balance: BalanceResponse
}
function emptyConfig(): SubscriptionConfig {
return {
status: createSubscriptionStatus(UNSUBSCRIBED),
balance: createBalance(ZERO_BALANCE)
}
}
export type SubscriptionOperator = (
config: SubscriptionConfig
) => SubscriptionConfig
function withSubscriptionStatus(
overrides: Partial<SubscriptionStatusResponse>
): SubscriptionOperator {
return (config) => ({
...config,
status: { ...config.status, ...overrides }
})
}
export function withActiveSubscription(
tier: NonNullable<SubscriptionStatusResponse['subscription_tier']> = 'CREATOR'
): SubscriptionOperator {
return withSubscriptionStatus({
is_active: true,
subscription_tier: tier,
renewal_date: '2099-12-31T00:00:00.000Z',
end_date: null
})
}
export function withFreeTier(): SubscriptionOperator {
return withSubscriptionStatus({
is_active: true,
subscription_tier: 'FREE',
end_date: null
})
}
export function withUnsubscribed(): SubscriptionOperator {
return withSubscriptionStatus({
is_active: false,
subscription_tier: 'FREE',
end_date: null,
renewal_date: null
})
}
export class SubscriptionHelper {
private statusResponse: SubscriptionStatusResponse
private balanceResponse: BalanceResponse
private routeHandlers: Array<{
pattern: string
handler: (route: Route) => Promise<void>
}> = []
constructor(
private readonly page: Page,
config: SubscriptionConfig = emptyConfig()
) {
this.statusResponse = createSubscriptionStatus(config.status)
this.balanceResponse = createBalance(config.balance)
}
async mock(): Promise<void> {
await this.page.addInitScript(() => {
window.__CONFIG__ = {
...window.__CONFIG__,
subscription_required: true
}
})
// The cloud build calls `/api/features` at boot via `refreshRemoteConfig`,
// which overwrites `window.__CONFIG__` wholesale. Mock it to preserve
// `subscription_required: true` after that fetch resolves.
const featuresPattern = '**/api/features'
const featuresHandler = async (route: Route) => {
await route.fulfill({ json: { subscription_required: true } })
}
this.routeHandlers.push({
pattern: featuresPattern,
handler: featuresHandler
})
await this.page.route(featuresPattern, featuresHandler)
const statusPattern = '**/customers/cloud-subscription-status'
const statusHandler = async (route: Route) => {
await route.fulfill({ json: this.statusResponse })
}
this.routeHandlers.push({ pattern: statusPattern, handler: statusHandler })
await this.page.route(statusPattern, statusHandler)
const balancePattern = '**/customers/balance'
const balanceHandler = async (route: Route) => {
await route.fulfill({ json: this.balanceResponse })
}
this.routeHandlers.push({
pattern: balancePattern,
handler: balanceHandler
})
await this.page.route(balancePattern, balanceHandler)
const checkoutPattern = '**/customers/cloud-subscription-checkout**'
const checkoutHandler = async (route: Route) => {
await route.fulfill({
json: { checkout_url: 'https://checkout.stripe.com/mock' }
})
}
this.routeHandlers.push({
pattern: checkoutPattern,
handler: checkoutHandler
})
await this.page.route(checkoutPattern, checkoutHandler)
}
configure(...operators: SubscriptionOperator[]): void {
const base: SubscriptionConfig = {
status: createSubscriptionStatus(this.statusResponse),
balance: createBalance(this.balanceResponse)
}
const config = operators.reduce<SubscriptionConfig>(
(cfg, op) => op(cfg),
base
)
this.statusResponse = createSubscriptionStatus(config.status)
this.balanceResponse = createBalance(config.balance)
}
setStatus(overrides: Partial<SubscriptionStatusResponse>): void {
this.statusResponse = {
...this.statusResponse,
...overrides
}
}
setBalance(overrides: Partial<BalanceResponse>): void {
this.balanceResponse = {
...this.balanceResponse,
...overrides
}
}
/**
* Seed localStorage with a pending checkout attempt.
* Required for `visibilitychange` to trigger a subscription re-fetch,
* because `recoverPendingSubscriptionCheckout` checks
* `hasPendingSubscriptionCheckoutAttempt()` before fetching.
*
* Call AFTER page navigation (localStorage needs a page context).
*/
async seedPendingCheckout(
tier: string = 'standard',
cycle: string = 'monthly'
): Promise<void> {
const storageKey = PENDING_SUBSCRIPTION_CHECKOUT_STORAGE_KEY
await this.page.evaluate(
([key, t, c]) => {
localStorage.setItem(
key,
JSON.stringify({
attempt_id: `test-${Date.now()}`,
started_at_ms: Date.now(),
tier: t,
cycle: c,
checkout_type: 'new'
})
)
},
[storageKey, tier, cycle] as const
)
}
/**
* Dispatch `visibilitychange` to trigger pending-checkout recovery.
* The app listens for this event and re-fetches subscription status
* when a pending checkout attempt exists in localStorage.
*/
async triggerSubscriptionRefetch(): Promise<void> {
await this.page.evaluate(() => {
document.dispatchEvent(new Event('visibilitychange'))
})
}
async clearMocks(): Promise<void> {
for (const { pattern, handler } of this.routeHandlers) {
await this.page.unroute(pattern, handler)
}
this.routeHandlers = []
this.statusResponse = createSubscriptionStatus(UNSUBSCRIBED)
this.balanceResponse = createBalance(ZERO_BALANCE)
}
}
export function createSubscriptionHelper(
page: Page,
...operators: SubscriptionOperator[]
): SubscriptionHelper {
const config = operators.reduce<SubscriptionConfig>(
(cfg, op) => op(cfg),
emptyConfig()
)
return new SubscriptionHelper(page, config)
}

View File

@@ -98,6 +98,7 @@ export const TestIds = {
queueModeMenuTrigger: 'queue-mode-menu-trigger',
saveButton: 'save-workflow-button',
subscribeButton: 'topbar-subscribe-button',
subscribeToRunButton: 'subscribe-to-run-button',
loginButton: 'login-button',
loginButtonPopover: 'login-button-popover',
loginButtonPopoverLearnMore: 'login-button-popover-learn-more',
@@ -247,7 +248,9 @@ export const TestIds = {
workflowCard: (id: string) => `template-workflow-${id}`
},
user: {
currentUserIndicator: 'current-user-indicator'
currentUserButton: 'current-user-button',
currentUserIndicator: 'current-user-indicator',
currentUserPopover: 'current-user-popover'
},
queue: {
jobHistorySidebar: 'job-history-sidebar',

View File

@@ -0,0 +1,262 @@
import { expect } from '@playwright/test'
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
import {
createSubscriptionHelper,
withActiveSubscription,
withFreeTier,
withUnsubscribed
} from '@e2e/fixtures/helpers/SubscriptionHelper'
import type { Locator, Page } from '@playwright/test'
import type { SubscriptionHelper } from '@e2e/fixtures/helpers/SubscriptionHelper'
async function openUserPopover(page: Page): Promise<Locator> {
// Use dispatchEvent instead of click() to bypass Playwright's actionability
// check — in the cloud environment a subscription dialog backdrop can be
// present during initial page load and would block a standard click.
await page.getByTestId(TestIds.user.currentUserButton).dispatchEvent('click')
const popover = page.getByTestId(TestIds.user.currentUserPopover)
await expect(popover).toBeVisible()
return popover
}
async function clickPopoverSubscribe(page: Page): Promise<void> {
const popover = await openUserPopover(page)
// Use dispatchEvent instead of click() because the click opens the
// subscription dialog whose backdrop appears mid-action; Playwright's
// actionability re-check would otherwise see the mask intercepting and
// retry until timeout. The button is already known-visible from
// openUserPopover, so dispatching a synthetic click is safe here.
await popover
.getByRole('button', { name: /subscribe/i })
.first()
.dispatchEvent('click')
}
// Closes the auto-opened subscription-required dialog if present.
// Polls briefly because the dialog opens asynchronously after the
// `isLoggedIn` watcher fires on app boot.
async function dismissSubscriptionDialogIfOpen(page: Page): Promise<void> {
// Target only the subscription-required dialog by its known aria-labelledby
// key — avoids strict-mode violations when multiple GlobalDialog items are
// on the stack simultaneously.
const dialog = page.locator('[aria-labelledby="subscription-required"]')
// Use expect with a short timeout: this is intentionally a "dismiss if open"
// helper, so absence of the dialog (TimeoutError) is not a failure — we
// discard only the timeout error, not any other unexpected exception.
const appeared = await expect(dialog)
.toBeVisible({ timeout: 2000 })
.then(() => true)
.catch((e: Error) => {
if (e.message.includes('Timeout')) return false
throw e
})
if (!appeared) return
const closeButton = dialog.getByRole('button', { name: /close/i }).first()
if (await closeButton.isVisible()) {
await closeButton.click()
} else {
await page.keyboard.press('Escape')
}
await expect(dialog).toBeHidden()
}
// Installs subscription mocks AFTER comfyPage.setup() and reloads the page
// so `addInitScript` (which sets `window.__CONFIG__.subscription_required`)
// applies before module-level reads in `ComfyRunButton/index.ts` evaluate.
// Depending on `comfyPage` here forces ordering: comfyPage's auth + setup
// runs first, then mocks are installed, then the page reloads with the
// mocked config + intercepted endpoints in place.
function createSubscriptionTest(
...defaultOps: Parameters<typeof createSubscriptionHelper>[1][]
) {
return comfyPageFixture.extend<{
subscriptionHelper: SubscriptionHelper
}>({
subscriptionHelper: [
async ({ comfyPage }, use) => {
const helper = createSubscriptionHelper(comfyPage.page, ...defaultOps)
await helper.mock()
// Disable the cloud-subscription extension so its `requireActive
// Subscription` watcher doesn't auto-open the subscription dialog
// on app boot. The PrimeVue Dialog mask would otherwise intercept
// pointer events on every topbar button these tests interact with.
await comfyPage.setupSettings({
'Comfy.Extension.Disabled': ['Comfy.Cloud.Subscription']
})
await comfyPage.page.reload()
// Wait for Firebase auth to resolve: the user button is v-if="isLoggedIn"
// and only renders after onAuthStateChanged fires with the mock user from
// IndexedDB. waitForAppReady() does not wait for this — Firebase resolves
// asynchronously after app boot. Waiting here ensures the button is
// present before any test body tries to click it.
await expect(
comfyPage.page.getByTestId(TestIds.user.currentUserButton)
).toBeVisible()
// Defense-in-depth: if the dialog still surfaces (e.g. via a
// different code path), dismiss it before the test runs.
await dismissSubscriptionDialogIfOpen(comfyPage.page)
await use(helper)
await helper.clearMocks()
},
{ auto: true }
]
})
}
const unsubscribedTest = createSubscriptionTest(withUnsubscribed())
const subscribedTest = createSubscriptionTest(withActiveSubscription('CREATOR'))
const freeTierTest = createSubscriptionTest(withFreeTier())
unsubscribedTest.describe(
'Subscription buttons — unsubscribed',
{ tag: '@cloud' },
() => {
unsubscribedTest(
'SubscribeToRun visible when unsubscribed',
async ({ comfyPage }) => {
await expect(
comfyPage.page.getByTestId(TestIds.topbar.subscribeToRunButton)
).toBeVisible()
}
)
unsubscribedTest(
'SubscribeToRun click opens subscription dialog',
async ({ comfyPage }) => {
await comfyPage.page
.getByTestId(TestIds.topbar.subscribeToRunButton)
.click()
await expect(
comfyPage.page.locator('[aria-labelledby="subscription-required"]')
).toBeVisible()
}
)
unsubscribedTest(
'SubscribeToRun shows short label at narrow viewport',
async ({ comfyPage }) => {
await comfyPage.page.setViewportSize({ width: 393, height: 851 })
const btn = comfyPage.page.getByTestId(
TestIds.topbar.subscribeToRunButton
)
await expect(btn).toBeVisible()
await expect(btn).not.toContainText(/to run/i)
}
)
unsubscribedTest(
'User popover shows subscribe when unsubscribed',
async ({ comfyPage }) => {
const popover = await openUserPopover(comfyPage.page)
await expect(
popover.getByRole('button', { name: /subscribe/i })
).toBeVisible()
}
)
unsubscribedTest(
'User popover subscribe click opens dialog',
async ({ comfyPage }) => {
await clickPopoverSubscribe(comfyPage.page)
await expect(
comfyPage.page.locator('[aria-labelledby="subscription-required"]')
).toBeVisible()
}
)
unsubscribedTest(
'Subscription state transition updates UI after re-fetch',
async ({ comfyPage, subscriptionHelper }) => {
await expect(
comfyPage.page.getByTestId(TestIds.topbar.subscribeToRunButton)
).toBeVisible()
// Simulate returning from Stripe checkout: seed pending checkout,
// mutate mock to return active subscription, trigger re-fetch.
await subscriptionHelper.seedPendingCheckout('standard', 'monthly')
subscriptionHelper.setStatus({
is_active: true,
subscription_tier: 'STANDARD',
subscription_duration: 'MONTHLY'
})
await subscriptionHelper.triggerSubscriptionRefetch()
await expect(
comfyPage.page.getByTestId(TestIds.topbar.subscribeToRunButton)
).toBeHidden()
}
)
unsubscribedTest(
'Cleanup prevents stale subscription state after dialog close',
async ({ comfyPage, subscriptionHelper }) => {
await clickPopoverSubscribe(comfyPage.page)
// Use the aria-labelledby key to target only the subscription dialog —
// avoids strict-mode violations when a second GlobalDialog is stacked.
const dialog = comfyPage.page.locator(
'[aria-labelledby="subscription-required"]'
)
await expect(dialog).toBeVisible()
await dialog.getByRole('button', { name: /close/i }).first().click()
await expect(dialog).toBeHidden()
await subscriptionHelper.seedPendingCheckout('standard', 'monthly')
subscriptionHelper.setStatus({
is_active: true,
subscription_tier: 'STANDARD',
subscription_duration: 'MONTHLY'
})
await subscriptionHelper.triggerSubscriptionRefetch()
await expect(dialog).toBeHidden()
}
)
}
)
subscribedTest.describe(
'Subscription buttons — subscribed',
{ tag: '@cloud' },
() => {
subscribedTest(
'SubscribeToRun hidden when subscribed',
async ({ comfyPage }) => {
await expect(
comfyPage.page.getByTestId(TestIds.topbar.queueButton)
).toBeVisible()
await expect(
comfyPage.page.getByTestId(TestIds.topbar.subscribeToRunButton)
).toBeHidden()
}
)
subscribedTest(
'Topbar subscribe button hidden for paid tier',
async ({ comfyPage }) => {
await expect(
comfyPage.page.getByTestId(TestIds.topbar.queueButton)
).toBeVisible()
await expect(
comfyPage.page.getByTestId(TestIds.topbar.subscribeButton)
).toBeHidden()
}
)
}
)
freeTierTest.describe(
'Subscription buttons — free tier',
{ tag: '@cloud' },
() => {
freeTierTest(
'Topbar subscribe button visible for free tier',
async ({ comfyPage }) => {
await expect(
comfyPage.page.getByTestId(TestIds.topbar.subscribeButton)
).toBeVisible()
}
)
}
)

View File

@@ -23,7 +23,6 @@ import { webSocketFixture } from '@e2e/fixtures/ws'
const test = mergeTests(comfyPageFixture, webSocketFixture)
const ERROR_CLASS = /ring-destructive-background/
const SLOT_ERROR_CLASS = /before:ring-error/
const UNKNOWN_NODE_ID = '1'
const INNER_EXECUTION_ID = '2:1'
const KSAMPLER_MODEL_INPUT_NAME = 'model'
@@ -70,25 +69,6 @@ async function selectLoadImageNodeForPaste(
}, localLoadImageId)
}
async function getInputSlotIndexByName(
comfyPage: ComfyPage,
nodeId: string,
inputName: string
): Promise<number> {
return comfyPage.page.evaluate(
({ inputName, nodeId }) => {
const graph = window.app!.canvas.graph ?? window.app!.graph
const node = graph.getNodeById(nodeId)
const index = node?.findInputSlot(inputName) ?? -1
if (index < 0) {
throw new Error(`Input slot "${inputName}" not found`)
}
return index
},
{ inputName, nodeId: toNodeId(nodeId) }
)
}
async function setupLoadImageErrorScenario(comfyPage: ComfyPage) {
await comfyPage.workflow.loadWorkflow('widgets/load_image_widget')
const loadImageNode = (
@@ -159,10 +139,17 @@ test.describe('Vue Node Error', { tag: '@vue-nodes' }, () => {
async ({ comfyPage }) => {
const ksamplerId = await comfyPage.vueNodes.getNodeIdByTitle('KSampler')
const ksamplerNode = comfyPage.vueNodes.getNodeLocator(ksamplerId)
const modelInputIndex = await getInputSlotIndexByName(
comfyPage,
ksamplerId,
KSAMPLER_MODEL_INPUT_NAME
const modelInputIndex = await comfyPage.page.evaluate(
({ nodeId, inputName }) => {
const node = window.app!.graph.getNodeById(nodeId)
const index =
node?.inputs?.findIndex((input) => input.name === inputName) ?? -1
if (index < 0) {
throw new Error(`Input slot "${inputName}" not found`)
}
return index
},
{ nodeId: toNodeId(ksamplerId), inputName: KSAMPLER_MODEL_INPUT_NAME }
)
const modelInputSlotRow = comfyPage.vueNodes.getInputSlotRow(
ksamplerId,
@@ -188,7 +175,7 @@ test.describe('Vue Node Error', { tag: '@vue-nodes' }, () => {
await expect(modelInputSlotRow).toBeVisible()
await expect(modelInputSlotRow).toBeInViewport()
await expect(modelInputSlotHighlight).toHaveClass(SLOT_ERROR_CLASS)
await expect(modelInputSlotHighlight).toHaveClass(/before:ring-error/)
await expect(
comfyPage.vueNodes.getNodeInnerWrapper(ksamplerId)
).toHaveClass(ERROR_CLASS)
@@ -420,76 +407,5 @@ test.describe('Vue Node Error', { tag: '@vue-nodes' }, () => {
await expect(innerWrapper).toHaveClass(ERROR_CLASS)
})
test('boundary-linked validation error surfaces on the subgraph host', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
const subgraphParentId =
await comfyPage.vueNodes.getNodeIdByTitle('New Subgraph')
const innerWrapper =
comfyPage.vueNodes.getNodeInnerWrapper(subgraphParentId)
const hostInputIndex = await getInputSlotIndexByName(
comfyPage,
subgraphParentId,
'positive'
)
const hostInputSlotHighlight =
comfyPage.vueNodes.getInputSlotConnectionDot(
subgraphParentId,
hostInputIndex
)
await expect(
innerWrapper,
'subgraph host must mount before injecting validation errors'
).toBeVisible()
await expect(
innerWrapper,
'subgraph host should start without an error ring'
).not.toHaveClass(ERROR_CLASS)
await test.step('surface the boundary-linked error on the host', async () => {
const exec = new ExecutionHelper(comfyPage)
await exec.mockValidationFailure({
[INNER_EXECUTION_ID]: buildKSamplerError(
'required_input_missing',
'positive',
'Required input is missing: positive'
)
})
await comfyPage.runButton.click()
await dismissErrorOverlay(comfyPage)
await expect(innerWrapper).toHaveClass(ERROR_CLASS)
await expect(hostInputSlotHighlight).toHaveClass(SLOT_ERROR_CLASS)
})
await test.step('confirm the interior node does not show the surfaced ring', async () => {
await comfyPage.vueNodes.enterSubgraph(subgraphParentId)
await comfyPage.nextFrame()
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
const interiorKSamplerId =
await comfyPage.vueNodes.getNodeIdByTitle('KSampler')
const interiorPositiveInputIndex = await getInputSlotIndexByName(
comfyPage,
interiorKSamplerId,
'positive'
)
const interiorPositiveSlotHighlight =
comfyPage.vueNodes.getInputSlotConnectionDot(
interiorKSamplerId,
interiorPositiveInputIndex
)
const interiorInnerWrapper =
comfyPage.vueNodes.getNodeInnerWrapper(interiorKSamplerId)
await expect(interiorInnerWrapper).toBeVisible()
await expect(interiorInnerWrapper).not.toHaveClass(ERROR_CLASS)
await expect(interiorPositiveSlotHighlight).toBeVisible()
await expect(interiorPositiveSlotHighlight).not.toHaveClass(
SLOT_ERROR_CLASS
)
})
})
})
})

View File

@@ -126,7 +126,7 @@ function nodeToNodeData(node: LGraphNode) {
return {
...nodeData,
hasErrors: !!executionErrorStore.surfacedNodeErrors?.[node.id],
hasErrors: !!executionErrorStore.lastNodeErrors?.[node.id],
dropIndicator,
onDragDrop: node.onDragDrop,
onDragOver: node.onDragOver

View File

@@ -5,11 +5,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { MissingNodeType } from '@/types/comfy'
import type { NodeExecutionId } from '@/types/nodeIdentification'
import type * as GraphTraversalUtil from '@/utils/graphTraversalUtil'
vi.mock('@/scripts/app', () => ({
app: {
isGraphReady: true,
rootGraph: {
serialize: vi.fn(() => ({})),
getNodeById: vi.fn()
@@ -129,8 +127,6 @@ import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
import { isLGraphNode } from '@/utils/litegraphUtil'
import { nodeError, validationError } from '@/utils/__tests__/nodeErrorHelpers'
import { createBoundaryLinkedSubgraph } from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
import {
getExecutionIdByNode,
getNodeByExecutionId
@@ -497,47 +493,6 @@ describe('useErrorGroups', () => {
)
})
it('groups lifted boundary errors under the host node card', async () => {
const { store, groups } = createErrorGroups()
const { rootGraph, host } = createBoundaryLinkedSubgraph({
interiorType: 'InteriorClass'
})
const { getNodeByExecutionId: actualGetNodeByExecutionId } =
await vi.importActual<typeof GraphTraversalUtil>(
'@/utils/graphTraversalUtil'
)
vi.mocked(getNodeByExecutionId).mockImplementation((_, nodeId) => {
return actualGetNodeByExecutionId(rootGraph, String(nodeId))
})
store.lastNodeErrors = {
'12:5': nodeError(
[
validationError(
'required_input_missing',
'seed_input',
{},
'Required input is missing'
)
],
'InteriorClass'
)
}
await nextTick()
const execGroup = groups.allErrorGroups.value.find(
(g) => g.type === 'execution'
)
expect(execGroup?.type).toBe('execution')
if (execGroup?.type !== 'execution') return
const card = execGroup.cards[0]
expect(card.nodeId).toBe('12')
expect(card.title).toBe(host.title)
expect(card.errors[0].displayDetails).toBe(
`${host.title} is missing a required input: seed`
)
})
it('groups node validation errors by catalog id across node types', async () => {
const { store, groups } = createErrorGroups()
store.lastNodeErrors = {

View File

@@ -382,10 +382,10 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
groupsMap: Map<string, GroupEntry>,
filterBySelection = false
) {
if (!executionErrorStore.surfacedNodeErrors) return
if (!executionErrorStore.lastNodeErrors) return
for (const [rawNodeId, nodeError] of Object.entries(
executionErrorStore.surfacedNodeErrors
executionErrorStore.lastNodeErrors
)) {
const nodeId = tryNormalizeNodeExecutionId(rawNodeId)
if (!nodeId) continue

View File

@@ -6,6 +6,7 @@
class="p-1 hover:bg-transparent"
variant="muted-textonly"
:aria-label="$t('g.currentUser')"
data-testid="current-user-button"
@click="popover?.toggle($event)"
>
<div

View File

@@ -1,6 +1,7 @@
<!-- A popover that shows current user information and actions -->
<template>
<div
data-testid="current-user-popover"
class="current-user-popover -m-3 w-80 rounded-lg border border-border-default bg-base-background p-2 shadow-[1px_1px_8px_0_rgba(0,0,0,0.4)]"
>
<!-- User Info Section -->

View File

@@ -1,22 +0,0 @@
<script setup lang="ts">
import { HoverCardRoot, useForwardPropsEmits } from 'reka-ui'
import type { HoverCardRootEmits, HoverCardRootProps } from 'reka-ui'
import { provide, ref } from 'vue'
import { hoverCardOpenKey } from './hoverCardContext'
// eslint-disable-next-line vue/no-unused-properties -- forwarded to Reka via useForwardPropsEmits
const props = defineProps<HoverCardRootProps>()
const emits = defineEmits<HoverCardRootEmits>()
const forwarded = useForwardPropsEmits(props, emits)
const isOpen = ref(false)
provide(hoverCardOpenKey, isOpen)
</script>
<template>
<HoverCardRoot v-bind="forwarded" v-model:open="isOpen">
<slot />
</HoverCardRoot>
</template>

View File

@@ -1,51 +0,0 @@
<script setup lang="ts">
import { ZIndex } from '@primeuix/utils/zindex'
import { HoverCardContent, HoverCardPortal, useForwardProps } from 'reka-ui'
import type { HoverCardContentProps } from 'reka-ui'
import { computed, inject } from 'vue'
import type { HTMLAttributes } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
import { hoverCardOpenKey } from './hoverCardContext'
// Shared base for @primeuix's auto-incrementing 'modal' z-index counter.
const MODAL_BASE_Z_INDEX = 1700
const {
class: className,
side = 'bottom',
sideOffset = 8,
...rest
} = defineProps<HoverCardContentProps & { class?: HTMLAttributes['class'] }>()
const forwarded = useForwardProps(computed(() => rest))
// Body-portaled content sits at a static z-1700 unless a dialog that joined
// @primeuix's 'modal' counter is open above it; then lift past that dialog.
const open = inject(hoverCardOpenKey, undefined)
const contentStyle = computed(() => {
if (!open?.value) return undefined
const topZIndex = ZIndex.getCurrent('modal')
return topZIndex >= MODAL_BASE_Z_INDEX ? { zIndex: topZIndex + 1 } : undefined
})
</script>
<template>
<HoverCardPortal>
<HoverCardContent
v-bind="forwarded"
:side
:side-offset
:style="contentStyle"
:class="
cn(
'z-1700 rounded-lg border border-border-subtle bg-secondary-background p-2.5 shadow-md outline-none',
className
)
"
>
<slot />
</HoverCardContent>
</HoverCardPortal>
</template>

View File

@@ -1,12 +0,0 @@
<script setup lang="ts">
import { HoverCardTrigger } from 'reka-ui'
import type { HoverCardTriggerProps } from 'reka-ui'
const props = defineProps<HoverCardTriggerProps>()
</script>
<template>
<HoverCardTrigger v-bind="props">
<slot />
</HoverCardTrigger>
</template>

View File

@@ -1,7 +0,0 @@
import type { InjectionKey, Ref } from 'vue'
// Shares the root open-state with the content so it can lift its z-index above
// a dialog that joined @primeuix's incrementing 'modal' counter (otherwise the
// body-portaled content renders behind the settings dialog).
export const hoverCardOpenKey: InjectionKey<Ref<boolean>> =
Symbol('hoverCardOpen')

View File

@@ -1,72 +0,0 @@
<template>
<PaginationRoot
:page="page"
:total="total"
:items-per-page="itemsPerPage"
:sibling-count="1"
show-edges
@update:page="(p: number) => emit('update:page', p)"
>
<div class="flex items-center gap-1">
<PaginationPrev as-child>
<Button variant="muted-textonly" size="md" class="text-sm">
<i class="icon-[lucide--chevron-left] size-4" />
{{ $t('g.previous') }}
</Button>
</PaginationPrev>
<PaginationList v-slot="{ items }" class="flex items-center gap-1">
<template v-for="(item, index) in items" :key="index">
<PaginationListItem
v-if="item.type === 'page'"
:value="item.value"
as-child
>
<Button
:variant="item.value === page ? 'secondary' : 'muted-textonly'"
size="icon"
>
{{ item.value }}
</Button>
</PaginationListItem>
<PaginationEllipsis v-else :index="index" :class="ellipsisClass">
</PaginationEllipsis>
</template>
</PaginationList>
<PaginationNext as-child>
<Button variant="muted-textonly" size="md" class="text-sm">
{{ $t('g.next') }}
<i class="icon-[lucide--chevron-right] size-4" />
</Button>
</PaginationNext>
</div>
</PaginationRoot>
</template>
<script setup lang="ts">
import {
PaginationEllipsis,
PaginationList,
PaginationListItem,
PaginationNext,
PaginationPrev,
PaginationRoot
} from 'reka-ui'
import Button from '@/components/ui/button/Button.vue'
const {
page = 1,
total,
itemsPerPage = 10
} = defineProps<{
page?: number
total: number
itemsPerPage?: number
}>()
const emit = defineEmits<{ 'update:page': [page: number] }>()
const ellipsisClass =
'inline-flex size-8 items-center justify-center text-sm text-muted-foreground'
</script>

View File

@@ -1,17 +0,0 @@
<template>
<div :class="cn('relative w-full overflow-auto', className)">
<table
class="w-full caption-bottom border-separate border-spacing-0 text-sm"
>
<slot />
</table>
</div>
</template>
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
const { class: className } = defineProps<{ class?: HTMLAttributes['class'] }>()
</script>

View File

@@ -1,13 +0,0 @@
<template>
<tbody :class="cn('[&_tr:last-child]:border-0', className)">
<slot />
</tbody>
</template>
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
const { class: className } = defineProps<{ class?: HTMLAttributes['class'] }>()
</script>

View File

@@ -1,13 +0,0 @@
<template>
<td :class="cn('px-2 py-2.5 align-middle whitespace-nowrap', className)">
<slot />
</td>
</template>
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
const { class: className } = defineProps<{ class?: HTMLAttributes['class'] }>()
</script>

View File

@@ -1,21 +0,0 @@
<template>
<th
scope="col"
:class="
cn(
'h-10 px-2 text-left align-middle text-sm font-normal whitespace-nowrap text-muted-foreground',
className
)
"
>
<slot />
</th>
</template>
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
const { class: className } = defineProps<{ class?: HTMLAttributes['class'] }>()
</script>

View File

@@ -1,15 +0,0 @@
<template>
<thead
:class="cn('[&_tr]:border-b [&_tr]:border-interface-stroke/60', className)"
>
<slot />
</thead>
</template>
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
const { class: className } = defineProps<{ class?: HTMLAttributes['class'] }>()
</script>

View File

@@ -1,20 +0,0 @@
<template>
<tr
:class="
cn(
'border-b border-interface-stroke/60 transition-colors hover:bg-secondary-background/50 data-[state=selected]:bg-secondary-background/50',
className
)
"
>
<slot />
</tr>
</template>
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
const { class: className } = defineProps<{ class?: HTMLAttributes['class'] }>()
</script>

View File

@@ -84,7 +84,7 @@ function reconcileNodeErrorFlags(
}
export function useNodeErrorFlagSync(
nodeErrors: Ref<Record<string, NodeError> | null>,
lastNodeErrors: Ref<Record<string, NodeError> | null>,
missingModelStore: ReturnType<typeof useMissingModelStore>,
missingMediaStore: ReturnType<typeof useMissingMediaStore>
): () => void {
@@ -95,7 +95,7 @@ export function useNodeErrorFlagSync(
const stop = watch(
[
nodeErrors,
lastNodeErrors,
() => missingModelStore.missingModelNodeIds,
() => missingMediaStore.missingMediaNodeIds,
showErrorsTab
@@ -108,7 +108,7 @@ export function useNodeErrorFlagSync(
// Vue nodes compute hasAnyError independently and are unaffected.
reconcileNodeErrorFlags(
app.rootGraph,
nodeErrors.value,
lastNodeErrors.value,
showErrorsTab.value
? missingModelStore.missingModelAncestorExecutionIds
: new Set(),

View File

@@ -1,299 +0,0 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import { promoteValueWidgetViaSubgraphInput } from '@/core/graph/subgraph/promotionUtils'
import { nodeError, validationError } from '@/utils/__tests__/nodeErrorHelpers'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import {
createBoundaryLinkedSubgraph,
createTestRootGraph,
createTestSubgraph,
createTestSubgraphNode
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
import { toNodeId } from '@/types/nodeId'
import { liftNodeErrorsToBoundary } from './liftNodeErrorsToBoundary'
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
describe('liftNodeErrorsToBoundary', () => {
it('lifts a boundary-linked slot error to the host', () => {
const { host, rootGraph } = createBoundaryLinkedSubgraph()
const errors = {
'12:5': nodeError([
validationError('required_input_missing', 'seed_input')
])
}
const result = liftNodeErrorsToBoundary(rootGraph, errors)
expect(result).toEqual({
'12': {
class_type: host.title,
dependent_outputs: [],
errors: [
expect.objectContaining({
type: 'required_input_missing',
extra_info: expect.objectContaining({
input_name: 'seed',
source_execution_id: '12:5',
source_input_name: 'seed_input'
})
})
]
}
})
})
it('lifts a promoted-widget value error to the host input', () => {
const rootGraph = createTestRootGraph()
const subgraph = createTestSubgraph({ rootGraph })
const host = createTestSubgraphNode(subgraph, { id: 12 })
rootGraph.add(host)
const interior = new LGraphNode('CheckpointLoaderSimple')
interior.id = toNodeId(5)
const input = interior.addInput('ckpt_name', 'COMBO')
const widget = interior.addWidget('combo', 'ckpt_name', '', () => {}, {
values: ['present.safetensors']
})
input.widget = { name: widget.name }
subgraph.add(interior)
expect(promoteValueWidgetViaSubgraphInput(host, interior, widget).ok).toBe(
true
)
const result = liftNodeErrorsToBoundary(rootGraph, {
'12:5': nodeError([
validationError('value_not_in_list', 'ckpt_name', {
received_value: 'missing.safetensors',
input_config: ['COMBO', { values: ['present.safetensors'] }]
})
])
})
expect(result['12'].errors[0].extra_info).toMatchObject({
input_name: 'ckpt_name',
source_execution_id: '12:5',
source_input_name: 'ckpt_name',
received_value: 'missing.safetensors',
input_config: ['COMBO', { values: ['present.safetensors'] }]
})
})
it('recurses through nested boundary-linked hosts', () => {
const rootGraph = createTestRootGraph()
const outerSubgraph = createTestSubgraph({
rootGraph,
inputs: [{ name: 'seed', type: '*' }]
})
const outerHost = createTestSubgraphNode(outerSubgraph, { id: 1 })
outerHost.title = 'Outer Host'
rootGraph.add(outerHost)
const middleSubgraph = createTestSubgraph({
rootGraph,
inputs: [{ name: 'seed', type: '*' }]
})
const middleHost = createTestSubgraphNode(middleSubgraph, {
id: 2,
parentGraph: outerSubgraph
})
outerSubgraph.add(middleHost)
outerSubgraph.inputNode.slots[0].connect(middleHost.inputs[0], middleHost)
const leaf = new LGraphNode('LeafNode')
leaf.id = toNodeId(3)
const leafInput = leaf.addInput('seed_input', '*')
middleSubgraph.add(leaf)
middleSubgraph.inputNode.slots[0].connect(leafInput, leaf)
const result = liftNodeErrorsToBoundary(rootGraph, {
'1:2:3': nodeError([
validationError('required_input_missing', 'seed_input')
])
})
expect(Object.keys(result)).toEqual(['1'])
expect(result['1'].class_type).toBe(outerHost.title)
expect(result['1'].errors[0].extra_info).toMatchObject({
input_name: 'seed',
source_execution_id: '1:2:3',
source_input_name: 'seed_input'
})
})
it('keeps errors on ordinary interior data-flow links', () => {
const rootGraph = createTestRootGraph()
const subgraph = createTestSubgraph({
rootGraph,
inputs: [{ name: 'seed', type: '*' }]
})
const host = createTestSubgraphNode(subgraph, { id: 12 })
rootGraph.add(host)
const source = new LGraphNode('SourceNode')
source.id = toNodeId(4)
source.addOutput('seed', '*')
subgraph.add(source)
const target = new LGraphNode('TargetNode')
target.id = toNodeId(5)
target.addInput('seed_input', '*')
subgraph.add(target)
source.connect(0, target, 0)
const errors = {
'12:5': nodeError([
validationError('required_input_missing', 'seed_input')
])
}
expect(liftNodeErrorsToBoundary(rootGraph, errors)).toEqual(errors)
})
it('keeps errors without a liftable subject on the interior node', () => {
const { rootGraph } = createBoundaryLinkedSubgraph()
const errors = {
'12:5': nodeError([
validationError('required_input_missing'),
validationError('exception_during_validation', 'seed_input'),
validationError('dependency_cycle', 'seed_input'),
validationError(
'custom_validation_failed',
'seed_input',
{ received_value: 'image.png' },
'Invalid image file'
)
])
}
expect(liftNodeErrorsToBoundary(rootGraph, errors)).toEqual(errors)
})
it('keeps unknown typed validation errors on the interior node', () => {
const { rootGraph } = createBoundaryLinkedSubgraph()
const errors = {
'12:5': nodeError([
validationError('future_backend_validation_type', 'seed_input')
])
}
expect(liftNodeErrorsToBoundary(rootGraph, errors)).toEqual(errors)
})
it('splits liftable and non-liftable errors from the same node entry', () => {
const { rootGraph } = createBoundaryLinkedSubgraph()
const result = liftNodeErrorsToBoundary(rootGraph, {
'12:5': nodeError([
validationError('required_input_missing', 'seed_input'),
validationError('exception_during_validation', 'seed_input')
])
})
expect(result['12'].errors).toHaveLength(1)
expect(result['12'].errors[0].type).toBe('required_input_missing')
expect(result['12:5'].errors).toHaveLength(1)
expect(result['12:5'].errors[0].type).toBe('exception_during_validation')
})
it('merges a lifted error into an existing host entry', () => {
const { rootGraph } = createBoundaryLinkedSubgraph()
const errors = {
'12': {
class_type: 'ExistingHostClass',
dependent_outputs: ['existing-output'],
errors: [validationError('value_smaller_than_min', 'other')]
},
'12:5': nodeError([
validationError('required_input_missing', 'seed_input')
])
}
const result = liftNodeErrorsToBoundary(rootGraph, errors)
expect(result['12']).toMatchObject({
class_type: 'ExistingHostClass',
dependent_outputs: ['existing-output']
})
expect(result['12'].errors.map((error) => error.type)).toEqual([
'value_smaller_than_min',
'required_input_missing'
])
})
it('keeps own errors before lifted errors for nested host keys', () => {
const rootGraph = createTestRootGraph()
const outerSubgraph = createTestSubgraph({ rootGraph })
const outerHost = createTestSubgraphNode(outerSubgraph, { id: 1 })
rootGraph.add(outerHost)
const middleSubgraph = createTestSubgraph({
rootGraph,
inputs: [{ name: 'seed', type: '*' }]
})
const middleHost = createTestSubgraphNode(middleSubgraph, {
id: 2,
parentGraph: outerSubgraph
})
outerSubgraph.add(middleHost)
const leaf = new LGraphNode('LeafNode')
leaf.id = toNodeId(3)
const leafInput = leaf.addInput('seed_input', '*')
middleSubgraph.add(leaf)
middleSubgraph.inputNode.slots[0].connect(leafInput, leaf)
const result = liftNodeErrorsToBoundary(rootGraph, {
'1:2:3': nodeError([
validationError('required_input_missing', 'seed_input')
]),
'1:2': nodeError([validationError('value_smaller_than_min', 'seed')])
})
expect(result['1:2'].errors.map((error) => error.type)).toEqual([
'value_smaller_than_min',
'required_input_missing'
])
})
it('preserves empty error entries unchanged', () => {
const rootGraph = createTestRootGraph()
const errors = {
'12': nodeError([], 'ExtraRootNode')
}
expect(liftNodeErrorsToBoundary(rootGraph, errors)).toEqual(errors)
})
it('fails open without mutating the input record', () => {
const rootGraph = createTestRootGraph()
const subgraph = createTestSubgraph({ rootGraph })
const host = createTestSubgraphNode(subgraph, { id: 12 })
rootGraph.add(host)
const interior = new LGraphNode('InteriorNode')
interior.id = toNodeId(5)
interior.addInput('unlinked', '*')
subgraph.add(interior)
const errors = {
'99:5': nodeError([validationError('required_input_missing', 'x')]),
'12:5': nodeError([
validationError('required_input_missing', 'missing'),
validationError('value_not_in_list', 'unlinked')
])
}
const original = structuredClone(errors)
const result = liftNodeErrorsToBoundary(rootGraph, errors)
expect(result).toEqual(original)
expect(errors).toEqual(original)
expect(result).not.toBe(errors)
})
})

View File

@@ -1,193 +0,0 @@
import { groupBy, partition } from 'es-toolkit'
import type { LGraph } from '@/lib/litegraph/src/litegraph'
import type { NodeError } from '@/schemas/apiSchema'
import { tryNormalizeNodeExecutionId } from '@/types/nodeIdentification'
import type { NodeExecutionId } from '@/types/nodeIdentification'
import { isNodeLevelValidationError } from '@/utils/executionErrorUtil'
import type { NodeValidationError } from '@/utils/executionErrorUtil'
import { getNodeByExecutionId } from '@/utils/graphTraversalUtil'
import { isSubgraph } from '@/utils/typeGuardUtil'
export interface LiftedErrorExtraInfo {
input_name: string
source_execution_id: string
source_input_name: string
}
export interface LiftedSurface {
hostExecId: NodeExecutionId
hostInputName: string
}
interface ErrorPlacement {
kind: 'own' | 'lifted'
targetExecId: string
error: NodeValidationError
}
export function getLiftedErrorSource(
error: NodeValidationError
): LiftedErrorExtraInfo | null {
const extraInfo = error.extra_info
if (!extraInfo) return null
const { input_name, source_execution_id, source_input_name } = extraInfo
if (
typeof input_name !== 'string' ||
typeof source_execution_id !== 'string' ||
typeof source_input_name !== 'string'
) {
return null
}
return { input_name, source_execution_id, source_input_name }
}
function getHostExecutionId(executionId: string): NodeExecutionId | null {
const separatorIndex = executionId.lastIndexOf(':')
if (separatorIndex <= 0) return null
return tryNormalizeNodeExecutionId(executionId.slice(0, separatorIndex))
}
/**
* Boundary surfaces that expose `(executionId, inputName)`, innermost first.
* Walks one host per level and stops at the last resolvable surface, so an
* unresolvable deeper host falls back to the shallower one (fail-open).
*/
export function resolveLiftChain(
rootGraph: LGraph,
executionId: string,
inputName: string
): LiftedSurface[] {
const chain: LiftedSurface[] = []
let currentExecId = executionId
let currentInputName = inputName
for (;;) {
const node = getNodeByExecutionId(rootGraph, currentExecId)
const graph = node?.graph
if (!node || !graph || !isSubgraph(graph)) break
const slot = node.inputs?.find((input) => input.name === currentInputName)
if (slot?.link == null) break
const subgraphInput = graph
.getLink(slot.link)
?.resolve(graph)?.subgraphInput
if (!subgraphInput) break
const hostExecId = getHostExecutionId(currentExecId)
if (!hostExecId || !getNodeByExecutionId(rootGraph, hostExecId)) break
chain.push({ hostExecId, hostInputName: subgraphInput.name })
currentExecId = hostExecId
currentInputName = subgraphInput.name
}
return chain
}
function createEmptyNodeError(nodeError: NodeError): NodeError {
return {
...nodeError,
errors: []
}
}
// Lifted host entries use the host title for display; SubgraphNode.type is a UUID.
function createLiftedHostEntry(
rootGraph: LGraph,
hostExecId: string
): NodeError {
return {
class_type:
getNodeByExecutionId(rootGraph, hostExecId)?.title ?? hostExecId,
dependent_outputs: [],
errors: []
}
}
function toErrorPlacement(
rootGraph: LGraph,
executionId: string,
error: NodeValidationError
): ErrorPlacement {
const inputName = error.extra_info?.input_name
const surface =
inputName && !isNodeLevelValidationError(error)
? resolveLiftChain(rootGraph, executionId, inputName).at(-1)
: undefined
if (!inputName || !surface) {
return {
kind: 'own',
targetExecId: executionId,
error
}
}
const liftedExtraInfo: LiftedErrorExtraInfo = {
input_name: surface.hostInputName,
source_execution_id: executionId,
source_input_name: inputName
}
return {
kind: 'lifted',
targetExecId: surface.hostExecId,
error: {
...error,
extra_info: {
...error.extra_info,
...liftedExtraInfo
}
}
}
}
export function liftNodeErrorsToBoundary(
rootGraph: LGraph,
nodeErrors: Record<string, NodeError>
): Record<string, NodeError> {
const output: Record<string, NodeError> = {}
const placements = Object.entries(nodeErrors).flatMap(
([executionId, nodeError]) =>
nodeError.errors.map((error) =>
toErrorPlacement(rootGraph, executionId, error)
)
)
for (const [executionId, nodeError] of Object.entries(nodeErrors)) {
if (nodeError.errors.length === 0) {
output[executionId] = createEmptyNodeError(nodeError)
}
}
const placementsByTarget = groupBy(
placements,
(placement) => placement.targetExecId
)
for (const [targetExecId, targetPlacements] of Object.entries(
placementsByTarget
)) {
const baseEntry = nodeErrors[targetExecId]
? createEmptyNodeError(nodeErrors[targetExecId])
: createLiftedHostEntry(rootGraph, targetExecId)
const [ownErrors, liftedErrors] = partition(
targetPlacements,
(placement) => placement.kind === 'own'
)
output[targetExecId] = {
...baseEntry,
errors: [...ownErrors, ...liftedErrors].map(
(placement) => placement.error
)
}
}
return output
}

View File

@@ -3,42 +3,23 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { GizmoManager } from './GizmoManager'
const {
mockSetMode,
mockAttach,
mockDetach,
mockGetHelper,
mockDispose,
transformControlsInstances,
omitGetPointer
} = vi.hoisted(() => ({
mockSetMode: vi.fn(),
mockAttach: vi.fn(),
mockDetach: vi.fn(),
mockGetHelper: vi.fn(),
mockDispose: vi.fn(),
transformControlsInstances: [] as unknown[],
omitGetPointer: { value: false }
}))
const { mockSetMode, mockAttach, mockDetach, mockGetHelper, mockDispose } =
vi.hoisted(() => ({
mockSetMode: vi.fn(),
mockAttach: vi.fn(),
mockDetach: vi.fn(),
mockGetHelper: vi.fn(),
mockDispose: vi.fn()
}))
vi.mock('three/examples/jsm/controls/TransformControls', () => {
class TransformControls {
enabled = true
dragging = false
camera: THREE.Camera
_getPointer?: (event: PointerEvent) => {
x: number
y: number
button: number
}
private listeners = new Map<string, ((e: unknown) => void)[]>()
constructor(camera: THREE.Camera) {
this.camera = camera
if (!omitGetPointer.value) {
this._getPointer = (event) => ({ x: 0, y: 0, button: event.button })
}
transformControlsInstances.push(this)
}
addEventListener(event: string, cb: (e: unknown) => void) {
@@ -83,8 +64,6 @@ describe('GizmoManager', () => {
beforeEach(() => {
vi.clearAllMocks()
transformControlsInstances.length = 0
omitGetPointer.value = false
scene = new THREE.Scene()
interactionElement = document.createElement('div')
@@ -110,120 +89,6 @@ describe('GizmoManager', () => {
vi.restoreAllMocks()
})
describe('setPointerNdcSource', () => {
type PointerNdc = { x: number; y: number; button: number }
function lastControls() {
return transformControlsInstances.at(-1) as {
dragging: boolean
_getPointer?: (event: PointerEvent) => PointerNdc
}
}
function getPointerOverride() {
return lastControls()._getPointer
}
it('routes TransformControls pointer NDC through the injected source', () => {
manager.init()
manager.setPointerNdcSource((clientX, clientY) => ({
x: clientX / 100,
y: clientY / 100,
inside: true
}))
const pointer = getPointerOverride()!({
clientX: 50,
clientY: -25,
button: 2
} as PointerEvent)
expect(pointer).toEqual({ x: 0.5, y: -0.25, button: 2 })
})
it('maps unmappable points to an off-screen pointer', () => {
manager.init()
manager.setPointerNdcSource(() => null)
const pointer = getPointerOverride()!({
clientX: 0,
clientY: 0,
button: 0
} as PointerEvent)
expect(pointer).toEqual({ x: 10, y: 10, button: 0 })
})
it('maps points outside the viewport to an off-screen pointer while not dragging', () => {
manager.init()
manager.setPointerNdcSource(() => ({ x: -1.2, y: 0.4, inside: false }))
const pointer = getPointerOverride()!({
clientX: 0,
clientY: 0,
button: 0
} as PointerEvent)
expect(pointer).toEqual({ x: 10, y: 10, button: 0 })
})
it('keeps the unclamped NDC for points outside the viewport mid-drag', () => {
manager.init()
manager.setPointerNdcSource(() => ({ x: -1.2, y: 0.4, inside: false }))
lastControls().dragging = true
const pointer = getPointerOverride()!({
clientX: 0,
clientY: 0,
button: -1
} as PointerEvent)
expect(pointer).toEqual({ x: -1.2, y: 0.4, button: -1 })
})
it('applies a source registered before init once init runs', () => {
manager.setPointerNdcSource(() => ({ x: 0.5, y: 0.5, inside: true }))
manager.init()
const pointer = getPointerOverride()!({
clientX: 0,
clientY: 0,
button: 1
} as PointerEvent)
expect(pointer).toEqual({ x: 0.5, y: 0.5, button: 1 })
})
it('delegates to the stock mapping until a source is registered', () => {
manager.init()
const stock = getPointerOverride()!({
clientX: 40,
clientY: 60,
button: 2
} as PointerEvent)
expect(stock).toEqual({ x: 0, y: 0, button: 2 })
manager.setPointerNdcSource(() => ({ x: 0.5, y: -0.25, inside: true }))
const mapped = getPointerOverride()!({
clientX: 40,
clientY: 60,
button: 2
} as PointerEvent)
expect(mapped).toEqual({ x: 0.5, y: -0.25, button: 2 })
})
it('warns and skips the override when _getPointer is missing at init', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
omitGetPointer.value = true
manager.setPointerNdcSource(() => ({ x: 0.5, y: 0.5, inside: true }))
manager.init()
expect(warn).toHaveBeenCalledWith(expect.stringContaining('_getPointer'))
expect(lastControls()._getPointer).toBeUndefined()
})
})
describe('init', () => {
it('adds helper to scene with correct name and render order', () => {
manager.init()

View File

@@ -4,9 +4,6 @@ import { TransformControls } from 'three/examples/jsm/controls/TransformControls
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls'
import type { GizmoMode, Model3DTransform } from './interfaces'
import type { PointerNdcSource } from './load3dViewport'
const OFF_SCREEN_POINTER_NDC = { x: 10, y: 10 }
export class GizmoManager {
private transformControls: TransformControls | null = null
@@ -21,7 +18,6 @@ export class GizmoManager {
private interactionElement: HTMLElement
private orbitControls: OrbitControls
private onTransformChange?: () => void
private getPointerNdc?: PointerNdcSource
constructor(
scene: THREE.Scene,
@@ -50,45 +46,12 @@ export class GizmoManager {
}
})
this.installPointerNdcOverride()
const helper = this.transformControls.getHelper()
helper.name = 'GizmoTransformControls'
helper.renderOrder = 999
this.scene.add(helper)
}
setPointerNdcSource(getPointerNdc: PointerNdcSource): void {
this.getPointerNdc = getPointerNdc
}
private installPointerNdcOverride(): void {
if (!this.transformControls) return
const transformControls = this.transformControls
const controls = transformControls as unknown as {
_getPointer?: (event: PointerEvent) => {
x: number
y: number
button: number
}
}
const original = controls._getPointer
if (typeof original !== 'function') {
console.warn(
'TransformControls no longer exposes _getPointer; letterbox-aware gizmo pointer mapping is disabled.'
)
return
}
controls._getPointer = (event: PointerEvent) => {
if (!this.getPointerNdc) return original.call(transformControls, event)
const ndc = this.getPointerNdc(event.clientX, event.clientY)
if (!ndc || (!ndc.inside && !transformControls.dragging)) {
return { ...OFF_SCREEN_POINTER_NDC, button: event.button }
}
return { x: ndc.x, y: ndc.y, button: event.button }
}
}
setupForModel(model: THREE.Object3D): void {
if (!this.transformControls) return

View File

@@ -1,13 +1,11 @@
import * as THREE from 'three'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { Load3dDeps } from '@/extensions/core/load3d/Load3d'
import Load3d from '@/extensions/core/load3d/Load3d'
import type {
CameraState,
GizmoMode
} from '@/extensions/core/load3d/interfaces'
import type { PointerNdcSource } from '@/extensions/core/load3d/load3dViewport'
const {
cloneSkinnedMock,
@@ -1262,102 +1260,4 @@ describe('Load3d', () => {
)
})
})
describe('constructor wiring', () => {
function makeConstructorDeps() {
const container = document.createElement('div')
const canvas = document.createElement('canvas')
container.appendChild(canvas)
const view = {
canvas,
renderer: {
setViewport: vi.fn(),
setScissor: vi.fn(),
setScissorTest: vi.fn(),
setClearColor: vi.fn(),
clear: vi.fn(),
render: vi.fn()
},
width: 800,
height: 600,
state: { clearColor: new THREE.Color(0x000000), clearAlpha: 0 },
observeResize: vi.fn(),
beginRender: vi.fn(),
blit: vi.fn(),
setSize: vi.fn(),
dispose: vi.fn()
}
const gizmoManager = {
setPointerNdcSource: vi.fn(),
init: vi.fn(),
dispose: vi.fn()
}
const deps = {
view,
eventManager: {
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
emitEvent: vi.fn()
},
sceneManager: {
init: vi.fn(),
scene: new THREE.Scene(),
renderBackground: vi.fn(),
handleResize: vi.fn(),
dispose: vi.fn()
},
cameraManager: {
init: vi.fn(),
activeCamera: new THREE.PerspectiveCamera(),
handleResize: vi.fn(),
dispose: vi.fn()
},
controlsManager: { init: vi.fn(), update: vi.fn(), dispose: vi.fn() },
lightingManager: { init: vi.fn(), dispose: vi.fn() },
viewHelperManager: {
createViewHelper: vi.fn(),
init: vi.fn(),
update: vi.fn(),
render: vi.fn(),
dispose: vi.fn()
},
hdriManager: { dispose: vi.fn() },
loaderManager: { init: vi.fn(), dispose: vi.fn() },
modelManager: { dispose: vi.fn() },
recordingManager: {
getIsRecording: vi.fn(() => false),
dispose: vi.fn()
},
animationManager: {
init: vi.fn(),
update: vi.fn(),
isAnimationPlaying: false,
dispose: vi.fn()
},
gizmoManager,
adapterRef: { current: null, capabilities: null }
}
return { container, deps: deps as unknown as Load3dDeps, gizmoManager }
}
it('wires the gizmo pointer NDC source to clientPointToNdc on every construction path', () => {
const { container, deps, gizmoManager } = makeConstructorDeps()
const load3d = new Load3d(container, deps)
expect(gizmoManager.setPointerNdcSource).toHaveBeenCalledOnce()
const ndc = { x: 0.25, y: -0.5, inside: true }
const clientPointToNdc = vi
.spyOn(load3d, 'clientPointToNdc')
.mockReturnValue(ndc)
const source = gizmoManager.setPointerNdcSource.mock
.calls[0][0] as PointerNdcSource
expect(source(12, 34)).toBe(ndc)
expect(clientPointToNdc).toHaveBeenCalledWith(12, 34)
load3d.remove()
})
})
})

View File

@@ -83,9 +83,6 @@ class Load3d extends Viewport3d {
this.loaderManager.init()
this.animationManager.init()
this.gizmoManager.setPointerNdcSource((clientX, clientY) =>
this.clientPointToNdc(clientX, clientY)
)
this.gizmoManager.init()
this.eventManager.addEventListener('modelReady', () => {

View File

@@ -386,67 +386,6 @@ describe('Viewport3d', () => {
})
})
describe('clientPointToNdc', () => {
function installCanvas(rect: {
left: number
top: number
width: number
height: number
}) {
const canvas = document.createElement('canvas')
vi.spyOn(canvas, 'getBoundingClientRect').mockReturnValue({
...rect,
right: rect.left + rect.width,
bottom: rect.top + rect.height,
x: rect.left,
y: rect.top,
toJSON: () => ({})
} as DOMRect)
Object.assign(ctx.viewport, { view: { canvas } })
}
beforeEach(() => {
Object.assign(ctx.viewport, {
targetWidth: 100,
targetHeight: 100,
targetAspectRatio: 1,
isViewerMode: false
})
})
it('normalizes client coordinates against the canvas rect before letterbox mapping', () => {
installCanvas({ left: 100, top: 50, width: 400, height: 200 })
expect(ctx.viewport.clientPointToNdc(300, 150)).toEqual({
x: expect.closeTo(0),
y: expect.closeTo(0),
inside: true
})
expect(ctx.viewport.clientPointToNdc(150, 150)).toEqual({
x: expect.closeTo(-1.5),
y: expect.closeTo(0),
inside: false
})
})
it('returns null when the canvas has no layout size', () => {
installCanvas({ left: 0, top: 0, width: 0, height: 0 })
expect(ctx.viewport.clientPointToNdc(10, 10)).toBeNull()
})
it('maps the full canvas when no aspect ratio is maintained', () => {
installCanvas({ left: 100, top: 50, width: 400, height: 200 })
Object.assign(ctx.viewport, { targetWidth: 0, targetHeight: 0 })
expect(ctx.viewport.clientPointToNdc(100, 50)).toEqual({
x: expect.closeTo(-1),
y: expect.closeTo(1),
inside: true
})
})
})
describe('start / remove lifecycle', () => {
beforeEach(() => {
vi.useFakeTimers()

View File

@@ -1,7 +1,6 @@
import * as THREE from 'three'
import type { RendererView } from '@/renderer/three/RendererView'
import { normalize } from '@/utils/mathUtil'
import type { CameraManager } from './CameraManager'
import type { ControlsManager } from './ControlsManager'
@@ -18,12 +17,7 @@ import type {
import { attachContextMenuGuard } from './load3dContextMenuGuard'
import type { RenderLoopHandle } from './load3dRenderLoop'
import { startRenderLoop } from './load3dRenderLoop'
import type { LetterboxNdc } from './load3dViewport'
import {
clientPointToLetterboxNdc,
computeLetterboxedViewport,
isLoad3dActive
} from './load3dViewport'
import { computeLetterboxedViewport, isLoad3dActive } from './load3dViewport'
const VIEW_HELPER_SIZE = 128
@@ -282,17 +276,6 @@ export class Viewport3d {
this.renderer.render(this.sceneManager.scene, this.getRenderCamera())
}
clientPointToNdc(clientX: number, clientY: number): LetterboxNdc | null {
const rect = this.domElement.getBoundingClientRect()
if (rect.width <= 0 || rect.height <= 0) return null
return clientPointToLetterboxNdc(
normalize(clientX, rect.left, rect.right),
normalize(clientY, rect.top, rect.bottom),
{ width: rect.width, height: rect.height },
this.shouldMaintainAspectRatio() ? this.targetAspectRatio : null
)
}
protected startAnimation(): void {
this.renderLoop = startRenderLoop({
tick: () => {

View File

@@ -1,10 +1,6 @@
import { describe, expect, it } from 'vitest'
import {
clientPointToLetterboxNdc,
computeLetterboxedViewport,
isLoad3dActive
} from './load3dViewport'
import { computeLetterboxedViewport, isLoad3dActive } from './load3dViewport'
import type { Load3dActivityFlags } from './load3dViewport'
describe('computeLetterboxedViewport', () => {
@@ -110,59 +106,3 @@ describe('isLoad3dActive', () => {
expect(isLoad3dActive({ ...idle, [flag]: true })).toBe(true)
})
})
describe('clientPointToLetterboxNdc', () => {
function ndc(x: number, y: number, inside = true) {
return { x: expect.closeTo(x), y: expect.closeTo(y), inside }
}
it('maps the full canvas when no target aspect is set', () => {
expect(
clientPointToLetterboxNdc(0.5, 0.5, { width: 400, height: 300 }, null)
).toEqual(ndc(0, 0))
expect(
clientPointToLetterboxNdc(0, 1, { width: 400, height: 300 }, null)
).toEqual(ndc(-1, -1))
})
it('maps pillarboxed content edges to -1/1', () => {
const container = { width: 400, height: 200 }
expect(clientPointToLetterboxNdc(0.25, 0.5, container, 1)).toEqual(
ndc(-1, 0)
)
expect(clientPointToLetterboxNdc(0.75, 0, container, 1)).toEqual(ndc(1, 1))
expect(clientPointToLetterboxNdc(0.5, 0.5, container, 1)).toEqual(ndc(0, 0))
})
it('extrapolates unclamped NDC marked outside on the letterbox bars', () => {
const container = { width: 400, height: 200 }
expect(clientPointToLetterboxNdc(0.1, 0.5, container, 1)).toEqual(
ndc(-1.6, 0, false)
)
expect(clientPointToLetterboxNdc(0.9, 0.5, container, 1)).toEqual(
ndc(1.6, 0, false)
)
})
it('handles letterbox bars above/below wide content', () => {
const container = { width: 200, height: 400 }
expect(clientPointToLetterboxNdc(0.5, 0.375, container, 2)).toEqual(
ndc(0, 1)
)
expect(clientPointToLetterboxNdc(0.5, 0.1, container, 2)).toEqual(
ndc(0, 3.2, false)
)
})
it('returns null instead of NaN for zero-size containers', () => {
expect(
clientPointToLetterboxNdc(0.5, 0.5, { width: 0, height: 0 }, 1)
).toBeNull()
expect(
clientPointToLetterboxNdc(0.5, 0.5, { width: 0, height: 200 }, 1)
).toBeNull()
expect(
clientPointToLetterboxNdc(0.5, 0.5, { width: 400, height: 0 }, 1)
).toBeNull()
})
})

View File

@@ -1,5 +1,3 @@
import { denormalize, normalize } from '@/utils/mathUtil'
type Size = { width: number; height: number }
type LetterboxedViewport = {
@@ -36,39 +34,6 @@ export function computeLetterboxedViewport(
}
}
export type LetterboxNdc = { x: number; y: number; inside: boolean }
export type PointerNdcSource = (
clientX: number,
clientY: number
) => LetterboxNdc | null
export function clientPointToLetterboxNdc(
normalizedX: number,
normalizedY: number,
container: Size,
targetAspectRatio: number | null
): LetterboxNdc | null {
const toNdc = (localX: number, localY: number): LetterboxNdc => ({
x: denormalize(localX, -1, 1),
y: -denormalize(localY, -1, 1),
inside: localX >= 0 && localX <= 1 && localY >= 0 && localY <= 1
})
if (targetAspectRatio === null) {
return toNdc(normalizedX, normalizedY)
}
const { offsetX, offsetY, width, height } = computeLetterboxedViewport(
container,
targetAspectRatio
)
if (width <= 0 || height <= 0) return null
return toNdc(
normalize(normalizedX * container.width, offsetX, offsetX + width),
normalize(normalizedY * container.height, offsetY, offsetY + height)
)
}
export type Load3dActivityFlags = {
mouseOnNode: boolean
mouseOnScene: boolean

View File

@@ -18,7 +18,6 @@ import {
SUBGRAPH_OUTPUT_ID
} from '@/lib/litegraph/src/constants'
import type { SerializedNodeId } from '@/types/nodeId'
import { toNodeId } from '@/types/nodeId'
import {
LGraph,
LGraphNode,
@@ -87,23 +86,6 @@ interface TestSubgraphNodeOptions {
size?: [number, number]
}
interface BoundaryLinkedSubgraphOptions {
rootGraph?: LGraph
hostId?: SerializedNodeId
interiorId?: SerializedNodeId
boundaryName?: string
inputName?: string
hostTitle?: string
interiorType?: string
}
export interface BoundaryLinkedSubgraphFixture {
rootGraph: LGraph
subgraph: Subgraph
host: SubgraphNode
interior: LGraphNode
}
interface NestedSubgraphOptions {
depth?: number
nodesPerLevel?: number
@@ -287,32 +269,6 @@ export function createTestSubgraphNode(
return new SubgraphNode(parentGraph, subgraph, instanceData)
}
export function createBoundaryLinkedSubgraph({
rootGraph = createTestRootGraph(),
hostId = 12,
interiorId = 5,
boundaryName = 'seed',
inputName = 'seed_input',
hostTitle = 'Host Subgraph',
interiorType = 'InteriorNode'
}: BoundaryLinkedSubgraphOptions = {}): BoundaryLinkedSubgraphFixture {
const subgraph = createTestSubgraph({
rootGraph,
inputs: [{ name: boundaryName, type: '*' }]
})
const host = createTestSubgraphNode(subgraph, { id: hostId })
host.title = hostTitle
rootGraph.add(host)
const interior = new LGraphNode(interiorType)
interior.id = toNodeId(interiorId)
const input = interior.addInput(inputName, '*')
subgraph.add(interior)
subgraph.inputNode.slots[0].connect(input, interior)
return { rootGraph, subgraph, host, interior }
}
export function setupComplexPromotionFixture(): {
graph: LGraph
subgraph: Subgraph

View File

@@ -1559,7 +1559,6 @@
"3DViewer": "3DViewer",
"Canvas Navigation": "Canvas Navigation",
"PlanCredits": "Plan & Credits",
"Members": "Members",
"Vue Nodes": "Nodes 2.0",
"VueNodes": "Nodes 2.0",
"Nodes 2_0": "Nodes 2.0",
@@ -2882,37 +2881,9 @@
"updatePassword": "Update Password"
},
"workspacePanel": {
"activity": {
"columns": {
"creditsUsed": "Credits",
"date": "Date",
"eventDetails": "Event details",
"eventType": "Event type",
"user": "User"
},
"empty": "No activity yet.",
"eventType": {
"cloudRun": "Cloud workflow run",
"partnerNode": "Partner node usage"
},
"fullActivity": "Full activity",
"hoverCard": {
"lastActivity": "Last activity",
"partnerNodeUsed": "Partner node used",
"totalCreditsUsed": "Total credits used"
},
"perUserHint": "Looking for total usage per user?",
"seeMembers": "See Members"
},
"invite": "Invite",
"inviteMember": "Invite member",
"inviteLimitReached": "You've reached the maximum of {count} members",
"planCredits": {
"tabs": {
"activity": "Activity",
"overview": "Credits"
}
},
"tabs": {
"dashboard": "Dashboard",
"planCredits": "Plan & Credits",
@@ -2922,12 +2893,6 @@
"placeholder": "Dashboard workspace settings"
},
"members": {
"activity": {
"daysAgo": "{count} day ago | {count} days ago",
"hoursAgo": "{n} hr ago",
"justNow": "just now",
"minutesAgo": "{n} min ago"
},
"header": "Members",
"membersCount": "{count} of {maxSeats} members",
"pendingInvitesCount": "{count} pending invite | {count} pending invites",

View File

@@ -1,10 +1,13 @@
import type { ExecutionErrorWsMessage, PromptError } from '@/schemas/apiSchema'
import type {
ExecutionErrorWsMessage,
NodeError,
PromptError
} from '@/schemas/apiSchema'
import type { MissingMediaGroup } from '@/platform/missingMedia/types'
import type { MissingModelGroup } from '@/platform/missingModel/types'
import type { MissingNodeType } from '@/types/comfy'
import type { NodeValidationError } from '@/utils/executionErrorUtil'
export type { NodeValidationError }
export type NodeValidationError = NodeError['errors'][number]
export interface ResolvedErrorMessage {
catalogId?: string

View File

@@ -11,12 +11,6 @@ import {
translateOptionalCatalogMessage
} from './catalogI18n'
import type { CatalogParams, ErrorResolveContext } from './catalogI18n'
import {
INPUT_LEVEL_VALIDATION_ERROR_TYPES,
NODE_LEVEL_VALIDATION_ERROR_TYPES,
getInputConfigBounds,
isImageNotLoadedValidationError
} from '@/utils/executionErrorUtil'
const REQUIRED_INPUT_MISSING_TYPE = 'required_input_missing'
@@ -68,31 +62,51 @@ const VALUE_SPECIFIC_COPY_RULES: Record<
}
}
const NODE_LEVEL_VALIDATION_ERROR_RULES: Record<string, ValidationCatalogRule> =
Object.fromEntries(
Array.from(NODE_LEVEL_VALIDATION_ERROR_TYPES, (type) => [
type,
{ catalogId: type, itemLabel: 'node' } satisfies ValidationCatalogRule
])
)
const INPUT_LEVEL_VALIDATION_ERROR_RULES: Record<
string,
ValidationCatalogRule
> = Object.fromEntries(
Array.from(INPUT_LEVEL_VALIDATION_ERROR_TYPES, (type) => [
type,
{ catalogId: type, itemLabel: 'nodeInput' } satisfies ValidationCatalogRule
])
)
const VALIDATION_ERROR_RULES: Record<string, ValidationCatalogRule> = {
...INPUT_LEVEL_VALIDATION_ERROR_RULES,
[REQUIRED_INPUT_MISSING_TYPE]: {
catalogId: MISSING_CONNECTION_CATALOG_ID,
itemLabel: 'nodeInput'
},
...NODE_LEVEL_VALIDATION_ERROR_RULES
bad_linked_input: {
catalogId: 'bad_linked_input',
itemLabel: 'nodeInput'
},
return_type_mismatch: {
catalogId: 'return_type_mismatch',
itemLabel: 'nodeInput'
},
invalid_input_type: {
catalogId: 'invalid_input_type',
itemLabel: 'nodeInput'
},
value_smaller_than_min: {
catalogId: 'value_smaller_than_min',
itemLabel: 'nodeInput'
},
value_bigger_than_max: {
catalogId: 'value_bigger_than_max',
itemLabel: 'nodeInput'
},
value_not_in_list: {
catalogId: 'value_not_in_list',
itemLabel: 'nodeInput'
},
custom_validation_failed: {
catalogId: 'custom_validation_failed',
itemLabel: 'nodeInput'
},
exception_during_inner_validation: {
catalogId: 'exception_during_inner_validation',
itemLabel: 'nodeInput'
},
exception_during_validation: {
catalogId: 'exception_during_validation',
itemLabel: 'node'
},
dependency_cycle: {
catalogId: 'dependency_cycle',
itemLabel: 'node'
}
}
// Image-not-loaded shares the custom_validation_failed type, so type-keyed
@@ -117,6 +131,26 @@ function getInputName(error: NodeValidationError): string {
)
}
function getErrorText(error: NodeValidationError) {
return [
'message' in error ? error.message : undefined,
'details' in error ? error.details : undefined
]
.filter(Boolean)
.join('\n')
}
function isImageNotLoadedText(text: string): boolean {
return /invalid image file|\[errno 21\].*is a directory/i.test(text)
}
function isImageNotLoadedValidationError(error: NodeValidationError): boolean {
return (
error.type === 'custom_validation_failed' &&
isImageNotLoadedText(getErrorText(error))
)
}
function nodeInputItemLabel(nodeName: string, inputName: string): string {
return `${nodeName} - ${inputName}`
}
@@ -145,7 +179,13 @@ function getInputConfigValue(
error: NodeValidationError,
key: 'min' | 'max'
): string | undefined {
return formatCatalogValue(getInputConfigBounds(error)[key])
const inputConfig = error.extra_info?.input_config
if (!Array.isArray(inputConfig)) return undefined
const config = inputConfig[1]
if (!config || typeof config !== 'object') return undefined
return formatCatalogValue((config as Record<string, unknown>)[key])
}
function getInputConfigType(error: NodeValidationError): string | undefined {

View File

@@ -95,7 +95,6 @@ import ColorPaletteMessage from '@/platform/settings/components/ColorPaletteMess
import SettingsPanel from '@/platform/settings/components/SettingsPanel.vue'
import { useSettingSearch } from '@/platform/settings/composables/useSettingSearch'
import { useSettingUI } from '@/platform/settings/composables/useSettingUI'
import { useSettingsNavigation } from '@/platform/settings/composables/useSettingsNavigation'
import { useSearchQueryTracking } from '@/platform/telemetry/searchQuery/useSearchQueryTracking'
import type { SettingTreeNode } from '@/platform/settings/settingStore'
import type {
@@ -136,14 +135,6 @@ const { fetchBalance } = useBillingContext()
const navRef = ref<HTMLElement | null>(null)
const activeCategoryKey = ref<string | null>(defaultCategory.value?.key ?? null)
// Let a panel deep-link into a sibling panel (e.g. the Activity tab → Members).
const { requestedPanelKey } = useSettingsNavigation()
watch(requestedPanelKey, (key) => {
if (!key) return
activeCategoryKey.value = key
requestedPanelKey.value = null
})
const searchableNavItems = computed(() =>
navGroups.value.flatMap((g) =>
g.items.map((item) => ({

View File

@@ -27,14 +27,13 @@ const CATEGORY_ICONS: Record<string, string> = {
keybinding: 'icon-[lucide--keyboard]',
LiteGraph: 'icon-[lucide--workflow]',
'Mask Editor': 'icon-[lucide--pen-tool]',
Members: 'icon-[lucide--users]',
Other: 'icon-[lucide--ellipsis]',
PlanCredits: 'icon-[lucide--receipt-text]',
PlanCredits: 'icon-[lucide--credit-card]',
secrets: 'icon-[lucide--key-round]',
'server-config': 'icon-[lucide--server]',
subscription: 'icon-[lucide--credit-card]',
user: 'icon-[lucide--user]',
workspace: 'icon-[lucide--receipt-text]'
workspace: 'icon-[lucide--building-2]'
}
interface SettingPanelItem {
@@ -176,30 +175,16 @@ export function useSettingUI(
)
}
// Workspace panels: only available on cloud with team workspaces enabled.
// The old single "Workspace" panel is split into two sidebar entries; the
// Plan & Credits entry keeps the 'workspace' key so existing deep links land.
const planCreditsPanel: SettingPanelItem = {
// Workspace panel: only available on cloud with team workspaces enabled
const workspacePanel: SettingPanelItem = {
node: {
key: 'workspace',
label: 'PlanCredits',
label: 'Workspace',
children: []
},
component: defineAsyncComponent(
() =>
import('@/platform/workspace/components/dialogs/settings/PlanCreditsPanelContent.vue')
)
}
const membersPanel: SettingPanelItem = {
node: {
key: 'workspace-members',
label: 'Members',
children: []
},
component: defineAsyncComponent(
() =>
import('@/platform/workspace/components/dialogs/settings/WorkspaceMembersPanelContent.vue')
import('@/platform/workspace/components/dialogs/settings/WorkspacePanelContent.vue')
)
}
@@ -260,9 +245,7 @@ export function useSettingUI(
aboutPanel,
creditsPanel,
userPanel,
...(shouldShowWorkspacePanel.value
? [planCreditsPanel, membersPanel]
: []),
...(shouldShowWorkspacePanel.value ? [workspacePanel] : []),
keybindingPanel,
extensionPanel,
...(isDesktop ? [serverConfigPanel] : []),
@@ -312,13 +295,8 @@ export function useSettingUI(
key: 'workspace',
label: 'Workspace',
children: [
...(shouldShowWorkspacePanel.value
? [planCreditsPanel.node, membersPanel.node]
: []),
// The legacy per-account Credits panel is redundant once the workspace
// Plan & Credits panel is present, which now owns the credit balance.
...(!shouldShowWorkspacePanel.value &&
isLoggedIn.value &&
...(shouldShowWorkspacePanel.value ? [workspacePanel.node] : []),
...(isLoggedIn.value &&
!(isCloud && window.__CONFIG__?.subscription_required)
? [creditsPanel.node]
: [])

View File

@@ -1,13 +0,0 @@
import { ref } from 'vue'
// A one-shot request to switch the open Settings dialog to another panel, so a
// panel's content can deep-link into a sibling panel (e.g. Activity → Members).
const requestedPanelKey = ref<string | null>(null)
export function useSettingsNavigation() {
function navigateToPanel(key: string) {
requestedPanelKey.value = key
}
return { requestedPanelKey, navigateToPanel }
}

View File

@@ -1,6 +1,7 @@
<!-- A popover that shows current user information and actions -->
<template>
<div
data-testid="current-user-popover"
class="current-user-popover -m-3 w-fit max-w-96 min-w-80 rounded-lg border border-border-default bg-base-background p-2 shadow-[1px_1px_8px_0_rgba(0,0,0,0.4)]"
>
<!-- User Info Section -->

View File

@@ -1,69 +0,0 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import { reactive } from 'vue'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json'
import PlanCreditsPanelContent from './PlanCreditsPanelContent.vue'
vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({
useTeamWorkspaceStore: () => reactive({ workspaceName: 'Acme Team' })
}))
const stubs = {
WorkspaceProfilePic: { template: '<div />' },
SubscriptionPanelContentWorkspace: {
template: '<div data-testid="credits-body" />'
},
WorkspaceActivityContent: {
props: ['search'],
template: '<div data-testid="activity-body">{{ search }}</div>'
}
}
function renderPanel() {
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
return render(PlanCreditsPanelContent, { global: { plugins: [i18n], stubs } })
}
describe('PlanCreditsPanelContent', () => {
it('shows Credits and Activity tabs with Credits active by default', () => {
renderPanel()
expect(screen.getByRole('button', { name: 'Credits' })).toBeTruthy()
expect(screen.getByRole('button', { name: 'Activity' })).toBeTruthy()
expect(screen.getByTestId('credits-body')).toBeTruthy()
expect(screen.queryByTestId('activity-body')).toBeNull()
})
it('shows the search box only on the Activity tab', async () => {
renderPanel()
expect(screen.queryByPlaceholderText('Search')).toBeNull()
await userEvent.click(screen.getByRole('button', { name: 'Activity' }))
expect(screen.getByTestId('activity-body')).toBeTruthy()
expect(screen.queryByTestId('credits-body')).toBeNull()
expect(screen.getByPlaceholderText('Search')).toBeTruthy()
})
it('passes the search query to the Activity tab and clears it on tab change', async () => {
renderPanel()
await userEvent.click(screen.getByRole('button', { name: 'Activity' }))
await userEvent.type(screen.getByPlaceholderText('Search'), 'flux')
expect(screen.getByTestId('activity-body').textContent).toContain('flux')
await userEvent.click(screen.getByRole('button', { name: 'Credits' }))
await userEvent.click(screen.getByRole('button', { name: 'Activity' }))
expect(screen.getByTestId('activity-body').textContent).not.toContain(
'flux'
)
expect(
(screen.getByPlaceholderText('Search') as HTMLInputElement).value
).toBe('')
})
})

View File

@@ -1,81 +0,0 @@
<template>
<div class="@container flex size-full min-h-0 flex-col">
<header class="mb-6 flex items-center gap-4">
<WorkspaceProfilePic
class="size-12 text-3xl!"
:workspace-name="workspaceName"
/>
<h1 class="text-3xl font-semibold text-base-foreground">
{{ workspaceName }}
</h1>
</header>
<div
class="mb-4 flex w-full flex-col gap-3 @2xl:flex-row @2xl:items-center @2xl:gap-9"
>
<div class="flex min-w-0 flex-1 items-center gap-2">
<Button
v-for="tab in tabs"
:key="tab.key"
:variant="activeView === tab.key ? 'secondary' : 'muted-textonly'"
size="lg"
@click="setView(tab.key)"
>
{{ tab.label }}
</Button>
</div>
<SearchInput
v-if="activeView === 'activity'"
v-model="searchQuery"
:placeholder="$t('g.search')"
size="lg"
class="w-full @2xl:w-64"
/>
</div>
<SubscriptionPanelContentWorkspace v-if="activeView === 'overview'" />
<WorkspaceActivityContent
v-else
:search="searchQuery"
:events="activityEvents"
/>
</div>
</template>
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
import SubscriptionPanelContentWorkspace from '@/platform/workspace/components/SubscriptionPanelContentWorkspace.vue'
import WorkspaceProfilePic from '@/platform/workspace/components/WorkspaceProfilePic.vue'
import WorkspaceActivityContent from '@/platform/workspace/components/dialogs/settings/WorkspaceActivityContent.vue'
import { useWorkspaceActivitySource } from '@/platform/workspace/composables/useWorkspaceActivitySource'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
type View = 'overview' | 'activity'
const { t } = useI18n()
const workspaceStore = useTeamWorkspaceStore()
const { workspaceName } = storeToRefs(workspaceStore)
const { events: activityEvents } = useWorkspaceActivitySource()
// The Invoices tab (owner/admin only) is added by FE-1245, which owns the
// next-invoice banner + Stripe portal link that fill it.
const tabs = computed<{ key: View; label: string }[]>(() => [
{ key: 'overview', label: t('workspacePanel.planCredits.tabs.overview') },
{ key: 'activity', label: t('workspacePanel.planCredits.tabs.activity') }
])
const activeView = ref<View>('overview')
const searchQuery = ref('')
function setView(view: View) {
activeView.value = view
searchQuery.value = ''
}
</script>

View File

@@ -1,37 +0,0 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { activityFixture } from '@/platform/workspace/fixtures/activityFixtures'
import WorkspaceActivityContent from './WorkspaceActivityContent.vue'
// The Activity tab renders inside a flex, height-constrained dialog panel and
// auto-sizes its page to the available height; the decorator reproduces that
// container (and the @container query context the footer/tabs rely on).
const meta: Meta<typeof WorkspaceActivityContent> = {
title: 'Platform/Workspace/WorkspaceActivityContent',
component: WorkspaceActivityContent,
tags: ['autodocs'],
parameters: { layout: 'fullscreen' },
decorators: [
() => ({
template:
'<div class="@container flex h-[520px] flex-col p-6"><story /></div>'
})
],
args: { search: '' }
}
export default meta
type Story = StoryObj<typeof meta>
export const Populated: Story = {
args: { events: activityFixture }
}
export const Empty: Story = {
args: { events: [] }
}
export const Searched: Story = {
args: { events: activityFixture, search: 'partner' }
}

View File

@@ -1,122 +0,0 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json'
import type { ActivityEvent } from '@/platform/workspace/composables/useWorkspaceActivity'
import WorkspaceActivityContent from './WorkspaceActivityContent.vue'
const {
mockCanManageSubscription,
mockNavigateToPanel,
mockRequestMembersSort
} = vi.hoisted(() => {
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/consistent-type-imports
const { ref } = require('vue') as typeof import('vue')
return {
mockCanManageSubscription: ref(true),
mockNavigateToPanel: vi.fn(),
mockRequestMembersSort: vi.fn()
}
})
vi.mock('@/platform/workspace/composables/useWorkspaceUI', () => ({
useWorkspaceUI: () => ({
permissions: {
get value() {
return { canManageSubscription: mockCanManageSubscription.value }
}
}
})
}))
vi.mock('@/composables/auth/useCurrentUser', () => ({
useCurrentUser: () => ({
userDisplayName: { value: 'Ada Lovelace' },
userEmail: { value: 'ada@example.com' }
})
}))
vi.mock('@/platform/settings/composables/useSettingsNavigation', () => ({
useSettingsNavigation: () => ({ navigateToPanel: mockNavigateToPanel })
}))
vi.mock('@/platform/workspace/composables/useMembersPanel', () => ({
requestMembersSort: mockRequestMembersSort
}))
vi.mock('@/config/comfyApi', () => ({
getComfyPlatformBaseUrl: () => 'https://platform.test'
}))
class NoopResizeObserver implements ResizeObserver {
observe = vi.fn()
unobserve = vi.fn()
disconnect = vi.fn()
}
function renderContent(events: ActivityEvent[] = []) {
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
return render(WorkspaceActivityContent, {
props: { search: '', events },
global: { plugins: [i18n] }
})
}
const creditedRow: ActivityEvent = {
id: 'inflow',
date: new Date('2026-07-14T12:00:00Z'),
userName: '',
eventType: 'Auto-reload',
detail: '—',
credits: 20000,
credited: true
}
describe('WorkspaceActivityContent', () => {
beforeEach(() => {
globalThis.ResizeObserver = NoopResizeObserver
mockCanManageSubscription.value = true
mockNavigateToPanel.mockClear()
mockRequestMembersSort.mockClear()
})
it('renders the empty state when there is no activity', () => {
renderContent([])
expect(screen.getByText('No activity yet.')).toBeTruthy()
})
it('shows the per-user footer actions to a billing manager', () => {
renderContent([])
const link = screen.getByRole('link', { name: /full activity/i })
expect(link.getAttribute('href')).toBe(
'https://platform.test/profile/usage'
)
expect(screen.getByRole('button', { name: /see members/i })).toBeTruthy()
})
it('hides the per-user footer from members', () => {
mockCanManageSubscription.value = false
renderContent([])
expect(screen.queryByRole('link', { name: /full activity/i })).toBeNull()
expect(screen.queryByRole('button', { name: /see members/i })).toBeNull()
})
it('deep-links to the Members panel pre-sorted by spend', async () => {
renderContent([])
await userEvent.click(screen.getByRole('button', { name: /see members/i }))
expect(mockRequestMembersSort).toHaveBeenCalledWith('credits')
expect(mockNavigateToPanel).toHaveBeenCalledWith('workspace-members')
})
it('marks a credit inflow with a leading plus', () => {
renderContent([creditedRow])
expect(screen.getByText(/^\+20,000$/)).toBeTruthy()
})
})

View File

@@ -1,328 +0,0 @@
<template>
<div class="flex min-h-0 flex-1 flex-col gap-4">
<div
ref="tableContainer"
class="flex min-h-0 flex-1 flex-col overflow-hidden rounded-2xl border border-interface-stroke/60"
>
<Table class="min-h-0 flex-1 px-4">
<TableHeader class="sticky top-0 z-10 bg-base-background">
<TableRow
class="hover:bg-transparent [&>th]:h-14 [&>th]:border-b [&>th]:border-interface-stroke/60"
>
<TableHead class="w-40" :aria-sort="ariaSort('date')">
<button :class="sortHeaderClass" @click="toggleSort('date')">
{{ $t('workspacePanel.activity.columns.date') }}
<i :class="sortIcon('date')" />
</button>
</TableHead>
<TableHead class="w-56" :aria-sort="ariaSort('user')">
<button :class="sortHeaderClass" @click="toggleSort('user')">
{{ $t('workspacePanel.activity.columns.user') }}
<i :class="sortIcon('user')" />
</button>
</TableHead>
<TableHead :aria-sort="ariaSort('eventType')">
<button :class="sortHeaderClass" @click="toggleSort('eventType')">
{{ $t('workspacePanel.activity.columns.eventType') }}
<i :class="sortIcon('eventType')" />
</button>
</TableHead>
<TableHead class="w-32" :aria-sort="ariaSort('detail')">
<button :class="sortHeaderClass" @click="toggleSort('detail')">
{{ $t('workspacePanel.activity.columns.eventDetails') }}
<i :class="sortIcon('detail')" />
</button>
</TableHead>
<TableHead class="w-40" :aria-sort="ariaSort('credits')">
<button
:class="cn(sortHeaderClass, 'ml-auto')"
@click="toggleSort('credits')"
>
<i class="icon-[lucide--coins] size-4" />
{{ $t('workspacePanel.activity.columns.creditsUsed') }}
<i :class="sortIcon('credits')" />
</button>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow
v-for="event in pagedItems"
:key="event.id"
class="hover:bg-transparent [&:last-child>td]:border-b-0 [&>td]:border-b [&>td]:border-interface-stroke/20"
>
<TableCell class="text-sm text-muted-foreground tabular-nums">
{{ formatDate(event.date) }}
</TableCell>
<TableCell>
<HoverCard
v-if="event.userName"
:open-delay="150"
:close-delay="0"
>
<HoverCardTrigger
as="div"
class="flex w-fit cursor-default items-center gap-3"
>
<span
class="flex size-5 shrink-0 items-center justify-center rounded-full"
:style="{ backgroundColor: userBadgeColor(event.userName) }"
>
<span class="text-2xs font-bold text-base-foreground">
{{ event.userName.charAt(0).toUpperCase() }}
</span>
</span>
<span class="truncate text-sm text-base-foreground">
{{ event.userName }}
</span>
</HoverCardTrigger>
<HoverCardContent class="w-64" align="start">
<div class="flex w-full flex-col gap-2">
<div class="flex h-5 items-center justify-between">
<span class="text-sm text-muted-foreground">
{{
$t(
'workspacePanel.activity.hoverCard.totalCreditsUsed'
)
}}
</span>
<span class="flex items-center gap-1">
<i
class="icon-[lucide--coins] size-4 text-muted-foreground"
/>
<span class="text-sm text-base-foreground tabular-nums">
{{
summaryFor(
event.userName
).totalCredits.toLocaleString()
}}
</span>
</span>
</div>
<div class="flex h-5 items-center justify-between">
<span class="text-sm text-muted-foreground">
{{
$t('workspacePanel.activity.hoverCard.lastActivity')
}}
</span>
<span class="text-sm text-base-foreground">
{{ lastActivityLabel(event.userName) }}
</span>
</div>
</div>
</HoverCardContent>
</HoverCard>
<span v-else class="text-sm text-muted-foreground"></span>
</TableCell>
<TableCell class="text-sm text-muted-foreground">
<HoverCard
v-if="event.partnerNode"
:open-delay="150"
:close-delay="0"
>
<HoverCardTrigger as="span" class="cursor-default">
{{ event.eventType }}
</HoverCardTrigger>
<HoverCardContent class="w-72" align="start">
<div class="flex h-5 items-center justify-between gap-4">
<span
class="text-sm whitespace-nowrap text-muted-foreground"
>
{{
$t('workspacePanel.activity.hoverCard.partnerNodeUsed')
}}
</span>
<span class="truncate text-sm text-base-foreground">
{{ event.partnerNode }}
</span>
</div>
</HoverCardContent>
</HoverCard>
<template v-else>{{ event.eventType }}</template>
</TableCell>
<TableCell class="text-sm text-muted-foreground tabular-nums">
{{ event.detail || '—' }}
</TableCell>
<TableCell
:class="
cn(
'text-right text-sm tabular-nums',
event.credited ? 'text-credit' : 'text-muted-foreground'
)
"
>
{{ event.credited ? '+' : ''
}}{{ event.credits.toLocaleString() }}
</TableCell>
</TableRow>
<TableRow v-if="pagedItems.length === 0" class="hover:bg-transparent">
<TableCell
:colspan="5"
class="py-6 text-center text-sm text-muted-foreground"
>
{{ $t('workspacePanel.activity.empty') }}
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
<div class="flex flex-col gap-3 @2xl:h-8 @2xl:flex-row @2xl:items-center">
<div
v-if="canViewTeamUsage"
class="flex flex-wrap items-center gap-x-6 gap-y-2"
>
<a
:href="fullActivityUrl"
target="_blank"
rel="noopener noreferrer"
class="flex cursor-pointer items-center gap-1 text-sm text-muted-foreground no-underline transition-colors hover:text-base-foreground"
>
<i class="icon-[lucide--external-link] size-4" />
{{ $t('workspacePanel.activity.fullActivity') }}
</a>
<div class="flex items-center gap-3">
<p class="text-sm text-muted-foreground">
{{ $t('workspacePanel.activity.perUserHint') }}
</p>
<button
class="flex cursor-pointer items-center gap-1 border-none bg-transparent p-0 font-[inherit] text-sm text-base-foreground transition-colors hover:text-muted-foreground"
@click="goToMembers"
>
{{ $t('workspacePanel.activity.seeMembers') }}
<i class="icon-[lucide--arrow-right] size-4" />
</button>
</div>
</div>
<Pagination
v-model:page="page"
:total="total"
:items-per-page="itemsPerPage"
class="@2xl:ml-auto"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import HoverCard from '@/components/ui/hover-card/HoverCard.vue'
import HoverCardContent from '@/components/ui/hover-card/HoverCardContent.vue'
import HoverCardTrigger from '@/components/ui/hover-card/HoverCardTrigger.vue'
import Pagination from '@/components/ui/pagination/Pagination.vue'
import Table from '@/components/ui/table/Table.vue'
import TableBody from '@/components/ui/table/TableBody.vue'
import TableCell from '@/components/ui/table/TableCell.vue'
import TableHead from '@/components/ui/table/TableHead.vue'
import TableHeader from '@/components/ui/table/TableHeader.vue'
import TableRow from '@/components/ui/table/TableRow.vue'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { getComfyPlatformBaseUrl } from '@/config/comfyApi'
import { useSettingsNavigation } from '@/platform/settings/composables/useSettingsNavigation'
import { useAutoPageSize } from '@/platform/workspace/composables/useAutoPageSize'
import { requestMembersSort } from '@/platform/workspace/composables/useMembersPanel'
import { useWorkspaceActivity } from '@/platform/workspace/composables/useWorkspaceActivity'
import type {
ActivityEvent,
ActivitySortField
} from '@/platform/workspace/composables/useWorkspaceActivity'
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
import { userBadgeColor } from '@/platform/workspace/utils/badgeColor'
import { formatRelativeTime } from '@/platform/workspace/utils/relativeTime'
import { cn } from '@comfyorg/tailwind-utils'
// `events` is the data seam: the tab shell mounts this with no events (empty
// state) until FE-1249 wires the per-workspace usage API and passes them in.
const { search, events = [] } = defineProps<{
search: string
events?: ActivityEvent[]
}>()
const { t, d } = useI18n()
const tableContainer = ref<HTMLElement | null>(null)
const { pageSize } = useAutoPageSize(tableContainer, 1)
// Owners/admins see team-wide activity + the per-user footer; members see only
// their own usage, scoped to their name.
const { permissions } = useWorkspaceUI()
const { userDisplayName, userEmail } = useCurrentUser()
const canViewTeamUsage = computed(() => permissions.value.canManageSubscription)
const selfName = computed(() =>
canViewTeamUsage.value
? null
: userDisplayName.value || userEmail.value || t('g.you')
)
const fullActivityUrl = `${getComfyPlatformBaseUrl()}/profile/usage`
const { navigateToPanel } = useSettingsNavigation()
function goToMembers() {
requestMembersSort('credits')
navigateToPanel('workspace-members')
}
const {
page,
total,
itemsPerPage,
pagedItems,
sortField,
sortDirection,
toggleSort,
userSummaries
} = useWorkspaceActivity(
() => search,
pageSize,
selfName,
() => events
)
function summaryFor(userName: string) {
return (
userSummaries.value.get(userName) ?? {
totalCredits: 0,
lastActivity: new Date()
}
)
}
function lastActivityLabel(userName: string): string {
return formatRelativeTime(summaryFor(userName).lastActivity, new Date(), {
justNow: t('workspacePanel.members.activity.justNow'),
minutesAgo: (n) => t('workspacePanel.members.activity.minutesAgo', { n }),
hoursAgo: (n) => t('workspacePanel.members.activity.hoursAgo', { n }),
daysAgo: (n) => t('workspacePanel.members.activity.daysAgo', n)
})
}
const sortHeaderClass =
'flex cursor-pointer items-center gap-1 border-none bg-transparent p-0 text-left font-[inherit] text-sm text-muted-foreground'
function sortIcon(field: ActivitySortField) {
if (sortField.value !== field) return 'icon-[lucide--chevrons-up-down] size-3'
return sortDirection.value === 'asc'
? 'icon-[lucide--chevron-up] size-3'
: 'icon-[lucide--chevron-down] size-3'
}
function ariaSort(
field: ActivitySortField
): 'ascending' | 'descending' | 'none' {
if (sortField.value !== field) return 'none'
return sortDirection.value === 'asc' ? 'ascending' : 'descending'
}
function formatDate(date: Date): string {
return d(date, {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
hour12: true
})
}
</script>

View File

@@ -1,36 +0,0 @@
import { render } from '@testing-library/vue'
import { describe, expect, it, vi } from 'vitest'
import { reactive, ref } from 'vue'
import WorkspaceMembersPanelContent from './WorkspaceMembersPanelContent.vue'
const { mockFetchMembers, mockFetchPendingInvites } = vi.hoisted(() => ({
mockFetchMembers: vi.fn(),
mockFetchPendingInvites: vi.fn()
}))
vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({
useTeamWorkspaceStore: () =>
reactive({
workspaceName: 'Acme Team',
fetchMembers: mockFetchMembers,
fetchPendingInvites: mockFetchPendingInvites
})
}))
vi.mock('@/platform/workspace/composables/useWorkspaceUI', () => ({
useWorkspaceUI: () => ({ workspaceRole: ref('owner') })
}))
const stubs = {
WorkspaceProfilePic: { template: '<div />' },
MembersPanelContent: { template: '<div data-testid="members-body" />' }
}
describe('WorkspaceMembersPanelContent', () => {
it('fetches members and pending invites on mount', () => {
render(WorkspaceMembersPanelContent, { global: { stubs } })
expect(mockFetchMembers).toHaveBeenCalled()
expect(mockFetchPendingInvites).toHaveBeenCalled()
})
})

View File

@@ -1,34 +0,0 @@
<template>
<div class="flex size-full flex-col">
<header class="mb-6 flex items-center gap-4">
<WorkspaceProfilePic
class="size-12 text-3xl!"
:workspace-name="workspaceName"
/>
<h1 class="text-3xl font-semibold text-base-foreground">
{{ workspaceName }}
</h1>
</header>
<MembersPanelContent :key="workspaceRole" />
</div>
</template>
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { onMounted } from 'vue'
import WorkspaceProfilePic from '@/platform/workspace/components/WorkspaceProfilePic.vue'
import MembersPanelContent from '@/platform/workspace/components/dialogs/settings/MembersPanelContent.vue'
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
const workspaceStore = useTeamWorkspaceStore()
const { workspaceName } = storeToRefs(workspaceStore)
const { fetchMembers, fetchPendingInvites } = workspaceStore
const { workspaceRole } = useWorkspaceUI()
onMounted(() => {
fetchMembers()
fetchPendingInvites()
})
</script>

View File

@@ -0,0 +1,129 @@
import { render, screen } from '@testing-library/vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import type { WorkspaceMember } from '@/platform/workspace/stores/teamWorkspaceStore'
import WorkspacePanelContent from './WorkspacePanelContent.vue'
const mockFetchMembers = vi.fn()
const mockFetchPendingInvites = vi.fn()
const { mockMembers, mockWorkspaceType } = vi.hoisted(() => {
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/consistent-type-imports
const { ref } = require('vue') as typeof import('vue')
return {
mockMembers: ref<WorkspaceMember[]>([]),
mockWorkspaceType: ref<'personal' | 'team'>('team')
}
})
vi.mock('pinia', async (importOriginal) => {
const actual = await importOriginal()
return {
...(actual as object),
storeToRefs: (store: Record<string, unknown>) => store
}
})
vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => {
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/consistent-type-imports
const { ref } = require('vue') as typeof import('vue')
return {
useTeamWorkspaceStore: () => ({
workspaceName: ref('Acme Team'),
members: mockMembers,
fetchMembers: mockFetchMembers,
fetchPendingInvites: mockFetchPendingInvites
})
}
})
vi.mock('@/platform/workspace/composables/useWorkspaceUI', () => {
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/consistent-type-imports
const { ref } = require('vue') as typeof import('vue')
return {
useWorkspaceUI: () => ({
workspaceType: mockWorkspaceType,
workspaceRole: ref('owner')
})
}
})
vi.mock(
'@/platform/workspace/components/SubscriptionPanelContentWorkspace.vue',
() => ({
default: { name: 'SubscriptionPanelContentWorkspace', template: '<div />' }
})
)
vi.mock(
'@/platform/workspace/components/dialogs/settings/MembersPanelContent.vue',
() => ({
default: { name: 'MembersPanelContent', template: '<div />' }
})
)
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: {} },
missingWarn: false,
fallbackWarn: false
})
function createMember(id: string): WorkspaceMember {
return {
id,
name: `Member ${id}`,
email: `member${id}@example.com`,
joinDate: new Date('2025-01-15'),
role: 'member',
isOriginalOwner: false
}
}
function renderComponent() {
return render(WorkspacePanelContent, {
global: {
plugins: [i18n],
stubs: { WorkspaceProfilePic: true }
}
})
}
describe('WorkspacePanelContent members tab label', () => {
beforeEach(() => {
vi.clearAllMocks()
mockMembers.value = []
mockWorkspaceType.value = 'team'
})
it('shows the counted label for team workspaces with multiple members', () => {
mockMembers.value = [createMember('1'), createMember('2')]
renderComponent()
expect(screen.getByText(/workspacePanel\.tabs\.membersCount/)).toBeTruthy()
})
it('drops the count when the owner is the only member', () => {
mockMembers.value = [createMember('1')]
renderComponent()
expect(screen.getByText('workspacePanel.members.header')).toBeTruthy()
expect(screen.queryByText(/workspacePanel\.tabs\.membersCount/)).toBeNull()
})
it('shows the plain Members label for personal workspaces', () => {
mockWorkspaceType.value = 'personal'
mockMembers.value = [createMember('1'), createMember('2')]
renderComponent()
expect(screen.getByText('workspacePanel.members.header')).toBeTruthy()
expect(screen.queryByText(/workspacePanel\.tabs\.membersCount/)).toBeNull()
})
it('fetches members and pending invites on mount', () => {
renderComponent()
expect(mockFetchMembers).toHaveBeenCalled()
expect(mockFetchPendingInvites).toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,95 @@
<template>
<div class="flex size-full flex-col">
<header class="mb-6 flex items-center gap-4">
<WorkspaceProfilePic
class="size-12 text-3xl!"
:workspace-name="workspaceName"
/>
<h1 class="text-3xl font-semibold text-base-foreground">
{{ workspaceName }}
</h1>
</header>
<TabsRoot v-model="activeTab">
<TabsList class="flex items-center gap-2 pb-1">
<TabsTrigger
value="plan"
:class="
cn(
tabTriggerBase,
activeTab === 'plan' ? tabTriggerActive : tabTriggerInactive
)
"
>
{{ $t('workspacePanel.tabs.planCredits') }}
</TabsTrigger>
<TabsTrigger
value="members"
:class="
cn(
tabTriggerBase,
activeTab === 'members' ? tabTriggerActive : tabTriggerInactive
)
"
>
{{
showMembersTabCount
? $t('workspacePanel.tabs.membersCount', {
count: members.length
})
: $t('workspacePanel.members.header')
}}
</TabsTrigger>
</TabsList>
<TabsContent value="plan" class="mt-4">
<SubscriptionPanelContentWorkspace />
</TabsContent>
<TabsContent value="members" class="mt-4">
<MembersPanelContent :key="workspaceRole" />
</TabsContent>
</TabsRoot>
</div>
</template>
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { computed, onMounted, ref } from 'vue'
import { TabsContent, TabsList, TabsRoot, TabsTrigger } from 'reka-ui'
import WorkspaceProfilePic from '@/platform/workspace/components/WorkspaceProfilePic.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'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import { cn } from '@comfyorg/tailwind-utils'
const tabTriggerBase =
'flex items-center justify-center shrink-0 px-2.5 py-2 text-sm rounded-lg cursor-pointer transition-all duration-200 outline-hidden border-none'
const tabTriggerActive =
'bg-interface-menu-component-surface-hovered text-text-primary font-bold'
const tabTriggerInactive =
'bg-transparent text-text-secondary hover:bg-button-hover-surface focus:bg-button-hover-surface'
const { defaultTab = 'plan' } = defineProps<{
defaultTab?: string
}>()
const workspaceStore = useTeamWorkspaceStore()
const { workspaceName, members } = storeToRefs(workspaceStore)
const { fetchMembers, fetchPendingInvites } = workspaceStore
const { workspaceType, workspaceRole } = useWorkspaceUI()
const isPersonalWorkspace = computed(() => workspaceType.value === 'personal')
const activeTab = ref(defaultTab)
// Per design, the tab counts members only when there is more than the owner
const showMembersTabCount = computed(
() => !isPersonalWorkspace.value && members.value.length > 1
)
onMounted(() => {
fetchMembers()
fetchPendingInvites()
})
</script>

View File

@@ -1,151 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { effectScope, ref } from 'vue'
import type { Ref } from 'vue'
import { useAutoPageSize } from './useAutoPageSize'
const resizeObserverState = vi.hoisted(() => {
const state = {
callback: null as ResizeObserverCallback | null,
observe: vi.fn<(element: Element) => void>(),
disconnect: vi.fn<() => void>()
}
const MockResizeObserver: typeof ResizeObserver = class MockResizeObserver implements ResizeObserver {
observe = state.observe
unobserve = vi.fn()
disconnect = state.disconnect
constructor(callback: ResizeObserverCallback) {
state.callback = callback
}
}
globalThis.ResizeObserver = MockResizeObserver
return state
})
interface ContainerShape {
clientHeight: number
rowHeight: number | null
headerHeight: number | null
}
function fakeContainer({
clientHeight,
rowHeight,
headerHeight
}: ContainerShape): HTMLElement {
return {
clientHeight,
querySelector(selector: string) {
if (selector === 'tbody tr') {
return rowHeight === null
? null
: { getBoundingClientRect: () => ({ height: rowHeight }) }
}
if (selector === 'thead') {
return headerHeight === null
? null
: { getBoundingClientRect: () => ({ height: headerHeight }) }
}
if (selector === 'table') return {}
return null
}
} as unknown as HTMLElement
}
function runInScope(container: Ref<HTMLElement | null>, min?: number) {
const scope = effectScope()
const result = scope.run(() => useAutoPageSize(container, min))!
return { ...result, stop: () => scope.stop() }
}
describe('useAutoPageSize', () => {
beforeEach(() => {
resizeObserverState.callback = null
resizeObserverState.observe.mockClear()
resizeObserverState.disconnect.mockClear()
})
it('fits as many whole rows as the container height allows', () => {
const container = ref(
fakeContainer({ clientHeight: 500, rowHeight: 41, headerHeight: 56 })
)
const { pageSize } = runInScope(container, 1)
resizeObserverState.callback!([], {} as ResizeObserver)
// (500 - 56) / 41 = 10.83 -> floored to 10 whole rows
expect(pageSize.value).toBe(10)
})
it('floors a fractional fit so a not-quite-fitting row is left out', () => {
const container = ref(
fakeContainer({ clientHeight: 449, rowHeight: 41, headerHeight: 0 })
)
const { pageSize } = runInScope(container, 1)
resizeObserverState.callback!([], {} as ResizeObserver)
// 449 / 41 = 10.95 -> 10, never 11 (which would overflow + show a scrollbar)
expect(pageSize.value).toBe(10)
})
it('never returns fewer than the requested minimum', () => {
const container = ref(
fakeContainer({ clientHeight: 50, rowHeight: 41, headerHeight: 0 })
)
const { pageSize } = runInScope(container, 5)
resizeObserverState.callback!([], {} as ResizeObserver)
// fit is 1, but min=5 wins
expect(pageSize.value).toBe(5)
})
it('falls back to a default row height before any rows are rendered', () => {
const container = ref(
fakeContainer({ clientHeight: 420, rowHeight: null, headerHeight: 0 })
)
const { pageSize } = runInScope(container, 1)
resizeObserverState.callback!([], {} as ResizeObserver)
// no tbody row yet -> FALLBACK_ROW_HEIGHT 41: floor(420 / 41) = 10
expect(pageSize.value).toBe(10)
})
it('re-measures when the table height changes after rows load', () => {
const container = ref(
fakeContainer({ clientHeight: 420, rowHeight: null, headerHeight: 0 })
)
const { pageSize } = runInScope(container, 1)
resizeObserverState.callback!([], {} as ResizeObserver)
expect(pageSize.value).toBe(10)
// real rows arrive taller than the fallback; the observer fires again
container.value = fakeContainer({
clientHeight: 420,
rowHeight: 60,
headerHeight: 0
})
resizeObserverState.callback!([], {} as ResizeObserver)
// floor(420 / 60) = 7
expect(pageSize.value).toBe(7)
})
it('disconnects the observer when the scope stops', () => {
const container = ref(
fakeContainer({ clientHeight: 500, rowHeight: 41, headerHeight: 0 })
)
const { stop } = runInScope(container, 1)
stop()
expect(resizeObserverState.disconnect).toHaveBeenCalled()
})
})

View File

@@ -1,55 +0,0 @@
import type { Ref } from 'vue'
import { onScopeDispose, ref, watch } from 'vue'
const FALLBACK_ROW_HEIGHT = 41
const MIN_ROWS = 5
/**
* Derive a table's rows-per-page from the live height of its scroll container so
* a taller dialog shows more rows instead of leaving empty space. Row and header
* heights are read from the rendered table, so it adapts if the design changes.
*/
export function useAutoPageSize(
containerRef: Ref<HTMLElement | null>,
min: number = MIN_ROWS
) {
const pageSize = ref(min)
function measure() {
const container = containerRef.value
if (!container) return
// Use fractional (getBoundingClientRect) heights, not the integer
// offsetHeight: truncating each row's height makes the floor below think an
// extra partial row fits, which overflows the container by a few pixels and
// shows a scrollbar. Fractional heights keep a not-quite-fitting row out.
const rowHeight =
container.querySelector<HTMLElement>('tbody tr')?.getBoundingClientRect()
.height || FALLBACK_ROW_HEIGHT
const headerHeight =
container.querySelector<HTMLElement>('thead')?.getBoundingClientRect()
.height ?? 0
const fit = Math.floor((container.clientHeight - headerHeight) / rowHeight)
pageSize.value = Math.max(min, fit)
}
let observer: ResizeObserver | null = null
watch(
containerRef,
(el) => {
observer?.disconnect()
if (!el) return
observer = new ResizeObserver(() => measure())
observer.observe(el)
// Also observe the table itself: async-loaded rows change the table's
// height without resizing the container, so the initial measure (taken
// against an empty state or fallback row height) would otherwise stick.
// Re-measuring converges — pageSize stabilizes once real rows exist.
const table = el.querySelector('table')
if (table) observer.observe(table)
},
{ immediate: true }
)
onScopeDispose(() => observer?.disconnect())
return { pageSize }
}

View File

@@ -23,17 +23,6 @@ type ActiveView = 'active' | 'pending'
type SortField = 'inviteDate' | 'expiryDate' | 'role'
type SortDirection = 'asc' | 'desc'
// One-shot cross-panel request from the Activity tab to open the Members table
// sorted by a per-member column. Those columns ship with member auditing on the
// Members panel (DES-479); until then useMembersPanel clears the request without
// a matching column to apply it to.
type RequestedMembersSort = 'credits' | 'lastActivity'
const requestedMembersSort = ref<RequestedMembersSort | null>(null)
export function requestMembersSort(field: RequestedMembersSort) {
requestedMembersSort.value = field
}
export function sortMembers(
members: WorkspaceMember[],
currentUserEmail: string | null,
@@ -124,9 +113,6 @@ export function useMembersPanel() {
const { isOnTeamPlan, isCancelled, hasLapsedTeamPlan } = useTeamPlan()
const subscriptionDialog = useSubscriptionDialog()
// Consume the Activity tab's cross-panel sort request (see requestMembersSort).
if (requestedMembersSort.value) requestedMembersSort.value = null
// The team plan caps members at a flat MAX_WORKSPACE_MEMBERS, independent of
// the subscription tier.
const maxSeats = computed(() => MAX_WORKSPACE_MEMBERS)

View File

@@ -1,102 +0,0 @@
import { describe, expect, it } from 'vitest'
import { effectScope, nextTick, ref } from 'vue'
import type { Ref } from 'vue'
import { activityFixture } from '@/platform/workspace/fixtures/activityFixtures'
import { useWorkspaceActivity } from './useWorkspaceActivity'
import type { ActivityEvent } from './useWorkspaceActivity'
interface SetupOptions {
search?: Ref<string>
pageSize?: Ref<number>
selfName?: Ref<string | null>
source?: ActivityEvent[]
}
function setup(options: SetupOptions = {}) {
const search = options.search ?? ref('')
const pageSize = options.pageSize ?? ref(100)
const selfName = options.selfName ?? ref<string | null>(null)
const scope = effectScope()
const api = scope.run(() =>
useWorkspaceActivity(
search,
pageSize,
selfName,
options.source ?? activityFixture
)
)!
return { ...api, search, pageSize, selfName, stop: () => scope.stop() }
}
describe('useWorkspaceActivity', () => {
it('shows the whole ledger to an owner (no self scope)', () => {
const { total } = setup()
expect(total.value).toBe(activityFixture.length)
})
it('scopes a member to their own usage rows plus workspace credit inflows', () => {
const { total, pagedItems } = setup({
selfName: ref('Ada Lovelace')
})
// Ada's 4 usage rows + the 2 credited inflows (userName '')
expect(total.value).toBe(6)
const names = new Set(pagedItems.value.map((e) => e.userName))
expect(names).toEqual(new Set(['Ada Lovelace', '']))
})
it('searches user name and event type only, not other columns', () => {
const search = ref('')
const { total } = setup({ search })
search.value = 'ada'
expect(total.value).toBe(4) // the 4 "Ada Lovelace" rows
search.value = 'partner'
expect(total.value).toBe(5) // the 5 "Partner node usage" rows
search.value = '2 runs' // a detail value — not searched
expect(total.value).toBe(0)
})
it('sorts by date descending by default and dispatches other fields', () => {
const { pagedItems, sortField, toggleSort } = setup()
expect(pagedItems.value[0].id).toBe('evt-01') // newest date
toggleSort('credits')
expect(sortField.value).toBe('credits')
expect(pagedItems.value[0].id).toBe('evt-12') // 50000, highest
toggleSort('credits') // flip to ascending
expect(pagedItems.value[0].id).toBe('evt-09') // 760, lowest
})
it('slices to the current page size', () => {
const { pagedItems, total } = setup({ pageSize: ref(5) })
expect(total.value).toBe(activityFixture.length)
expect(pagedItems.value).toHaveLength(5)
})
it('clamps the page when the filtered result shrinks past it', async () => {
const search = ref('')
const { page, search: s } = setup({ search, pageSize: ref(5) })
page.value = 3 // valid: ceil(14 / 5) = 3 pages
s.value = 'katherine' // narrows to 2 rows -> 1 page
await nextTick()
expect(page.value).toBe(1)
})
it('rolls up per-user totals excluding credit inflows', () => {
const { userSummaries } = setup()
const ada = userSummaries.value.get('Ada Lovelace')
expect(ada?.totalCredits).toBe(1840 + 5120 + 4100 + 2950)
expect(ada?.lastActivity).toEqual(new Date('2026-07-14T09:32:00Z'))
// credited inflows (userName '') never appear in the rollups
expect(userSummaries.value.has('')).toBe(false)
})
})

View File

@@ -1,133 +0,0 @@
import type { MaybeRefOrGetter } from 'vue'
import { computed, ref, toValue, watch } from 'vue'
export interface ActivityEvent {
id: string
date: Date
userName: string
eventType: string
detail: string
credits: number
/** The partner node used, for 'Partner node usage' events. */
partnerNode?: string
/** True for credit inflows (auto-reload, top-up) vs. usage outflows. */
credited?: boolean
}
export interface UserSummary {
totalCredits: number
lastActivity: Date
}
export type ActivitySortField =
| 'date'
| 'user'
| 'eventType'
| 'detail'
| 'credits'
/**
* Headless state for the workspace Activity ledger: role-scoped filtering,
* search, sort, auto-paginated slicing, and per-user rollups.
*
* `source` is the data seam. It defaults to an empty list, so the ledger renders
* its empty state until a caller supplies events; the per-workspace usage API
* that will feed it is tracked in FE-1249. Swapping in real data means passing a
* populated ref/getter here — every downstream computed stays untouched.
*/
export function useWorkspaceActivity(
search: MaybeRefOrGetter<string>,
pageSize: MaybeRefOrGetter<number>,
selfName: MaybeRefOrGetter<string | null> = null,
source: MaybeRefOrGetter<ActivityEvent[]> = []
) {
const page = ref(1)
const perPage = computed(() => Math.max(1, toValue(pageSize)))
const sortField = ref<ActivitySortField>('date')
const sortDirection = ref<'asc' | 'desc'>('desc')
// Members only see their own usage; credit inflows stay workspace-level.
const base = computed<ActivityEvent[]>(() => {
const events = toValue(source)
const self = toValue(selfName)
if (!self) return events
return events.filter((event) => event.credited || event.userName === self)
})
const filtered = computed(() => {
const q = toValue(search).trim().toLowerCase()
if (!q) return base.value
return base.value.filter(
(event) =>
event.userName.toLowerCase().includes(q) ||
event.eventType.toLowerCase().includes(q)
)
})
const sorted = computed(() => {
const dir = sortDirection.value === 'asc' ? 1 : -1
return [...filtered.value].sort((a, b) => {
if (sortField.value === 'credits') return dir * (a.credits - b.credits)
if (sortField.value === 'user')
return dir * a.userName.localeCompare(b.userName)
if (sortField.value === 'eventType')
return dir * a.eventType.localeCompare(b.eventType)
if (sortField.value === 'detail')
return dir * a.detail.localeCompare(b.detail)
return dir * (a.date.getTime() - b.date.getTime())
})
})
const total = computed(() => filtered.value.length)
const pagedItems = computed(() => {
const start = (page.value - 1) * perPage.value
return sorted.value.slice(start, start + perPage.value)
})
function toggleSort(field: ActivitySortField) {
if (sortField.value === field) {
sortDirection.value = sortDirection.value === 'asc' ? 'desc' : 'asc'
} else {
sortField.value = field
sortDirection.value = 'desc'
}
}
watch([total, perPage], ([count]) => {
const lastPage = Math.max(1, Math.ceil(count / perPage.value))
if (page.value > lastPage) page.value = lastPage
})
// Per-user rollups behind the User hover card: lifetime credits and the most
// recent event, aggregated across the whole (unpaged) list.
const userSummaries = computed(() => {
const map = new Map<string, UserSummary>()
for (const event of base.value) {
if (event.credited) continue
const existing = map.get(event.userName)
if (!existing) {
map.set(event.userName, {
totalCredits: event.credits,
lastActivity: event.date
})
} else {
existing.totalCredits += event.credits
if (event.date > existing.lastActivity)
existing.lastActivity = event.date
}
}
return map
})
return {
page,
total,
itemsPerPage: perPage,
pagedItems,
sortField,
sortDirection,
toggleSort,
userSummaries
}
}

View File

@@ -1,64 +0,0 @@
import { storeToRefs } from 'pinia'
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import type { ActivityEvent } from '@/platform/workspace/composables/useWorkspaceActivity'
import { workspaceApi } from '@/platform/workspace/api/workspaceApi'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import type { BillingEventInput } from '@/platform/workspace/utils/billingEventToActivity'
import {
billingEventToActivity,
isUsageEvent
} from '@/platform/workspace/utils/billingEventToActivity'
/**
* Live data source for the workspace Activity ledger (FE-1249): fetches the
* per-workspace usage feed (`GET /api/billing/events`), keeps only usage rows,
* and maps them to the ledger's `ActivityEvent` shape, resolving member names
* from the workspace store so the mapping updates once members load.
*/
export function useWorkspaceActivitySource() {
const { t } = useI18n()
const workspaceStore = useTeamWorkspaceStore()
const { members } = storeToRefs(workspaceStore)
const rawEvents = ref<BillingEventInput[]>([])
const isLoading = ref(false)
const error = ref<unknown>(null)
function resolveUserName(userId: string | undefined): string {
if (!userId) return ''
const member = members.value.find((m) => m.id === userId)
return member?.name || member?.email || userId
}
const events = computed<ActivityEvent[]>(() => {
const labels = {
cloudRun: t('workspacePanel.activity.eventType.cloudRun'),
partnerNode: t('workspacePanel.activity.eventType.partnerNode')
}
return rawEvents.value
.filter(isUsageEvent)
.map((event) => billingEventToActivity(event, resolveUserName, labels))
})
async function refresh() {
isLoading.value = true
error.value = null
try {
const response = await workspaceApi.getBillingEvents()
rawEvents.value = response.events
} catch (err) {
error.value = err
rawEvents.value = []
} finally {
isLoading.value = false
}
}
onMounted(() => {
void refresh()
})
return { events, isLoading, error, refresh }
}

View File

@@ -11,7 +11,7 @@ import { useDialogService } from '@/services/dialogService'
* Builds the Plan & Credits overflow-menu model for the workspace subscription
* panel. Visibility and the Delete enable/disable policy are derived from the
* shared useWorkspaceUI state so this menu can't desync with the sibling
* Plan & Credits panel menu.
* WorkspacePanelContent menu.
*/
export function useWorkspaceMenuItems() {
const { t } = useI18n()

View File

@@ -1,130 +0,0 @@
import type { ActivityEvent } from '@/platform/workspace/composables/useWorkspaceActivity'
/**
* Non-production sample ledger for Storybook and unit tests only — never
* imported by shipping code. It stands in for the per-workspace usage API
* (FE-1249) so the Activity table can be exercised with a populated,
* multi-page, multi-user data set (usage outflows, partner-node rows, and
* workspace-level credit inflows) while the live source is still empty.
*/
export const activityFixture: ActivityEvent[] = [
{
id: 'evt-01',
date: new Date('2026-07-14T09:32:00Z'),
userName: 'Ada Lovelace',
eventType: 'Cloud workflow run',
detail: '2 runs',
credits: 1840
},
{
id: 'evt-02',
date: new Date('2026-07-14T08:10:00Z'),
userName: 'Grace Hopper',
eventType: 'Partner node usage',
detail: '1 run',
credits: 3200,
partnerNode: 'Flux Pro 1.1 Ultra'
},
{
id: 'evt-03',
date: new Date('2026-07-13T22:47:00Z'),
userName: 'Ada Lovelace',
eventType: 'Partner node usage',
detail: '4 runs',
credits: 5120,
partnerNode: 'Kling v2 Master'
},
{
id: 'evt-04',
date: new Date('2026-07-13T18:05:00Z'),
userName: 'Alan Turing',
eventType: 'Cloud workflow run',
detail: '7 runs',
credits: 6300
},
{
id: 'evt-05',
date: new Date('2026-07-13T12:00:00Z'),
userName: '',
eventType: 'Auto-reload',
detail: '—',
credits: 20000,
credited: true
},
{
id: 'evt-06',
date: new Date('2026-07-12T16:22:00Z'),
userName: 'Grace Hopper',
eventType: 'Cloud workflow run',
detail: '3 runs',
credits: 2700
},
{
id: 'evt-07',
date: new Date('2026-07-12T11:41:00Z'),
userName: 'Alan Turing',
eventType: 'Partner node usage',
detail: '2 runs',
credits: 4480,
partnerNode: 'Gemini 2.5 Flash Image'
},
{
id: 'evt-08',
date: new Date('2026-07-11T20:15:00Z'),
userName: 'Ada Lovelace',
eventType: 'Cloud workflow run',
detail: '5 runs',
credits: 4100
},
{
id: 'evt-09',
date: new Date('2026-07-11T09:03:00Z'),
userName: 'Katherine Johnson',
eventType: 'Cloud workflow run',
detail: '1 run',
credits: 760
},
{
id: 'evt-10',
date: new Date('2026-07-10T14:38:00Z'),
userName: 'Grace Hopper',
eventType: 'Partner node usage',
detail: '6 runs',
credits: 8900,
partnerNode: 'Veo 3'
},
{
id: 'evt-11',
date: new Date('2026-07-10T07:26:00Z'),
userName: 'Alan Turing',
eventType: 'Cloud workflow run',
detail: '2 runs',
credits: 1560
},
{
id: 'evt-12',
date: new Date('2026-07-09T19:52:00Z'),
userName: '',
eventType: 'Top-up',
detail: '—',
credits: 50000,
credited: true
},
{
id: 'evt-13',
date: new Date('2026-07-09T13:14:00Z'),
userName: 'Katherine Johnson',
eventType: 'Partner node usage',
detail: '3 runs',
credits: 3840,
partnerNode: 'Recraft V3'
},
{
id: 'evt-14',
date: new Date('2026-07-08T10:48:00Z'),
userName: 'Ada Lovelace',
eventType: 'Cloud workflow run',
detail: '4 runs',
credits: 2950
}
]

View File

@@ -1,23 +0,0 @@
import { describe, expect, it } from 'vitest'
import { userBadgeColor } from './badgeColor'
const HEX = /^#[0-9a-f]{6}$/
describe('userBadgeColor', () => {
it('is deterministic for the same seed', () => {
expect(userBadgeColor('Ada Lovelace')).toBe(userBadgeColor('Ada Lovelace'))
})
it('always returns a color from the palette', () => {
for (const seed of ['', 'a', 'Grace Hopper', 'user@example.com', '你好']) {
expect(userBadgeColor(seed)).toMatch(HEX)
}
})
it('spreads different seeds across more than one color', () => {
const seeds = Array.from({ length: 30 }, (_, i) => `user-${i}`)
const distinct = new Set(seeds.map(userBadgeColor))
expect(distinct.size).toBeGreaterThan(1)
})
})

View File

@@ -1,21 +0,0 @@
// Muted, low-saturation badge palette (sampled from the Figma usage palette).
// Dark tones that read against the dark surface with a white monogram.
const BADGE_COLORS = [
'#956252', // terracotta
'#3e465f', // slate indigo
'#424f45', // olive green
'#90646e', // mauve rose
'#6d5a7a', // muted purple
'#4f6b6b', // muted teal
'#7a6a4a', // khaki
'#5a6270' // steel
]
/** Stable muted badge color for a user, keyed by name/email. */
export function userBadgeColor(seed: string): string {
let hash = 0
for (let i = 0; i < seed.length; i++) {
hash = (hash * 31 + seed.charCodeAt(i)) >>> 0
}
return BADGE_COLORS[hash % BADGE_COLORS.length]
}

View File

@@ -1,83 +0,0 @@
import { describe, expect, it } from 'vitest'
import type { BillingEventInput } from '@/platform/workspace/utils/billingEventToActivity'
import {
billingEventToActivity,
isUsageEvent
} from '@/platform/workspace/utils/billingEventToActivity'
const labels = {
cloudRun: 'Cloud workflow run',
partnerNode: 'Partner node usage'
}
const nameById: Record<string, string> = { 'user-1': 'Ada Lovelace' }
function resolveName(userId: string | undefined): string {
if (!userId) return ''
return nameById[userId] ?? userId
}
function event(overrides: Partial<BillingEventInput>): BillingEventInput {
return {
event_type: 'gpu_usage',
event_id: 'evt',
createdAt: '2026-07-14T00:00:00Z',
...overrides
}
}
describe('isUsageEvent', () => {
it('keeps gpu and api-node usage and drops non-usage events', () => {
expect(isUsageEvent(event({ event_type: 'gpu_usage' }))).toBe(true)
expect(isUsageEvent(event({ event_type: 'api_node_usage' }))).toBe(true)
expect(isUsageEvent(event({ event_type: 'invoice_paid' }))).toBe(false)
})
})
describe('billingEventToActivity', () => {
it('maps a GPU usage row to a cloud-run entry attributed to its member', () => {
const row = billingEventToActivity(
event({
event_type: 'gpu_usage',
event_id: 'evt-1',
createdAt: '2026-07-14T09:32:00Z',
params: { user_id: 'user-1', gpu_seconds: 12 }
}),
resolveName,
labels
)
expect(row).toMatchObject({
id: 'evt-1',
userName: 'Ada Lovelace',
eventType: 'Cloud workflow run',
credits: 0,
credited: false
})
expect(row.date.toISOString()).toBe('2026-07-14T09:32:00.000Z')
expect(row.partnerNode).toBeUndefined()
})
it('maps a partner usage row with its partner node', () => {
const row = billingEventToActivity(
event({
event_type: 'api_node_usage',
event_id: 'evt-2',
params: { user_id: 'user-1', partner_node: 'Flux Pro 1.1 Ultra' }
}),
resolveName,
labels
)
expect(row.eventType).toBe('Partner node usage')
expect(row.partnerNode).toBe('Flux Pro 1.1 Ultra')
})
it('leaves the user name empty when the event has no user_id', () => {
const row = billingEventToActivity(
event({ event_id: 'evt-3', params: {} }),
resolveName,
labels
)
expect(row.userName).toBe('')
})
})

View File

@@ -1,57 +0,0 @@
import type { ActivityEvent } from '@/platform/workspace/composables/useWorkspaceActivity'
/**
* Maps a `/api/billing/events` usage row to an Activity ledger row (FE-1249).
*
* Only the fields the backend exposes today are populated: the date, the member
* the event is attributed to (`params.user_id`), and the event type. The Credits
* column stays 0 until per-event cost lands (FE-1249 / P1), and `partnerNode` is
* best-effort from `params` because the partner (comfy-api) property contract is
* not yet fixed.
*/
export interface BillingEventInput {
event_type: string
event_id: string
params?: Record<string, unknown>
createdAt: string
}
export interface ActivityEventTypeLabels {
cloudRun: string
partnerNode: string
}
const USAGE_EVENT_TYPES = new Set(['gpu_usage', 'api_node_usage'])
export function isUsageEvent(event: BillingEventInput): boolean {
return USAGE_EVENT_TYPES.has(event.event_type)
}
function stringParam(
params: Record<string, unknown> | undefined,
key: string
): string | undefined {
const value = params?.[key]
return typeof value === 'string' && value.length > 0 ? value : undefined
}
export function billingEventToActivity(
event: BillingEventInput,
resolveUserName: (userId: string | undefined) => string,
labels: ActivityEventTypeLabels
): ActivityEvent {
const isPartner = event.event_type === 'api_node_usage'
return {
id: event.event_id,
date: new Date(event.createdAt),
userName: resolveUserName(stringParam(event.params, 'user_id')),
eventType: isPartner ? labels.partnerNode : labels.cloudRun,
detail: '',
credits: 0,
partnerNode: isPartner
? stringParam(event.params, 'partner_node')
: undefined,
credited: false
}
}

View File

@@ -1,44 +0,0 @@
import { describe, expect, it } from 'vitest'
import { formatRelativeTime } from './relativeTime'
const labels = {
justNow: 'just now',
minutesAgo: (n: number) => `${n} min ago`,
hoursAgo: (n: number) => `${n} hr ago`,
daysAgo: (n: number) => `${n} days ago`
}
const now = new Date('2026-07-14T12:00:00Z')
function ago(ms: number) {
return new Date(now.getTime() - ms)
}
const SECOND = 1000
const MINUTE = 60 * SECOND
const HOUR = 60 * MINUTE
const DAY = 24 * HOUR
describe('formatRelativeTime', () => {
it('reads "just now" under a minute', () => {
expect(formatRelativeTime(ago(30 * SECOND), now, labels)).toBe('just now')
})
it('reads whole minutes under an hour', () => {
expect(formatRelativeTime(ago(6 * MINUTE), now, labels)).toBe('6 min ago')
expect(formatRelativeTime(ago(59 * MINUTE), now, labels)).toBe('59 min ago')
})
it('reads whole hours under a day', () => {
expect(formatRelativeTime(ago(2 * HOUR), now, labels)).toBe('2 hr ago')
})
it('reads whole days beyond a day', () => {
expect(formatRelativeTime(ago(3 * DAY), now, labels)).toBe('3 days ago')
})
it('never returns a negative bucket for a future timestamp', () => {
expect(formatRelativeTime(ago(-5 * MINUTE), now, labels)).toBe('just now')
})
})

View File

@@ -1,29 +0,0 @@
const MINUTE_MS = 60 * 1000
const HOUR_MS = 60 * MINUTE_MS
const DAY_MS = 24 * HOUR_MS
interface RelativeTimeLabels {
justNow: string
minutesAgo: (n: number) => string
hoursAgo: (n: number) => string
daysAgo: (n: number) => string
}
/**
* Abbreviated "time ago" label (e.g. "6 min ago", "2 hr ago", "3 days ago"),
* matching the member-list activity column. Copy is injected so callers can
* supply localized, pluralized strings.
*/
export function formatRelativeTime(
date: Date,
now: Date,
labels: RelativeTimeLabels
): string {
const elapsed = Math.max(0, now.getTime() - date.getTime())
if (elapsed < MINUTE_MS) return labels.justNow
if (elapsed < HOUR_MS)
return labels.minutesAgo(Math.floor(elapsed / MINUTE_MS))
if (elapsed < DAY_MS) return labels.hoursAgo(Math.floor(elapsed / HOUR_MS))
return labels.daysAgo(Math.floor(elapsed / DAY_MS))
}

View File

@@ -35,13 +35,13 @@ const inputNodeIds = computed(() => {
})
const accessibleNodeErrors = computed(() =>
Object.keys(executionErrorStore.surfacedNodeErrors ?? {}).filter((k) =>
Object.keys(executionErrorStore.lastNodeErrors ?? {}).filter((k) =>
inputNodeIds.value.has(k)
)
)
const accessibleErrors = computed(() =>
accessibleNodeErrors.value.flatMap((k) => {
const nodeError = executionErrorStore.surfacedNodeErrors?.[k]
const nodeError = executionErrorStore.lastNodeErrors?.[k]
if (!nodeError) return []
return nodeError.errors.flatMap((error) => {

View File

@@ -252,41 +252,6 @@ describe('hasWidgetError', () => {
).toBe(true)
expect(spy).toHaveBeenCalledWith('1', 'display_slot')
})
it('matches raw interior errors by the source widget name for promoted widgets', () => {
const sourceExecutionId = createNodeExecutionId([
toNodeId(65),
toNodeId(18)
])
const widget = createMockWidget({
name: 'display_slot',
sourceExecutionId,
sourceWidgetName: 'ckpt_name'
})
executionErrorStore.lastNodeErrors = {
[sourceExecutionId]: {
errors: [
{
type: 'value_not_in_list',
message: 'Invalid model',
details: '',
extra_info: { input_name: 'ckpt_name' }
}
],
class_type: 'CheckpointLoaderSimple',
dependent_outputs: []
}
}
expect(
hasWidgetError(
widget,
createNodeExecutionId([toNodeId(1)]),
undefined,
executionErrorStore,
missingModelStore
)
).toBe(true)
})
})
const noopUi = {
@@ -702,36 +667,6 @@ describe('createWidgetUpdateHandler (via computeProcessedWidgets)', () => {
)
})
it('clears raw interior errors through widget.sourceExecutionId, which boundary lift relies on', () => {
const sourceExecutionId = createNodeExecutionId([65, 18])
const widget = createMockWidget({
name: 'display_slot',
nodeId: NODE_ID,
sourceExecutionId,
sourceWidgetName: 'ckpt_name'
})
const executionErrorStore = useExecutionErrorStore()
executionErrorStore.lastNodeErrors = {
[sourceExecutionId]: {
errors: [
{
type: 'value_not_in_list',
message: 'Invalid model',
details: '',
extra_info: { input_name: 'ckpt_name' }
}
],
class_type: 'CheckpointLoaderSimple',
dependent_outputs: []
}
}
const [processed] = processWidgets([widget])
processed.updateHandler('real_model.safetensors')
expect(executionErrorStore.lastNodeErrors).toBeNull()
})
it('clears execution errors on update', () => {
const widget = createMockWidget({
name: 'seed',

View File

@@ -130,12 +130,8 @@ export function hasWidgetError(
const errors = widget.sourceExecutionId
? executionErrorStore.lastNodeErrors?.[widget.sourceExecutionId]?.errors
: nodeErrors?.errors
// Raw interior errors name the source widget, not the boundary name
const errorInputName = widget.sourceExecutionId
? (widget.sourceWidgetName ?? widget.name)
: widget.name
return (
!!errors?.some((e) => e.extra_info?.input_name === errorInputName) ||
!!errors?.some((e) => e.extra_info?.input_name === widget.name) ||
missingModelStore.isWidgetMissingModel(nodeExecId, widget.name)
)
}

View File

@@ -1,21 +1,9 @@
import { fromAny } from '@total-typescript/shoehorn'
import { createPinia, setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nodeError, validationError } from '@/utils/__tests__/nodeErrorHelpers'
import type { MissingNodeType } from '@/types/comfy'
import {
createBoundaryLinkedSubgraph,
createTestRootGraph,
createTestSubgraph,
createTestSubgraphNode
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { app } from '@/scripts/app'
import {
createNodeExecutionId,
createNodeLocatorId
} from '@/types/nodeIdentification'
import { createNodeExecutionId } from '@/types/nodeIdentification'
// Mock dependencies
vi.mock('@/i18n', () => ({
@@ -51,20 +39,11 @@ import { useExecutionErrorStore } from './executionErrorStore'
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
import { toNodeId } from '@/types/nodeId'
function mockGraphReady(rootGraph: typeof app.rootGraph) {
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(rootGraph)
vi.spyOn(app, 'isGraphReady', 'get').mockReturnValue(true)
}
describe('executionErrorStore — node error operations', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('clearSimpleNodeErrors', () => {
it('does nothing if lastNodeErrors is null', () => {
const store = useExecutionErrorStore()
@@ -317,97 +296,6 @@ describe('executionErrorStore — node error operations', () => {
// Error should remain
expect(store.lastNodeErrors?.['123'].errors).toHaveLength(1)
})
it('clears a lifted host slot error from the raw interior record', () => {
const { rootGraph } = createBoundaryLinkedSubgraph()
mockGraphReady(rootGraph)
const store = useExecutionErrorStore()
store.lastNodeErrors = {
'12:5': nodeError([
validationError('required_input_missing', 'seed_input')
])
}
expect(store.surfacedNodeErrors).toHaveProperty('12')
store.clearSimpleNodeErrors(createNodeExecutionId([toNodeId(12)]), 'seed')
expect(store.lastNodeErrors).toBeNull()
expect(store.surfacedNodeErrors).toBeNull()
})
it('does not clear lifted host slot errors when the raw error is not simple', () => {
const { rootGraph } = createBoundaryLinkedSubgraph()
mockGraphReady(rootGraph)
const store = useExecutionErrorStore()
store.lastNodeErrors = {
'12:5': nodeError([
validationError(
'custom_validation_failed',
'seed_input',
{},
'Custom validation failed'
)
])
}
expect(store.surfacedNodeErrors).toHaveProperty('12')
store.clearSimpleNodeErrors(createNodeExecutionId([toNodeId(12)]), 'seed')
expect(store.lastNodeErrors).toHaveProperty('12:5')
expect(store.lastNodeErrors?.['12:5'].errors).toHaveLength(1)
})
it('clears a nested lifted error fixed at an intermediate host level', () => {
const rootGraph = createTestRootGraph()
const outerSubgraph = createTestSubgraph({
rootGraph,
inputs: [{ name: 'seed', type: '*' }]
})
const outerHost = createTestSubgraphNode(outerSubgraph, { id: 1 })
rootGraph.add(outerHost)
const middleSubgraph = createTestSubgraph({
rootGraph,
inputs: [{ name: 'seed', type: '*' }]
})
const middleHost = createTestSubgraphNode(middleSubgraph, {
id: 2,
parentGraph: outerSubgraph
})
outerSubgraph.add(middleHost)
outerSubgraph.inputNode.slots[0].connect(middleHost.inputs[0], middleHost)
const leaf = new LGraphNode('LeafNode')
leaf.id = toNodeId(3)
const leafInput = leaf.addInput('seed_input', '*')
middleSubgraph.add(leaf)
middleSubgraph.inputNode.slots[0].connect(leafInput, leaf)
mockGraphReady(rootGraph)
const store = useExecutionErrorStore()
store.lastNodeErrors = {
'1:2:3': nodeError([
validationError('required_input_missing', 'seed_input')
])
}
expect(store.surfacedNodeErrors).toHaveProperty('1')
store.clearSimpleNodeErrors(
createNodeExecutionId([toNodeId(1), toNodeId(2)]),
'seed'
)
expect(
store.lastNodeErrors,
'a fix at the intermediate host clears the raw interior error'
).toBeNull()
expect(store.surfacedNodeErrors).toBeNull()
})
})
describe('clearWidgetRelatedErrors', () => {
@@ -500,137 +388,6 @@ describe('executionErrorStore — node error operations', () => {
expect(store.lastNodeErrors).not.toBeNull()
expect(store.lastNodeErrors?.['123'].errors).toHaveLength(1)
})
it('validates the base target against live widget bounds, not recorded ones', () => {
const store = useExecutionErrorStore()
store.lastNodeErrors = {
'123': nodeError([
validationError('value_bigger_than_max', 'testWidget', {
input_config: ['INT', { max: 100 }]
})
])
}
store.clearWidgetRelatedErrors(
createNodeExecutionId([toNodeId(123)]),
'testWidget',
'testWidget',
150,
{ max: 200 }
)
expect(
store.lastNodeErrors,
'a value within the refreshed widget bounds clears despite stale recorded bounds'
).toBeNull()
})
it('does not clear lifted range errors until the host value is in range', () => {
const { rootGraph } = createBoundaryLinkedSubgraph()
mockGraphReady(rootGraph)
const store = useExecutionErrorStore()
store.lastNodeErrors = {
'12:5': nodeError([
validationError('value_bigger_than_max', 'seed_input', {}, 'Too high')
])
}
expect(store.surfacedNodeErrors).toHaveProperty('12')
store.clearWidgetRelatedErrors(
createNodeExecutionId([toNodeId(12)]),
'seed',
'seed',
200,
{ max: 100 }
)
expect(store.lastNodeErrors).toHaveProperty('12:5')
expect(store.lastNodeErrors?.['12:5'].errors).toHaveLength(1)
store.clearWidgetRelatedErrors(
createNodeExecutionId([toNodeId(12)]),
'seed',
'seed',
50,
{ max: 100 }
)
expect(store.lastNodeErrors).toBeNull()
})
it('clears fan-out lifted targets per their own recorded bounds', () => {
const { rootGraph, subgraph } = createBoundaryLinkedSubgraph()
const second = new LGraphNode('SecondInterior')
second.id = toNodeId(7)
const secondInput = second.addInput('other_input', '*')
subgraph.add(second)
subgraph.inputNode.slots[0].connect(secondInput, second)
mockGraphReady(rootGraph)
const store = useExecutionErrorStore()
store.lastNodeErrors = {
'12:5': nodeError([
validationError('value_bigger_than_max', 'seed_input', {
input_config: ['INT', { max: 100 }]
})
]),
'12:7': nodeError([
validationError('value_bigger_than_max', 'other_input', {
input_config: ['INT', { max: 50 }]
})
])
}
expect(store.surfacedNodeErrors?.['12'].errors).toHaveLength(2)
store.clearWidgetRelatedErrors(
createNodeExecutionId([toNodeId(12)]),
'seed',
'seed',
75,
{ max: 100 }
)
expect(
store.lastNodeErrors?.['12:5'],
'the target whose max=100 is satisfied by 75 clears'
).toBeUndefined()
expect(
store.lastNodeErrors?.['12:7'].errors,
'the target whose max=50 is still violated by 75 stays'
).toHaveLength(1)
})
})
describe('surfacedNodeErrors', () => {
it('derives boundary-lifted errors while preserving the raw record', () => {
const { rootGraph, host } = createBoundaryLinkedSubgraph()
mockGraphReady(rootGraph)
const store = useExecutionErrorStore()
store.lastNodeErrors = {
'12:5': nodeError([
validationError('required_input_missing', 'seed_input')
])
}
const hostLocatorId = createNodeLocatorId(null, toNodeId(12))
expect(store.lastNodeErrors).toHaveProperty('12:5')
expect(store.surfacedNodeErrors).toHaveProperty('12')
expect(
store.surfacedNodeErrors?.['12'].errors[0].extra_info
).toMatchObject({
input_name: 'seed',
source_execution_id: '12:5',
source_input_name: 'seed_input'
})
expect(store.getNodeErrors(hostLocatorId)?.class_type).toBe(host.title)
expect(store.allErrorExecutionIds).toEqual(['12'])
expect(store.activeGraphErrorNodeIds).toEqual(new Set(['12']))
})
})
})

View File

@@ -2,11 +2,6 @@ import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import { useNodeErrorFlagSync } from '@/composables/graph/useNodeErrorFlagSync'
import {
getLiftedErrorSource,
liftNodeErrorsToBoundary,
resolveLiftChain
} from '@/core/graph/subgraph/liftNodeErrorsToBoundary'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { useMissingMediaStore } from '@/platform/missingMedia/missingMediaStore'
@@ -21,10 +16,7 @@ import type {
NodeError,
PromptError
} from '@/schemas/apiSchema'
import {
getAncestorExecutionIds,
tryNormalizeNodeExecutionId
} from '@/types/nodeIdentification'
import { getAncestorExecutionIds } from '@/types/nodeIdentification'
import type { NodeExecutionId, NodeLocatorId } from '@/types/nodeIdentification'
import {
executionIdToNodeLocatorId,
@@ -33,18 +25,10 @@ import {
} from '@/utils/graphTraversalUtil'
import {
SIMPLE_ERROR_TYPES,
getInputConfigBounds,
isValueStillOutOfRange
} from '@/utils/executionErrorUtil'
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
interface SlotNodeErrorClearTarget {
executionId: NodeExecutionId
slotName: string
/** Interior targets validate against the bounds recorded on their errors. */
useRecordedBounds?: boolean
}
/** Execution error state: node errors, runtime errors, prompt errors, and missing assets. */
export const useExecutionErrorStore = defineStore('executionError', () => {
const workflowStore = useWorkflowStore()
@@ -95,27 +79,31 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
lastPromptError.value = null
}
function clearSimpleNodeErrorsFromRecord(
nodeErrors: Record<string, NodeError>,
/**
* Removes a node's errors if they consist entirely of simple, auto-resolvable
* types. When `slotName` is provided, only errors for that slot are checked.
*/
function clearSimpleNodeErrors(
executionId: NodeExecutionId,
slotName?: string
): Record<string, NodeError> | null {
const nodeError = nodeErrors[executionId]
if (!nodeError) return null
): void {
if (!lastNodeErrors.value) return
const nodeError = lastNodeErrors.value[executionId]
if (!nodeError) return
const isSlotScoped = slotName !== undefined
const relevantErrors = isSlotScoped
? nodeError.errors.filter((e) => e.extra_info?.input_name === slotName)
: nodeError.errors
if (relevantErrors.length === 0) return null
if (!relevantErrors.every((e) => SIMPLE_ERROR_TYPES.has(e.type))) {
return null
}
if (relevantErrors.length === 0) return
if (!relevantErrors.every((e) => SIMPLE_ERROR_TYPES.has(e.type))) return
const updated = { ...nodeErrors }
const updated = { ...lastNodeErrors.value }
if (isSlotScoped) {
// Remove only the target slot's errors if they were all simple
const remainingErrors = nodeError.errors.filter(
(e) => e.extra_info?.input_name !== slotName
)
@@ -128,150 +116,16 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
}
}
} else {
// If no slot specified and all errors were simple, clear the whole node
delete updated[executionId]
}
return updated
}
/**
* Raw interior sources of lifted errors whose boundary chain passes through
* `(executionId, slotName)`, so a fix at any host level — final surface or
* intermediate — clears the error at its raw key.
*/
function getLiftedErrorSourceTargets(
executionId: NodeExecutionId,
slotName: string
): SlotNodeErrorClearTarget[] {
const surfaced = surfacedNodeErrors.value
if (!surfaced || !app.isGraphReady) return []
return Object.values(surfaced).flatMap((surface) =>
surface.errors.flatMap((error): SlotNodeErrorClearTarget[] => {
const source = getLiftedErrorSource(error)
if (!source) return []
const sourceExecutionId = tryNormalizeNodeExecutionId(
source.source_execution_id
)
if (!sourceExecutionId) return []
const clearsThisError = resolveLiftChain(
app.rootGraph,
sourceExecutionId,
source.source_input_name
).some(
(level) =>
level.hostExecId === executionId && level.hostInputName === slotName
)
return clearsThisError
? [
{
executionId: sourceExecutionId,
slotName: source.source_input_name,
useRecordedBounds: true
}
]
: []
})
)
}
/** Raw targets are keys into lastNodeErrors, not surfacedNodeErrors. */
function getRawClearTargets(
executionId: NodeExecutionId,
slotName: string
): SlotNodeErrorClearTarget[] {
return [
{ executionId, slotName },
...getLiftedErrorSourceTargets(executionId, slotName)
]
}
/**
* Bounds recorded on the error win only for interior lifted targets, where
* the caller's options describe the host widget rather than the interior
* input. The base target keeps the caller's live widget bounds, which stay
* authoritative when node definitions change after validation.
*/
function getTargetRangeOptions(
errors: NodeError['errors'],
fallback: { min?: number; max?: number }
): { min?: number; max?: number } {
for (const error of errors) {
const { min, max } = getInputConfigBounds(error)
if (min === undefined && max === undefined) continue
return {
min: typeof min === 'number' ? min : fallback.min,
max: typeof max === 'number' ? max : fallback.max
}
}
return fallback
}
function isTargetStillOutOfRange(
nodeErrors: Record<string, NodeError>,
target: SlotNodeErrorClearTarget,
value: number,
callerOptions: { min?: number; max?: number }
): boolean {
const nodeError = nodeErrors[target.executionId]
if (!nodeError) return false
const errors = nodeError.errors.filter(
(error) => error.extra_info?.input_name === target.slotName
)
const options = target.useRecordedBounds
? getTargetRangeOptions(errors, callerOptions)
: callerOptions
return isValueStillOutOfRange(value, errors, options)
}
function clearTargets(
targets: { executionId: NodeExecutionId; slotName?: string }[]
): void {
if (!lastNodeErrors.value) return
let updated = lastNodeErrors.value
for (const target of targets) {
updated =
clearSimpleNodeErrorsFromRecord(
updated,
target.executionId,
target.slotName
) ?? updated
}
if (updated === lastNodeErrors.value) return
lastNodeErrors.value = Object.keys(updated).length > 0 ? updated : null
}
/**
* Removes a node's errors if they consist entirely of simple, auto-resolvable
* types. When `slotName` is provided, only errors for that slot are checked
* and boundary-lifted errors are also cleared through their raw interior
* source. Node-scoped calls (no `slotName`) operate on the raw record key
* only.
*/
function clearSimpleNodeErrors(
executionId: NodeExecutionId,
slotName?: string
): void {
clearTargets(
slotName === undefined
? [{ executionId }]
: getRawClearTargets(executionId, slotName)
)
}
/**
* Attempts to clear an error for a given widget, but avoids clearing it if
* the error is a range violation and the new value is still out of bounds.
* The base target validates against the caller's live widget bounds; each
* interior lifted target validates against its own recorded bounds, so a
* boundary input fanning out to inputs with different constraints clears
* only the targets the new value satisfies.
*
* Note: `value_not_in_list` errors are optimistically cleared without
* list-membership validation because combo widgets constrain choices to
@@ -284,24 +138,16 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
newValue: unknown,
options?: { min?: number; max?: number }
): void {
const nodeErrors = lastNodeErrors.value
if (!nodeErrors) return
const targets = getRawClearTargets(executionId, widgetName)
const clearableTargets =
typeof newValue === 'number'
? targets.filter(
(target) =>
!isTargetStillOutOfRange(
nodeErrors,
target,
newValue,
options ?? {}
)
)
: targets
clearTargets(clearableTargets)
if (typeof newValue === 'number' && lastNodeErrors.value) {
const nodeErrors = lastNodeErrors.value[executionId]
if (nodeErrors) {
const errs = nodeErrors.errors.filter(
(e) => e.extra_info?.input_name === widgetName
)
if (isValueStillOutOfRange(newValue, errs, options || {})) return
}
}
clearSimpleNodeErrors(executionId, widgetName)
}
/**
@@ -378,13 +224,6 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
() => !!lastNodeErrors.value && Object.keys(lastNodeErrors.value).length > 0
)
// Re-lifts only when the record changes; topology is assumed stable while errors are displayed.
const surfacedNodeErrors = computed(() =>
lastNodeErrors.value && app.isGraphReady
? liftNodeErrorsToBoundary(app.rootGraph, lastNodeErrors.value)
: lastNodeErrors.value
)
const hasAnyError = computed(
() =>
hasExecutionError.value ||
@@ -397,8 +236,8 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
const allErrorExecutionIds = computed<string[]>(() => {
const ids: string[] = []
if (surfacedNodeErrors.value) {
ids.push(...Object.keys(surfacedNodeErrors.value))
if (lastNodeErrors.value) {
ids.push(...Object.keys(lastNodeErrors.value))
}
if (lastExecutionError.value) {
const nodeId = lastExecutionError.value.node_id
@@ -440,8 +279,8 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
// Fall back to rootGraph when currentGraph hasn't been initialized yet
const activeGraph = canvasStore.currentGraph ?? app.rootGraph
if (surfacedNodeErrors.value) {
for (const executionId of Object.keys(surfacedNodeErrors.value)) {
if (lastNodeErrors.value) {
for (const executionId of Object.keys(lastNodeErrors.value)) {
const graphNode = getNodeByExecutionId(app.rootGraph, executionId)
if (graphNode?.graph === activeGraph) {
ids.add(String(graphNode.id))
@@ -463,12 +302,12 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
/** Map of node errors indexed by locator ID. */
const nodeErrorsByLocatorId = computed<Record<NodeLocatorId, NodeError>>(
() => {
if (!surfacedNodeErrors.value) return {}
if (!lastNodeErrors.value) return {}
const map: Record<NodeLocatorId, NodeError> = {}
for (const [executionId, nodeError] of Object.entries(
surfacedNodeErrors.value
lastNodeErrors.value
)) {
const locatorId = executionIdToNodeLocatorId(app.rootGraph, executionId)
if (locatorId) {
@@ -522,7 +361,7 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
return errorAncestorExecutionIds.value.has(execId)
}
useNodeErrorFlagSync(surfacedNodeErrors, missingModelStore, missingMediaStore)
useNodeErrorFlagSync(lastNodeErrors, missingModelStore, missingMediaStore)
return {
// Raw state
@@ -541,7 +380,6 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
dismissErrorOverlay,
// Derived state
surfacedNodeErrors,
hasExecutionError,
hasPromptError,
hasNodeError,

View File

@@ -1,16 +0,0 @@
import { computed, ref } from 'vue'
/**
* Storybook mock for `useCurrentUser`.
*
* The real composable reads the Firebase-backed `authStore`, whose setup calls
* `setPersistence` and crashes in the Storybook environment (no Firebase). This
* static stub presents a signed-in user so components that only need the
* display name / email (e.g. WorkspaceActivityContent's member self-scope)
* render without any auth.
*/
export const useCurrentUser = () => ({
userDisplayName: ref('Ada Lovelace'),
userEmail: ref('ada@example.com'),
isLoggedIn: computed(() => true)
})

View File

@@ -1,17 +0,0 @@
import { computed, ref } from 'vue'
/**
* Storybook mock for `useWorkspaceUI`.
*
* The real composable derives its state from the auth/workspace stores that
* pull in Firebase and crash in Storybook. This stub presents a billing
* manager (owner) so role-gated surfaces — the Activity ledger's team-wide
* scope and its per-user footer — render in their fully-populated form.
*/
export function useWorkspaceUI() {
return {
permissions: computed(() => ({ canManageSubscription: true })),
workspaceRole: ref('owner'),
workspaceType: ref('team')
}
}

View File

@@ -1,18 +1,30 @@
import type { NodeError } from '@/schemas/apiSchema'
import type { useExecutionErrorStore } from '@/stores/executionErrorStore'
import type { NodeExecutionId } from '@/types/nodeIdentification'
import { nodeError, validationError } from '@/utils/__tests__/nodeErrorHelpers'
type ExecutionErrorStore = ReturnType<typeof useExecutionErrorStore>
function createRequiredInputMissingNodeError(inputName: string): NodeError {
return {
errors: [
{
type: 'required_input_missing',
message: 'Missing',
details: '',
extra_info: { input_name: inputName }
}
],
dependent_outputs: [],
class_type: 'TestNode'
}
}
export function seedRequiredInputMissingNodeError(
store: ExecutionErrorStore,
executionId: NodeExecutionId,
inputName: string
): void {
store.lastNodeErrors = {
[executionId]: nodeError(
[validationError('required_input_missing', inputName, {}, 'Missing', '')],
'TestNode'
)
[executionId]: createRequiredInputMissingNodeError(inputName)
}
}

View File

@@ -1,30 +0,0 @@
import type { NodeError } from '@/schemas/apiSchema'
import type { NodeValidationError } from '@/utils/executionErrorUtil'
export function validationError(
type: string,
inputName?: string,
extraInfo: Record<string, unknown> = {},
message = `${type} message`,
details = `${type} details`
): NodeValidationError {
return {
type,
message,
details,
...(inputName
? { extra_info: { ...extraInfo, input_name: inputName } }
: {})
}
}
export function nodeError(
errors: NodeValidationError[],
classType = 'InteriorNode'
): NodeError {
return {
class_type: classType,
dependent_outputs: [],
errors
}
}

View File

@@ -210,15 +210,10 @@ describe('formatShortMonthDay', () => {
})
describe('formatClockTime', () => {
it('uses app locale with explicit 12-hour preference', () => {
it('formats time with hours, minutes, and seconds', () => {
const ts = new Date(2024, 5, 15, 14, 5, 6).getTime()
expect(formatClockTime(ts, 'en-US', 'en-u-hc-h12')).toBe('2:05:06 PM')
})
it('uses app locale with explicit 24-hour preference', () => {
const ts = new Date(2024, 5, 15, 14, 5, 6).getTime()
expect(formatClockTime(ts, 'en-US', 'en-u-hc-h23')).toBe('14:05:06')
const result = formatClockTime(ts, 'en-GB')
// en-GB uses 24-hour format
expect(result).toBe('14:05:06')
})
})

View File

@@ -84,27 +84,17 @@ export const formatShortMonthDay = (ts: number, locale: string): string => {
}
/**
* Localized clock time, e.g. "10:05:06" with the app locale for language and
* the browser/system locale preference for 12/24-hour formatting.
* Localized clock time, e.g. "10:05:06" with locale defaults for 12/24 hour.
*
* @param ts Unix timestamp in milliseconds
* @param locale BCP-47 locale string
* @param clockPreferenceLocale Optional locale source for hour-cycle preference
* @returns Localized time string
*/
export const formatClockTime = (
ts: number,
locale: string,
clockPreferenceLocale?: string
): string => {
export const formatClockTime = (ts: number, locale: string): string => {
const d = new Date(ts)
const { hourCycle } = new Intl.DateTimeFormat(clockPreferenceLocale, {
hour: 'numeric'
}).resolvedOptions()
return new Intl.DateTimeFormat(locale, {
hour: 'numeric',
minute: '2-digit',
second: '2-digit',
hourCycle
second: '2-digit'
}).format(d)
}

View File

@@ -105,61 +105,6 @@ export const SIMPLE_ERROR_TYPES = new Set([
'required_input_missing'
])
export type NodeValidationError = NodeError['errors'][number]
export const INPUT_LEVEL_VALIDATION_ERROR_TYPES = new Set([
'required_input_missing',
'bad_linked_input',
'return_type_mismatch',
'invalid_input_type',
'value_smaller_than_min',
'value_bigger_than_max',
'value_not_in_list',
'custom_validation_failed',
'exception_during_inner_validation'
])
export const NODE_LEVEL_VALIDATION_ERROR_TYPES = new Set([
'exception_during_validation',
'dependency_cycle'
])
/** Decodes the `[type, { min, max, ... }]` tuple the backend attaches to range errors. */
export function getInputConfigBounds(error: NodeValidationError): {
min?: unknown
max?: unknown
} {
const config = error.extra_info?.input_config
if (!Array.isArray(config)) return {}
const bounds = config[1]
if (!bounds || typeof bounds !== 'object') return {}
const { min, max } = bounds as { min?: unknown; max?: unknown }
return { min, max }
}
export function isImageNotLoadedValidationError(
error: NodeValidationError
): boolean {
return (
error.type === 'custom_validation_failed' &&
/invalid image file|\[errno 21\].*is a directory/i.test(
[error.message, error.details].filter(Boolean).join('\n')
)
)
}
// Anything not input-level (including unknown types) is node-level.
export function isNodeLevelValidationError(
error: NodeValidationError
): boolean {
return (
!INPUT_LEVEL_VALIDATION_ERROR_TYPES.has(error.type) ||
isImageNotLoadedValidationError(error)
)
}
/**
* Returns true if `value` still violates a recorded range constraint.
* Pass errors already filtered to the target widget (by `input_name`).