Files
ComfyUI_frontend/browser_tests/fixtures/selectors.ts
jaeone94 b4d209b5f6 feat: refresh missing models through pipeline (#11661)
## Summary

Follow-up to the closed earlier attempt in #11646. This PR keeps the
same user-facing goal, but changes the implementation to reuse the
existing missing model pipeline for refresh instead of maintaining a
separate candidate-only recheck path.

Adds a missing model refresh action in the Errors tab by reusing the
existing missing model pipeline, so users can re-check models after
downloading or manually placing files without reloading the workflow.

## Changes

- **What**:
- Adds `app.refreshMissingModels()` as a reusable refresh entry point
for the current root graph.
- Splits node definition reloading into `app.reloadNodeDefs()` so
missing-model refresh can pull fresh `object_info` without showing the
generic combo refresh success flow.
- Reuses the existing missing model pipeline instead of adding a
separate candidate-only checker. The refresh path serializes the current
graph, reuses active workflow model metadata when available, falls back
to current missing-model metadata, and then reruns the same candidate
discovery/enrichment/surfacing flow used during workflow load.
- Adds missing model refresh state and error handling to
`missingModelStore`.
- Adds a Refresh button next to Download all in the missing model card
action bar.
- Moves Download all from the Errors tab header into the missing model
card, so the Download all and Refresh actions render or hide together.
- Changes Download all visibility from “more than one downloadable
model” to “at least one downloadable model.”
- Keeps the action bar hidden when there are no downloadable missing
models; Cloud still does not render this action area.
- Normalizes active workflow `pendingWarnings` updates so resolved
missing model warnings do not get revived by stale empty warning
objects.
- Adds test IDs and coverage for the new action bar, refresh state,
refresh delegation, pending warning sync, and E2E refresh behavior.
- **Breaking**: None.
- **Dependencies**: None.

## Review Focus

The main design choice is intentionally reusing the missing model
pipeline for refresh instead of implementing a smaller candidate-only
recheck.

The earlier candidate-only approach was cheaper, but it created a
separate source of truth for missing-model resolution and made edge
cases harder to reason about. In particular, it could diverge from the
behavior used when a workflow is loaded, and it did not naturally handle
the case where a model becomes missing after the workflow is already
open. This version pays the cost of refreshing node definitions and
rerunning the missing-model scan for the current graph, but keeps the
refresh behavior aligned with workflow load semantics.

Expected behavior by environment:

- OSS browser:
- The action bar appears when at least one missing model has a
downloadable URL and directory.
  - Download all uses the existing browser download path.
- Refresh reloads `object_info`, refreshes node definitions/combo
values, reruns missing-model detection for the current graph, and clears
the error if the selected model is now available.
- OSS desktop:
- The same action bar appears under the same downloadable-model
condition.
  - Download all uses the existing Electron DownloadManager path.
- Refresh uses the same missing-model pipeline as browser, so manually
placed files or desktop-downloaded files can be rechecked without
reloading the workflow.
- Cloud:
- The action bar remains hidden because model download/import is not
supported in this section for Cloud.

A few boundaries are intentional:

- This PR does not add automatic filesystem watching. Browser OSS cannot
reliably observe local model folder changes, so the user-triggered
Refresh button remains the cross-environment mechanism.
- This PR does not redesign the public `refreshComboInNodes` API beyond
extracting `reloadNodeDefs()` for reuse. Further cleanup of toast
behavior or a more explicit object-info reload API can be follow-up
work.
- This PR keeps refresh scoped to missing-model validation; missing
media and missing nodes continue to use their existing flows.

Linear: FE-417

## Screenshots (if applicable)


https://github.com/user-attachments/assets/2e02799f-1374-4377-b7b3-172241517772


## Validation

- `pnpm format`
- `pnpm lint` (passes; existing unrelated warning remains in
`src/platform/workspace/composables/useWorkspaceBilling.test.ts`)
- `pnpm typecheck`
- `pnpm test:unit`
- `pnpm test:browser:local -- --project=chromium
browser_tests/tests/propertiesPanel/errorsTabMissingModels.spec.ts`
- `pnpm build`
- `NX_SKIP_NX_CACHE=true DISTRIBUTION=desktop USE_PROD_CONFIG=true
NODE_OPTIONS='--max-old-space-size=8192' pnpm exec nx build`
- Manual desktop verification through `~/Projects/desktop` after copying
the desktop build into `assets/ComfyUI/web_custom_versions/desktop_app`:
  - confirmed the FE bundle is built with `DISTRIBUTION = "desktop"`
- confirmed missing model Download uses the desktop download path
instead of browser download
- confirmed Refresh can clear the missing model error after the model is
available
- Push hook: `pnpm knip --cache`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11661-feat-refresh-missing-models-through-pipeline-34f6d73d3650811488defee54a7a6667)
by [Unito](https://www.unito.io)
2026-04-27 18:53:50 +00:00

251 lines
8.6 KiB
TypeScript

/**
* Centralized test selectors for browser tests.
* Use data-testid attributes for stable selectors.
*/
export const TestIds = {
sidebar: {
toolbar: 'side-toolbar',
nodeLibrary: 'node-library-tree',
nodeLibrarySearch: 'node-library-search',
workflows: 'workflows-sidebar',
modeToggle: 'mode-toggle'
},
tree: {
folder: 'tree-folder',
leaf: 'tree-leaf',
node: 'tree-node'
},
canvas: {
main: 'graph-canvas',
contextMenu: 'canvas-context-menu',
toggleMinimapButton: 'toggle-minimap-button',
closeMinimapButton: 'close-minimap-button',
minimapContainer: 'minimap-container',
minimapCanvas: 'minimap-canvas',
minimapViewport: 'minimap-viewport',
minimapInteractionOverlay: 'minimap-interaction-overlay',
toggleLinkVisibilityButton: 'toggle-link-visibility-button',
zoomControlsButton: 'zoom-controls-button',
zoomInAction: 'zoom-in-action',
zoomOutAction: 'zoom-out-action',
zoomToFitAction: 'zoom-to-fit-action',
zoomPercentageInput: 'zoom-percentage-input'
},
dialogs: {
settings: 'settings-dialog',
settingsContainer: 'settings-container',
settingsTabAbout: 'settings-tab-about',
confirm: 'confirm-dialog',
errorOverlay: 'error-overlay',
errorOverlaySeeErrors: 'error-overlay-see-errors',
errorOverlayDismiss: 'error-overlay-dismiss',
errorOverlayMessages: 'error-overlay-messages',
runtimeErrorPanel: 'runtime-error-panel',
missingNodeCard: 'missing-node-card',
errorCardFindOnGithub: 'error-card-find-on-github',
errorCardCopy: 'error-card-copy',
errorDialog: 'error-dialog',
errorDialogShowReport: 'error-dialog-show-report',
errorDialogContactSupport: 'error-dialog-contact-support',
errorDialogCopyReport: 'error-dialog-copy-report',
errorDialogFindIssues: 'error-dialog-find-issues',
about: 'about-panel',
whatsNewSection: 'whats-new-section',
missingNodePacksGroup: 'error-group-missing-node',
missingModelsGroup: 'error-group-missing-model',
missingModelExpand: 'missing-model-expand',
missingModelLocate: 'missing-model-locate',
missingModelCopyName: 'missing-model-copy-name',
missingModelCopyUrl: 'missing-model-copy-url',
missingModelDownload: 'missing-model-download',
missingModelActions: 'missing-model-actions',
missingModelDownloadAll: 'missing-model-download-all',
missingModelRefresh: 'missing-model-refresh',
missingModelImportUnsupported: 'missing-model-import-unsupported',
missingMediaGroup: 'error-group-missing-media',
missingMediaRow: 'missing-media-row',
missingMediaUploadDropzone: 'missing-media-upload-dropzone',
missingMediaLibrarySelect: 'missing-media-library-select',
missingMediaStatusCard: 'missing-media-status-card',
missingMediaConfirmButton: 'missing-media-confirm-button',
missingMediaCancelButton: 'missing-media-cancel-button',
missingMediaLocateButton: 'missing-media-locate-button',
publishTabPanel: 'publish-tab-panel',
apiSignin: 'api-signin-dialog',
updatePassword: 'update-password-dialog',
cloudNotification: 'cloud-notification-dialog'
},
keybindings: {
presetMenu: 'keybinding-preset-menu'
},
nodeTemplates: {
manageDialog: 'manage-node-templates-dialog'
},
topbar: {
queueButton: 'queue-button',
queueModeMenuTrigger: 'queue-mode-menu-trigger',
saveButton: 'save-workflow-button',
subscribeButton: 'topbar-subscribe-button'
},
nodeLibrary: {
bookmarksSection: 'node-library-bookmarks-section'
},
propertiesPanel: {
root: 'properties-panel',
errorsTab: 'panel-tab-errors'
},
subgraphEditor: {
toggle: 'subgraph-editor-toggle',
shownSection: 'subgraph-editor-shown-section',
hiddenSection: 'subgraph-editor-hidden-section',
widgetToggle: 'subgraph-widget-toggle',
widgetLabel: 'subgraph-widget-label',
iconLink: 'icon-link',
iconEye: 'icon-eye',
widgetActionsMenuButton: 'widget-actions-menu-button'
},
node: {
titleInput: 'node-title-input',
pinIndicator: 'node-pin-indicator',
innerWrapper: 'node-inner-wrapper',
mainImage: 'main-image'
},
selectionToolbox: {
root: 'selection-toolbox',
colorPickerButton: 'color-picker-button',
colorPickerCurrentColor: 'color-picker-current-color',
colorBlue: 'blue',
colorRed: 'red'
},
menu: {
moreMenuContent: 'more-menu-content'
},
helpCenter: {
button: 'help-center-button',
popup: 'help-center-popup',
backdrop: 'help-center-backdrop',
menuItem: (key: string) => `help-menu-item-${key}`,
releaseItem: (version: string) => `help-release-item-${version}`
},
widgets: {
container: 'node-widgets',
widget: 'node-widget',
decrement: 'decrement',
increment: 'increment',
domWidgetTextarea: 'dom-widget-textarea',
subgraphEnterButton: 'subgraph-enter-button'
},
builder: {
footerNav: 'builder-footer-nav',
saveButton: 'builder-save-button',
saveAsButton: 'builder-save-as-button',
saveGroup: 'builder-save-group',
saveAsChevron: 'builder-save-as-chevron',
ioItem: 'builder-io-item',
ioItemTitle: 'builder-io-item-title',
ioItemSubtitle: 'builder-io-item-subtitle',
widgetActionsMenu: 'widget-actions-menu',
opensAs: 'builder-opens-as',
widgetItem: 'builder-widget-item',
widgetLabel: 'builder-widget-label',
outputPlaceholder: 'builder-output-placeholder',
connectOutputPopover: 'builder-connect-output-popover',
connectOutputSwitch: 'builder-connect-output-switch',
emptyWorkflowDialog: 'builder-empty-workflow-dialog',
emptyWorkflowBack: 'builder-empty-workflow-back',
emptyWorkflowLoadTemplate: 'builder-empty-workflow-load-template'
},
outputHistory: {
outputs: 'linear-outputs',
welcome: 'linear-welcome',
outputInfo: 'linear-output-info',
activeQueue: 'linear-job',
queueBadge: 'linear-job-badge',
inProgressItem: 'linear-in-progress-item',
historyItem: 'linear-history-item',
skeleton: 'linear-skeleton',
latentPreview: 'linear-latent-preview',
imageOutput: 'linear-image-output',
videoOutput: 'linear-video-output',
cancelRun: 'linear-cancel-run',
headerProgressBar: 'linear-header-progress-bar',
itemProgressBar: 'linear-item-progress-bar',
progressOverall: 'linear-progress-overall',
progressNode: 'linear-progress-node'
},
appMode: {
widgetItem: 'app-mode-widget-item',
welcome: 'linear-welcome',
emptyWorkflow: 'linear-welcome-empty-workflow',
buildApp: 'linear-welcome-build-app',
backToWorkflow: 'linear-welcome-back-to-workflow',
loadTemplate: 'linear-welcome-load-template',
arrangePreview: 'linear-arrange-preview',
arrangeNoOutputs: 'linear-arrange-no-outputs',
arrangeSwitchToOutputs: 'linear-arrange-switch-to-outputs',
vueNodeSwitchPopup: 'linear-vue-node-switch-popup',
vueNodeSwitchDismiss: 'linear-vue-node-switch-dismiss',
vueNodeSwitchDontShowAgain: 'linear-vue-node-switch-dont-show-again'
},
breadcrumb: {
subgraph: 'subgraph-breadcrumb',
back: 'subgraph-breadcrumb-back',
item: (key: string) => `subgraph-breadcrumb-item-${key}`,
blueprintTag: 'subgraph-breadcrumb-blueprint-tag',
missingNodesIcon: 'subgraph-breadcrumb-missing-nodes-icon',
renameInput: 'subgraph-breadcrumb-rename-input',
menu: (key: string) => `subgraph-breadcrumb-menu-${key}`
},
templates: {
content: 'template-workflows-content',
workflowCard: (id: string) => `template-workflow-${id}`
},
user: {
currentUserIndicator: 'current-user-indicator'
},
queue: {
overlayToggle: 'queue-overlay-toggle',
clearHistoryAction: 'clear-history-action'
},
errors: {
imageLoadError: 'error-loading-image',
videoLoadError: 'error-loading-video'
},
loading: {
overlay: 'loading-overlay'
},
load3d: {
recordingDuration: 'load3d-recording-duration'
},
load3dViewer: {
sidebar: 'load3d-viewer-sidebar'
},
terminal: {
root: 'terminal-root',
host: 'terminal-host',
copyButton: 'terminal-copy-button',
errorMessage: 'terminal-error-message',
loadingSpinner: 'terminal-loading-spinner'
},
imageCompare: {
viewport: 'image-compare-viewport',
empty: 'image-compare-empty',
batchNav: 'batch-nav',
beforeBatch: 'before-batch',
afterBatch: 'after-batch',
batchCounter: 'batch-counter',
batchNext: 'batch-next',
batchPrev: 'batch-prev'
}
} as const
export type TestId<K extends keyof typeof TestIds> = Exclude<
(typeof TestIds)[K][keyof (typeof TestIds)[K]],
(...args: never[]) => string
>
export type TestIdValue = {
[K in keyof typeof TestIds]: TestId<K>
}[keyof typeof TestIds]