fix: allow URL input for free tier users, gate on import button (#10024)

## Summary
- Remove free-tier restriction from the URL input field in
`MissingModelUrlInput.vue` so it is always editable
- Move the subscription check (`canImportModels`) to the Import button
click handler — free-tier users see the upgrade modal only when they
attempt to import
- Extract inline ternary to named `handleImportClick` method for clarity

## Test plan
- [x] Unit tests added (`MissingModelUrlInput.test.ts`) verifying:
  - URL input is always editable regardless of subscription tier
  - Import button calls `handleImport` for paid users
- Import button calls `showUploadDialog` (upgrade modal) for free-tier
users
- [x] Verify URL input is editable for free-tier users on cloud
- [x] Verify clicking Import as free-tier opens the subscription modal
- [x] Verify paid users can import normally without changes

## E2E test rationale
Playwright E2E regression tests are impractical for this change because
`MissingModelUrlInput` only renders when `isAssetSupported` is true,
which requires `isCloud` — a compile-time constant (`__DISTRIBUTION__`).
The OSS test build always sets `isCloud = false`, so the component never
renders in the E2E environment. Unit tests with mocked feature flags
provide equivalent behavioral coverage.
This commit is contained in:
jaeone94
2026-03-16 17:20:48 +09:00
committed by GitHub
parent 8851ab1821
commit f6bc0ff1ee
2 changed files with 193 additions and 31 deletions

View File

@@ -0,0 +1,183 @@
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
const mockPrivateModelsEnabled = vi.hoisted(() => ({ value: true }))
const mockShowUploadDialog = vi.hoisted(() => vi.fn())
const mockHandleUrlInput = vi.hoisted(() => vi.fn())
const mockHandleImport = vi.hoisted(() => vi.fn())
vi.mock('@/composables/useFeatureFlags', () => ({
useFeatureFlags: () => ({
flags: {
get privateModelsEnabled() {
return mockPrivateModelsEnabled.value
}
}
})
}))
vi.mock('@/platform/assets/composables/useModelUpload', () => ({
useModelUpload: () => ({
isUploadButtonEnabled: { value: true },
showUploadDialog: mockShowUploadDialog
})
}))
vi.mock(
'@/platform/missingModel/composables/useMissingModelInteractions',
() => ({
useMissingModelInteractions: () => ({
handleUrlInput: mockHandleUrlInput,
handleImport: mockHandleImport
})
})
)
vi.mock('@/components/rightSidePanel/layout/TransitionCollapse.vue', () => ({
default: {
name: 'TransitionCollapse',
template: '<div><slot /></div>'
}
}))
import MissingModelUrlInput from './MissingModelUrlInput.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
g: { loading: 'Loading' },
rightSidePanel: {
missingModels: {
urlPlaceholder: 'Paste model URL...',
clearUrl: 'Clear URL',
import: 'Import',
importAnyway: 'Import Anyway',
typeMismatch: 'Type mismatch: {detectedType}',
unsupportedUrl: 'Unsupported URL',
metadataFetchFailed: 'Failed to fetch metadata',
importFailed: 'Import failed'
}
}
}
},
missingWarn: false,
fallbackWarn: false
})
const MODEL_KEY = 'supported::checkpoints::model.safetensors'
function mountComponent(
props: Partial<{
modelKey: string
directory: string | null
typeMismatch: string | null
}> = {}
) {
return mount(MissingModelUrlInput, {
props: {
modelKey: MODEL_KEY,
directory: 'checkpoints',
typeMismatch: null,
...props
},
global: {
plugins: [i18n]
}
})
}
describe('MissingModelUrlInput', () => {
beforeEach(() => {
setActivePinia(createPinia())
mockPrivateModelsEnabled.value = true
mockShowUploadDialog.mockClear()
mockHandleUrlInput.mockClear()
mockHandleImport.mockClear()
})
describe('URL input is always editable', () => {
it('input is editable when privateModelsEnabled is true', () => {
mockPrivateModelsEnabled.value = true
const wrapper = mountComponent()
const input = wrapper.find('input')
expect(input.attributes('readonly')).toBeUndefined()
})
it('input is editable when privateModelsEnabled is false (free tier)', () => {
mockPrivateModelsEnabled.value = false
const wrapper = mountComponent()
const input = wrapper.find('input')
expect(input.attributes('readonly')).toBeUndefined()
})
it('input accepts user typing when privateModelsEnabled is false', async () => {
mockPrivateModelsEnabled.value = false
const wrapper = mountComponent()
const input = wrapper.find('input')
input.element.value = 'https://example.com/model.safetensors'
await input.trigger('input')
expect(mockHandleUrlInput).toHaveBeenCalledWith(
MODEL_KEY,
'https://example.com/model.safetensors'
)
})
})
describe('Import button gates on subscription', () => {
it('calls handleImport when privateModelsEnabled is true', async () => {
mockPrivateModelsEnabled.value = true
const store = useMissingModelStore()
store.urlMetadata[MODEL_KEY] = {
filename: 'model.safetensors',
content_length: 1024,
final_url: 'https://example.com/model.safetensors'
}
const wrapper = mountComponent()
const importBtn = wrapper
.findAll('button')
.find((b) => b.text().includes('Import'))
expect(importBtn).toBeTruthy()
await importBtn!.trigger('click')
expect(mockHandleImport).toHaveBeenCalledWith(MODEL_KEY, 'checkpoints')
expect(mockShowUploadDialog).not.toHaveBeenCalled()
})
it('calls showUploadDialog when privateModelsEnabled is false (free tier)', async () => {
mockPrivateModelsEnabled.value = false
const store = useMissingModelStore()
store.urlMetadata[MODEL_KEY] = {
filename: 'model.safetensors',
content_length: 1024,
final_url: 'https://example.com/model.safetensors'
}
const wrapper = mountComponent()
const importBtn = wrapper
.findAll('button')
.find((b) => b.text().includes('Import'))
expect(importBtn).toBeTruthy()
await importBtn!.trigger('click')
expect(mockShowUploadDialog).toHaveBeenCalled()
expect(mockHandleImport).not.toHaveBeenCalled()
})
it('clear button works for free-tier users', async () => {
mockPrivateModelsEnabled.value = false
const store = useMissingModelStore()
store.urlInputs[MODEL_KEY] = 'https://example.com/model.safetensors'
const wrapper = mountComponent()
const clearBtn = wrapper.find('button[aria-label="Clear URL"]')
await clearBtn.trigger('click')
expect(mockHandleUrlInput).toHaveBeenCalledWith(MODEL_KEY, '')
})
})
})

View File

@@ -1,13 +1,6 @@
<template>
<div
:class="
cn(
'flex h-8 items-center rounded-lg border border-transparent bg-secondary-background px-3 transition-colors focus-within:border-interface-stroke',
!canImportModels && 'cursor-pointer'
)
"
v-bind="upgradePromptAttrs"
@click="!canImportModels && showUploadDialog()"
class="flex h-8 items-center rounded-lg border border-transparent bg-secondary-background px-3 transition-colors focus-within:border-interface-stroke"
>
<label :for="`url-input-${modelKey}`" class="sr-only">
{{ t('rightSidePanel.missingModels.urlPlaceholder') }}
@@ -16,14 +9,8 @@
:id="`url-input-${modelKey}`"
type="text"
:value="urlInputs[modelKey] ?? ''"
:readonly="!canImportModels"
:placeholder="t('rightSidePanel.missingModels.urlPlaceholder')"
:class="
cn(
'text-foreground w-full border-none bg-transparent text-xs outline-none placeholder:text-muted-foreground',
!canImportModels && 'pointer-events-none opacity-60'
)
"
class="text-foreground w-full border-none bg-transparent text-xs outline-none placeholder:text-muted-foreground"
@input="
handleUrlInput(modelKey, ($event.target as HTMLInputElement).value)
"
@@ -73,7 +60,7 @@
variant="primary"
class="h-9 w-full justify-center gap-2 text-sm font-semibold"
:loading="urlImporting[modelKey]"
@click="handleImport(modelKey, directory)"
@click="handleImportClick"
>
<i aria-hidden="true" class="icon-[lucide--download] size-4" />
{{
@@ -113,7 +100,6 @@
import { computed } from 'vue'
import { storeToRefs } from 'pinia'
import { useI18n } from 'vue-i18n'
import { cn } from '@/utils/tailwindUtil'
import Button from '@/components/ui/button/Button.vue'
import TransitionCollapse from '@/components/rightSidePanel/layout/TransitionCollapse.vue'
import { formatSize } from '@/utils/formatUtil'
@@ -139,18 +125,11 @@ const { urlInputs, urlMetadata, urlFetching, urlErrors, urlImporting } =
const { handleUrlInput, handleImport } = useMissingModelInteractions()
const upgradePromptAttrs = computed(() =>
canImportModels.value
? {}
: {
role: 'button',
tabindex: 0,
onKeydown: (e: KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
showUploadDialog()
}
}
}
)
function handleImportClick() {
if (canImportModels.value) {
handleImport(modelKey, directory)
} else {
showUploadDialog()
}
}
</script>