Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions browser_tests/assets/missing/missing_models_gated.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
{
"last_node_id": 1,
"last_link_id": 1,
"nodes": [
{
"id": 1,
"type": "CheckpointLoaderSimple",
"pos": [256, 256],
"size": [315, 98],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "MODEL",
"type": "MODEL",
"links": null
},
{
"name": "CLIP",
"type": "CLIP",
"links": null
},
{
"name": "VAE",
"type": "VAE",
"links": null
}
],
"properties": {
"Node name for S&R": "CheckpointLoaderSimple",
"models": [
{
"name": "gated_model.safetensors",
"url": "https://huggingface.co/comfy-e2e/gated-test/resolve/main/gated_model.safetensors",
"directory": "checkpoints"
}
]
},
"widgets_values": ["gated_model.safetensors"]
}
],
"links": [],
"groups": [],
"config": {},
"extra": {
"ds": {
"offset": [0, 0],
"scale": 1
}
},
"version": 0.4
}
2 changes: 2 additions & 0 deletions browser_tests/fixtures/selectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@ export const TestIds = {
missingModelReferenceCount: 'missing-model-reference-count',
missingModelUnsupportedSection:
'missing-model-import-not-supported-section',
missingModelGatedAccess: 'missing-model-gated-access',
missingModelGatedHint: 'missing-model-gated-hint',
missingModelDownload: 'missing-model-download',
missingModelActions: 'missing-model-actions',
missingModelDownloadAll: 'missing-model-download-all',
Expand Down
67 changes: 67 additions & 0 deletions browser_tests/tests/propertiesPanel/errorsTabMissingModels.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
} from '@e2e/fixtures/helpers/ErrorsTabHelper'

const FAKE_MODEL_NAME = 'fake_model.safetensors'
const GATED_MODEL_REPO_URL = 'https://huggingface.co/comfy-e2e/gated-test'

function getModelLabel(group: Locator, modelName: string = FAKE_MODEL_NAME) {
return group.getByRole('button', { name: modelName, exact: true })
Expand Down Expand Up @@ -160,5 +161,71 @@ test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
comfyPage.page.getByTestId(TestIds.dialogs.missingModelsGroup)
).toBeHidden()
})

test.describe('Gated Hugging Face model', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.page
.context()
.route('https://huggingface.co/**', async (route) => {
if (route.request().method() === 'HEAD') {
return route.fulfill({
status: 403,
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Expose-Headers': 'X-Error-Code',
'X-Error-Code': 'GatedRepo'
}
})
}

return route.fulfill({
status: 200,
contentType: 'text/html',
body: '<html><body>stub repo page</body></html>'
})
})
await loadWorkflowAndOpenErrorsTab(
comfyPage,
'missing/missing_models_gated'
)
})

test('Should classify a 403 model as gated', async ({ comfyPage }) => {
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.missingModelGatedAccess)
).toBeVisible()
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.missingModelGatedHint)
).toBeVisible()
})

test('Should keep Download available for a gated model', async ({
comfyPage
}) => {
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.missingModelGatedAccess)
).toBeVisible()

const downloadButton = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelDownload
)

await expect(downloadButton).toBeVisible()
await expect(downloadButton).toBeEnabled()
await expect(downloadButton).toHaveText('Download')
})

test('Should open the gated repository with the browser fallback', async ({
comfyPage
}) => {
const pagePromise = comfyPage.page.context().waitForEvent('page')
await comfyPage.page
.getByTestId(TestIds.dialogs.missingModelGatedAccess)
.click()
const accessPage = await pagePromise

await expect(accessPage).toHaveURL(GATED_MODEL_REPO_URL)
})
})
})
})
5 changes: 5 additions & 0 deletions packages/comfyui-desktop-bridge-types/comfyDesktopBridge.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,12 @@ export interface ComfyDesktop2TelemetryBridge {
}

export interface ComfyDesktop2Bridge {
/** Reports whether the backend server is cloud/remote, not the user's location. */
isRemote(): boolean
/** Opens a model provider access page in the hosted frontend's browser session.
* Resolves `true` when the host has taken ownership of the request.
* On `false` or rejection the frontend falls back to opening a new tab. */
openModelAccessPage?: (url: string) => Promise<boolean>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick: (non-blocking) The downloadUrlToHfRepoUrl utility used to derive gatedRepoUrl has a catch block that returns the raw url argument unchanged on parse failure. Since isHuggingFaceRepoUrl uses new URL() too and would fail on the same malformed input, this path is unreachable via fetchHeadMetadata. But the function's own contract is broken: it claims to return an HF repo URL but can return anything. A safer fallback would be return 'https://huggingface.co/' so future callers that skip the hostname check don't receive arbitrary values.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I left the raw fallback unchanged because it is a pre-existing utility-contract issue and remains unreachable from the gated flow. In 7caff34, both the composable and openGatedRepoPage independently require the exact https://huggingface.co origin, so a malformed/raw fallback cannot reach browser navigation or Electron IPC. I would prefer to clean up the broader conversion utility contract in a focused follow-up if it gains additional callers.

downloadModel?: (
url: string,
filename: string,
Expand Down
2 changes: 1 addition & 1 deletion packages/comfyui-desktop-bridge-types/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@comfyorg/comfyui-desktop-bridge-types",
"version": "0.1.3",
"version": "0.1.4",
"description": "TypeScript definitions for the Comfy Desktop hosted frontend bridge",
"homepage": "https://comfy.org",
"license": "MIT",
Expand Down
4 changes: 4 additions & 0 deletions src/locales/en/main.json
Original file line number Diff line number Diff line change
Expand Up @@ -4005,7 +4005,11 @@
"importNotSupported": "Import Not Supported",
"copyModelName": "Copy model name",
"copyUrl": "Copy URL",
"gatedModelsHint": "Some models are gated. To download them, sign in to Hugging Face and accept the model license agreement.",
"gatedModelTooltip": "This model is gated and requires you to be logged in to Hugging Face and to accept its license agreement.",
Comment thread
coderabbitai[bot] marked this conversation as resolved.
"gatedModelDownloadTooltip": "Download may require signing in to Hugging Face first.",
"locateNode": "Locate node on canvas",
"openHuggingFaceRepo": "Open Hugging Face repo",
"expandNodes": "Show referencing nodes",
"collapseNodes": "Hide referencing nodes",
"unknownCategory": "Unknown",
Expand Down
95 changes: 95 additions & 0 deletions src/platform/missingModel/components/MissingModelCard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,28 @@ import { render, screen, within } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import PrimeVue from 'primevue/config'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'

import enMessages from '@/locales/en/main.json' with { type: 'json' }
import type {
MissingModelGroup,
MissingModelViewModel
} from '@/platform/missingModel/types'
import type * as MissingModelDownload from '@/platform/missingModel/missingModelDownload'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'

const mockDownloadModel = vi.hoisted(() => vi.fn())

vi.mock('@/platform/missingModel/missingModelDownload', async () => {
const actual = await vi.importActual<typeof MissingModelDownload>(
'@/platform/missingModel/missingModelDownload'
)
return {
...actual,
downloadModel: mockDownloadModel
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
})

vi.mock('./MissingModelRow.vue', () => ({
default: {
Expand Down Expand Up @@ -131,6 +146,7 @@ function getRowsIn(testId: string) {

describe('MissingModelCard', () => {
beforeEach(() => {
vi.clearAllMocks()
mockIsCloud.value = true
})

Expand Down Expand Up @@ -241,6 +257,21 @@ describe('MissingModelCard', () => {
screen.queryByTestId('missing-model-actions')
).not.toBeInTheDocument()
})

it('does not show gated model guidance in cloud', async () => {
const group = makeGroup({ withDownloadUrls: true })
const url =
'https://huggingface.co/comfy/test/resolve/main/model.safetensors'
mountCard({ missingModelGroups: [group] })

useMissingModelStore().gatedRepoUrls[url] =
'https://huggingface.co/comfy/test'
await nextTick()

expect(
screen.queryByTestId('missing-model-gated-hint')
).not.toBeInTheDocument()
})
})

describe('Event Handling', () => {
Expand All @@ -256,6 +287,7 @@ describe('MissingModelCard', () => {

describe('MissingModelCard (OSS)', () => {
beforeEach(() => {
vi.clearAllMocks()
mockIsCloud.value = false
})

Expand Down Expand Up @@ -293,6 +325,69 @@ describe('MissingModelCard (OSS)', () => {
).toBeVisible()
})

it('shows gated model guidance in OSS', async () => {
const group = makeGroup({ withDownloadUrls: true })
const url =
'https://huggingface.co/comfy/test/resolve/main/model.safetensors'
mountCard({ missingModelGroups: [group] })

useMissingModelStore().gatedRepoUrls[url] =
'https://huggingface.co/comfy/test'
await nextTick()

expect(screen.getByRole('note')).toHaveTextContent(
'Some models are gated. To download them, sign in to Hugging Face and accept the model license agreement.'
)
})

it('does not show gated guidance for a model that is not downloadable', async () => {
const group = makeGroup({
modelNames: ['model.bin'],
withDownloadUrls: true
})
const url = 'https://huggingface.co/comfy/test/resolve/main/model.bin'
mountCard({ missingModelGroups: [group] })

useMissingModelStore().gatedRepoUrls[url] =
'https://huggingface.co/comfy/test'
await nextTick()

expect(
screen.queryByTestId('missing-model-gated-hint')
).not.toBeInTheDocument()
})

it('routes Download all through the shared missing-model download handler', async () => {
mountCard({
missingModelGroups: [
makeGroup({
withDownloadUrls: true,
modelNames: ['first.safetensors', 'second.safetensors']
})
]
})

await userEvent.click(screen.getByTestId('missing-model-download-all'))

expect(mockDownloadModel).toHaveBeenCalledTimes(2)
expect(mockDownloadModel).toHaveBeenCalledWith(
{
name: 'first.safetensors',
url: 'https://huggingface.co/comfy/test/resolve/main/first.safetensors',
directory: 'checkpoints'
},
{}
)
expect(mockDownloadModel).toHaveBeenCalledWith(
{
name: 'second.safetensors',
url: 'https://huggingface.co/comfy/test/resolve/main/second.safetensors',
directory: 'checkpoints'
},
{}
)
})

Comment thread
coderabbitai[bot] marked this conversation as resolved.
it('hides Download all when no model is downloadable', () => {
mountCard()

Expand Down
26 changes: 24 additions & 2 deletions src/platform/missingModel/components/MissingModelCard.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,20 @@
<template>
<div class="px-3">
<div
v-if="showGatedModelsHint"
data-testid="missing-model-gated-hint"
role="note"
class="mb-2 flex gap-2 rounded-md border border-warning-background/30 bg-warning-background/10 p-2.5"
>
<i
aria-hidden="true"
class="mt-0.5 icon-[lucide--lock] size-4 shrink-0 text-warning-background"
/>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick: (non-blocking) The gated-hint banner conveys its warning state through color alone (text-warning-background, border-warning-background). Users with color vision deficiencies may not distinguish it from ordinary informational content. A short text prefix ("Note:" or similar) or a non-color visual indicator alongside the lock icon would satisfy WCAG 1.4.1 Use of Color.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The banner already has two non-color indicators: a visible lock icon and explicit text stating that some models are gated and require Hugging Face sign-in/license acceptance. Color is only supplemental styling here, so I kept the current copy rather than adding a generic “Note:” prefix.

<p class="m-0 text-xs/relaxed text-warning-background">
{{ t('rightSidePanel.missingModels.gatedModelsHint') }}
</p>
</div>

<div
v-if="importableModelRows.length > 0"
data-testid="missing-model-importable-rows"
Expand Down Expand Up @@ -68,7 +83,7 @@ import type { MissingModelGroup } from '@/platform/missingModel/types'
import { isCloud } from '@/platform/distribution/types'
import MissingModelRow from '@/platform/missingModel/components/MissingModelRow.vue'
import Button from '@/components/ui/button/Button.vue'
import { downloadModel } from '@/platform/missingModel/missingModelDownload'
import { useMissingModelDownload } from '@/platform/missingModel/composables/useMissingModelDownload'
import { getDownloadableModels } from '@/platform/missingModel/missingModelViewUtils'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { formatSize } from '@/utils/formatUtil'
Expand Down Expand Up @@ -100,6 +115,7 @@ const emit = defineEmits<{

const { t } = useI18n()
const missingModelStore = useMissingModelStore()
const { downloadMissingModel } = useMissingModelDownload()

const sortedModelRows = computed(() =>
missingModelGroups
Expand Down Expand Up @@ -128,6 +144,12 @@ const downloadableModels = computed(() => {
return getDownloadableModels(missingModelGroups)
})

const showGatedModelsHint = computed(() =>
downloadableModels.value.some(
(model) => !!missingModelStore.gatedRepoUrls[model.url]
)
)

const downloadAllLabel = computed(() => {
const base = t('rightSidePanel.missingModels.downloadAll')
const total = downloadableModels.value.reduce(
Expand All @@ -139,7 +161,7 @@ const downloadAllLabel = computed(() => {

function downloadAllModels() {
for (const model of downloadableModels.value) {
downloadModel(model, missingModelStore.folderPaths)
downloadMissingModel(model)
}
}

Expand Down
Loading
Loading