Compare commits

...

2 Commits

Author SHA1 Message Date
Glary-Bot
fb93e936e3 test: query SubscribeToRun by accessible role; cache lazy billing context
Address CodeRabbit review:
- Switch all SubscribeToRun.test queries from getByTestId to getByRole,
  matching the repo's testing-library guidelines.
- Cache the lazily-resolved useBillingContext per component instance so
  repeated clicks reuse the same shared-composable handle instead of
  re-invoking createSharedComposable each time. New regression test
  asserts three clicks resolve the context exactly once.
2026-06-30 23:11:32 +00:00
Glary-Bot
060fb5c382 fix(billing): split useWorkspacePermissions out of useWorkspaceUI to break setup-time cycle
SubscribeToRun.vue was reading useWorkspaceUI() at component setup,
which transitively re-enters useBillingContext via the
useBillingContext -> useWorkspaceBilling -> useWorkspaceUI cycle.
When the component mounts during the boot-time window where the
billing context is still being constructed (e.g. via the run-button
wrapper rerunning its computed after PostHogTelemetryProvider reads
subscription state), the cycle resolves a half-built context and
the button defaults to Subscribe-to-Run for already-subscribed users.

bfa534fc0 fixed the same shape inside useSubscriptionDialog by
deferring the useWorkspaceUI() read until showPricingTable() runs.
SubscribeToRun.vue cannot use that pattern because it needs
permissions at render time, so this commit splits the billing-free
parts of useWorkspaceUI into useWorkspacePermissions and points
SubscribeToRun at the new composable. useBillingContext is also
moved off the setup path and resolved lazily inside the click
handler, matching the showPricingTable shape.

Regression tests assert that neither useBillingContext nor (via the
new composable) the billing dependency chain is touched at component
setup.
2026-06-30 22:45:20 +00:00
3 changed files with 84 additions and 25 deletions

View File

@@ -8,17 +8,18 @@ import { createI18n } from 'vue-i18n'
import SubscribeToRun from './SubscribeToRun.vue'
const mockShowSubscriptionDialog = vi.fn()
const mockUseBillingContext = vi.fn(() => ({
showSubscriptionDialog: mockShowSubscriptionDialog
}))
const mockCanManageSubscription = ref(true)
const mockIsMdOrLarger = ref(true)
vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: () => ({
showSubscriptionDialog: mockShowSubscriptionDialog
})
useBillingContext: () => mockUseBillingContext()
}))
vi.mock('@/platform/workspace/composables/useWorkspaceUI', () => ({
useWorkspaceUI: () => ({
useWorkspacePermissions: () => ({
permissions: computed(() => ({
canManageSubscription: mockCanManageSubscription.value
}))
@@ -81,24 +82,24 @@ describe('SubscribeToRun', () => {
it('shows the subscribe label for owners who can manage the subscription', () => {
renderButton()
expect(screen.getByTestId('subscribe-to-run-button')).toHaveTextContent(
'Subscribe to Run'
)
expect(
screen.getByRole('button', { name: /subscribe to run/i })
).toBeInTheDocument()
})
it('shows a neutral run label for members who cannot subscribe', () => {
mockCanManageSubscription.value = false
renderButton()
const button = screen.getByTestId('subscribe-to-run-button')
expect(button).toHaveTextContent('Run')
const button = screen.getByRole('button', { name: 'Run' })
expect(button).toBeInTheDocument()
expect(button).not.toHaveTextContent('Subscribe')
})
it('opens the subscription dialog for owners on click', async () => {
const { user } = renderButton()
await user.click(screen.getByTestId('subscribe-to-run-button'))
await user.click(screen.getByRole('button', { name: /subscribe to run/i }))
expect(mockShowSubscriptionDialog).toHaveBeenCalledOnce()
})
@@ -107,8 +108,38 @@ describe('SubscribeToRun', () => {
mockCanManageSubscription.value = false
const { user } = renderButton()
await user.click(screen.getByTestId('subscribe-to-run-button'))
await user.click(screen.getByRole('button', { name: 'Run' }))
expect(mockShowSubscriptionDialog).toHaveBeenCalledOnce()
})
describe('billing context import cycle', () => {
it('does not resolve useBillingContext at component setup', () => {
renderButton()
expect(mockUseBillingContext).not.toHaveBeenCalled()
})
it('resolves useBillingContext lazily when the button is clicked', async () => {
const { user } = renderButton()
expect(mockUseBillingContext).not.toHaveBeenCalled()
await user.click(
screen.getByRole('button', { name: /subscribe to run/i })
)
expect(mockUseBillingContext).toHaveBeenCalled()
})
it('reuses the billing context across repeated clicks', async () => {
const { user } = renderButton()
const button = screen.getByRole('button', { name: /subscribe to run/i })
await user.click(button)
await user.click(button)
await user.click(button)
expect(mockUseBillingContext).toHaveBeenCalledOnce()
expect(mockShowSubscriptionDialog).toHaveBeenCalledTimes(3)
})
})
})

View File

@@ -24,16 +24,22 @@ import Button from '@/components/ui/button/Button.vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useRunButtonTelemetry } from '@/composables/useRunButtonTelemetry'
import { isCloud } from '@/platform/distribution/types'
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
import { useWorkspacePermissions } from '@/platform/workspace/composables/useWorkspaceUI'
const { t } = useI18n()
const breakpoints = useBreakpoints(breakpointsTailwind)
const isMdOrLarger = breakpoints.greaterOrEqual('md')
const { permissions } = useWorkspaceUI()
const { showSubscriptionDialog } = useBillingContext()
const { permissions } = useWorkspacePermissions()
const { trackRunButton } = useRunButtonTelemetry()
let billingContext: ReturnType<typeof useBillingContext> | undefined
function getBillingContext() {
billingContext ??= useBillingContext()
return billingContext
}
const canResubscribe = computed(() => permissions.value.canManageSubscription)
const buttonLabel = computed(() => {
@@ -54,6 +60,6 @@ function handleSubscribeToRun() {
trackRunButton({ subscribe_to_run: true })
}
showSubscriptionDialog()
getBillingContext().showSubscriptionDialog()
}
</script>

View File

@@ -156,17 +156,13 @@ function isOriginalOwnerByEmail(
return original.email.toLowerCase() === email
}
/**
* Internal implementation of UI configuration composable.
*/
function useWorkspaceUIInternal() {
// Billing-free permissions: must not read useBillingContext, directly or
// indirectly. useWorkspaceUI sits inside the
// useBillingContext -> useWorkspaceBilling -> useWorkspaceUI cycle, so any
// component whose render path needs only permissions must use this composable
// to avoid re-entering a half-built billing context at setup time.
function useWorkspacePermissionsInternal() {
const store = useTeamWorkspaceStore()
const { userEmail } = useCurrentUser()
const { isActiveSubscription, subscription } = useBillingContext()
const isInPersonalWorkspace = computed(() => store.isInPersonalWorkspace)
const isWorkspaceSubscribed = computed(() => store.isWorkspaceSubscribed)
const members = computed(() => store.members)
const workspaceType = computed<WorkspaceType>(
() => store.activeWorkspace?.type ?? 'personal'
@@ -202,6 +198,32 @@ function useWorkspaceUIInternal() {
getUIConfig(workspaceType.value, workspaceRole.value)
)
return {
permissions,
uiConfig,
workspaceType,
workspaceRole
}
}
export const useWorkspacePermissions = createSharedComposable(
useWorkspacePermissionsInternal
)
/**
* Internal implementation of UI configuration composable.
*/
function useWorkspaceUIInternal() {
const store = useTeamWorkspaceStore()
const { userEmail } = useCurrentUser()
const { isActiveSubscription, subscription } = useBillingContext()
const { permissions, uiConfig, workspaceType, workspaceRole } =
useWorkspacePermissions()
const isInPersonalWorkspace = computed(() => store.isInPersonalWorkspace)
const isWorkspaceSubscribed = computed(() => store.isWorkspaceSubscribed)
const members = computed(() => store.members)
// Cancel / reactivate / delete are original-owner-only; personal workspaces
// are single-member, so the user is always their own original owner.
const isOriginalOwner = computed(() => {