mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-09 00:29:22 +00:00
Compare commits
1 Commits
pysssss/po
...
shihchi/co
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1f8b62cd89 |
@@ -1,45 +0,0 @@
|
||||
{
|
||||
"last_node_id": 9,
|
||||
"last_link_id": 9,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 9,
|
||||
"type": "SaveImage",
|
||||
"pos": {
|
||||
"0": 64,
|
||||
"1": 104
|
||||
},
|
||||
"size": {
|
||||
"0": 210,
|
||||
"1": 58
|
||||
},
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {},
|
||||
"widgets_values": ["ComfyUI"]
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 1,
|
||||
"offset": [0, 0]
|
||||
},
|
||||
"linearData": {
|
||||
"inputs": [],
|
||||
"outputs": ["9"]
|
||||
}
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -34,24 +34,20 @@ export class AppModeHelper {
|
||||
public readonly outputPlaceholder: Locator
|
||||
/** The linear-mode widget list container (visible in app mode). */
|
||||
public readonly linearWidgets: Locator
|
||||
/** The validation warning shown above the app mode run button. */
|
||||
public readonly validationWarning: Locator
|
||||
/** The action that opens graph mode errors from the validation warning. */
|
||||
public readonly viewErrorsInGraphButton: Locator
|
||||
/** The PrimeVue Popover for the image picker (renders with role="dialog"). */
|
||||
public readonly imagePickerPopover: Locator
|
||||
/** The Run button in the app mode footer. */
|
||||
public readonly runButton: Locator
|
||||
/** The welcome card shown when the graph has nodes or outputs (build prompt / ready to run). */
|
||||
/** The welcome screen shown when app mode has no outputs or no nodes. */
|
||||
public readonly welcome: Locator
|
||||
/** The empty workflow message shown when no nodes exist. */
|
||||
public readonly emptyWorkflowText: Locator
|
||||
/** The "Build app" button shown when nodes exist but no outputs. */
|
||||
public readonly buildAppButton: Locator
|
||||
/** The get started page shown when the graph is empty. */
|
||||
public readonly getStarted: Locator
|
||||
/** The "Discover all templates" button on the get started page. */
|
||||
public readonly getStartedDiscoverButton: Locator
|
||||
/** Featured template cards on the get started page. */
|
||||
public readonly getStartedTemplateCards: Locator
|
||||
/** The "Back to workflow" button on the welcome screen. */
|
||||
public readonly backToWorkflowButton: Locator
|
||||
/** The "Load template" button shown when no nodes exist. */
|
||||
public readonly loadTemplateButton: Locator
|
||||
/** The cancel button for an in-progress run in the output history. */
|
||||
public readonly cancelRunButton: Locator
|
||||
/** Arrange-step placeholder shown when outputs are configured but no run has happened. */
|
||||
@@ -96,28 +92,24 @@ export class AppModeHelper {
|
||||
this.outputPlaceholder = this.page.getByTestId(
|
||||
TestIds.builder.outputPlaceholder
|
||||
)
|
||||
this.linearWidgets = this.page.getByTestId(TestIds.linear.widgetContainer)
|
||||
this.validationWarning = this.page.getByTestId(
|
||||
TestIds.linear.validationWarning
|
||||
)
|
||||
this.viewErrorsInGraphButton = this.validationWarning.getByTestId(
|
||||
TestIds.linear.viewErrorsInGraph
|
||||
)
|
||||
this.linearWidgets = this.page.getByTestId('linear-widgets')
|
||||
this.imagePickerPopover = this.page
|
||||
.getByRole('dialog')
|
||||
.filter({ has: this.page.getByRole('button', { name: 'All' }) })
|
||||
.first()
|
||||
this.runButton = this.page
|
||||
.getByTestId(TestIds.linear.runButton)
|
||||
.getByTestId('linear-run-button')
|
||||
.getByRole('button', { name: /run/i })
|
||||
this.welcome = this.page.getByTestId(TestIds.appMode.welcome)
|
||||
this.buildAppButton = this.page.getByTestId(TestIds.appMode.buildApp)
|
||||
this.getStarted = this.page.getByTestId(TestIds.appMode.getStarted)
|
||||
this.getStartedDiscoverButton = this.page.getByTestId(
|
||||
TestIds.appMode.getStartedDiscover
|
||||
this.emptyWorkflowText = this.page.getByTestId(
|
||||
TestIds.appMode.emptyWorkflow
|
||||
)
|
||||
this.getStartedTemplateCards = this.page.getByTestId(
|
||||
TestIds.appMode.getStartedTemplate
|
||||
this.buildAppButton = this.page.getByTestId(TestIds.appMode.buildApp)
|
||||
this.backToWorkflowButton = this.page.getByTestId(
|
||||
TestIds.appMode.backToWorkflow
|
||||
)
|
||||
this.loadTemplateButton = this.page.getByTestId(
|
||||
TestIds.appMode.loadTemplate
|
||||
)
|
||||
this.cancelRunButton = this.page.getByTestId(
|
||||
TestIds.outputHistory.cancelRun
|
||||
@@ -234,13 +226,6 @@ export class AppModeHelper {
|
||||
await this.toggleAppMode()
|
||||
}
|
||||
|
||||
/** Featured template `name`s in the order the cards are rendered. */
|
||||
async getStartedTemplateNames(): Promise<string[]> {
|
||||
return this.getStartedTemplateCards.evaluateAll((cards) =>
|
||||
cards.map((card) => card.getAttribute('data-template-name') ?? '')
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the actions menu trigger for a widget in the app mode widget list.
|
||||
* @param widgetName Text shown in the widget label (e.g. "seed").
|
||||
|
||||
@@ -172,9 +172,6 @@ export const TestIds = {
|
||||
mobileNavigation: 'linear-mobile-navigation',
|
||||
mobileWorkflows: 'linear-mobile-workflows',
|
||||
outputInfo: 'linear-output-info',
|
||||
runButton: 'linear-run-button',
|
||||
validationWarning: 'linear-validation-warning',
|
||||
viewErrorsInGraph: 'linear-view-errors',
|
||||
widgetContainer: 'linear-widgets'
|
||||
},
|
||||
builder: {
|
||||
@@ -218,10 +215,10 @@ export const TestIds = {
|
||||
appMode: {
|
||||
widgetItem: 'app-mode-widget-item',
|
||||
welcome: 'linear-welcome',
|
||||
emptyWorkflow: 'linear-welcome-empty-workflow',
|
||||
buildApp: 'linear-welcome-build-app',
|
||||
getStarted: 'linear-get-started',
|
||||
getStartedDiscover: 'linear-get-started-discover',
|
||||
getStartedTemplate: 'linear-get-started-template',
|
||||
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',
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import type { NodeError, PromptResponse } from '@/schemas/apiSchema'
|
||||
import { ExecutionHelper } from '@e2e/fixtures/helpers/ExecutionHelper'
|
||||
import { enableErrorsOverlay } from '@e2e/fixtures/helpers/ErrorsTabHelper'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
const SAVE_IMAGE_NODE_ID = '9'
|
||||
|
||||
function buildSaveImageRequiredInputError(): NodeError {
|
||||
return {
|
||||
class_type: 'SaveImage',
|
||||
dependent_outputs: [],
|
||||
errors: [
|
||||
{
|
||||
type: 'required_input_missing',
|
||||
message: 'Required input is missing: images',
|
||||
details: '',
|
||||
extra_info: { input_name: 'images' }
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
test.describe(
|
||||
'App mode validation warning',
|
||||
{ tag: ['@ui', '@workflow'] },
|
||||
() => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await enableErrorsOverlay(comfyPage)
|
||||
await comfyPage.workflow.loadWorkflow('linear-validation-warning')
|
||||
await comfyPage.appMode.toggleAppMode()
|
||||
await expect(comfyPage.appMode.linearWidgets).toBeVisible()
|
||||
})
|
||||
|
||||
test('opens graph errors from the app mode validation warning', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await expect(comfyPage.appMode.validationWarning).toBeHidden()
|
||||
|
||||
const exec = new ExecutionHelper(comfyPage)
|
||||
await exec.mockValidationFailure({
|
||||
[SAVE_IMAGE_NODE_ID]: buildSaveImageRequiredInputError()
|
||||
})
|
||||
|
||||
await comfyPage.appMode.runButton.click()
|
||||
const appModeOverlay = comfyPage.appMode.centerPanel.getByTestId(
|
||||
TestIds.dialogs.errorOverlay
|
||||
)
|
||||
await expect(appModeOverlay).toBeHidden()
|
||||
|
||||
await expect(comfyPage.appMode.validationWarning).toBeVisible()
|
||||
await expect(comfyPage.appMode.validationWarning).toContainText(
|
||||
/Required input missing/i
|
||||
)
|
||||
await expect(comfyPage.appMode.viewErrorsInGraphButton).toBeVisible()
|
||||
|
||||
await comfyPage.appMode.viewErrorsInGraphButton.click()
|
||||
|
||||
await expect(comfyPage.appMode.linearWidgets).toBeHidden()
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.propertiesPanel.root)
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.propertiesPanel.errorsTab)
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('keeps the app mode run button enabled when the warning is visible', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const exec = new ExecutionHelper(comfyPage)
|
||||
await exec.mockValidationFailure({
|
||||
[SAVE_IMAGE_NODE_ID]: buildSaveImageRequiredInputError()
|
||||
})
|
||||
|
||||
await comfyPage.appMode.runButton.click()
|
||||
await expect(comfyPage.appMode.validationWarning).toBeVisible()
|
||||
await expect(comfyPage.appMode.runButton).toBeEnabled()
|
||||
|
||||
let promptQueued = false
|
||||
const mockResponse: PromptResponse = {
|
||||
prompt_id: 'test-id',
|
||||
node_errors: {},
|
||||
error: ''
|
||||
}
|
||||
await comfyPage.page.route(
|
||||
'**/api/prompt',
|
||||
async (route) => {
|
||||
promptQueued = true
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
body: JSON.stringify(mockResponse)
|
||||
})
|
||||
},
|
||||
{ times: 1 }
|
||||
)
|
||||
|
||||
await comfyPage.appMode.runButton.click()
|
||||
|
||||
await expect.poll(() => promptQueued).toBe(true)
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -9,12 +9,14 @@ test.describe('App mode welcome states', { tag: '@ui' }, () => {
|
||||
await comfyPage.appMode.suppressVueNodeSwitchPopup()
|
||||
})
|
||||
|
||||
test('Get started page is visible when no nodes', async ({ comfyPage }) => {
|
||||
test('Empty workflow text is visible when no nodes', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.nodeOps.clearGraph()
|
||||
await comfyPage.appMode.toggleAppMode()
|
||||
|
||||
await expect(comfyPage.appMode.getStarted).toBeVisible()
|
||||
await expect(comfyPage.appMode.welcome).toBeHidden()
|
||||
await expect(comfyPage.appMode.welcome).toBeVisible()
|
||||
await expect(comfyPage.appMode.emptyWorkflowText).toBeVisible()
|
||||
await expect(comfyPage.appMode.buildAppButton).toBeHidden()
|
||||
})
|
||||
|
||||
@@ -25,94 +27,39 @@ test.describe('App mode welcome states', { tag: '@ui' }, () => {
|
||||
|
||||
await expect(comfyPage.appMode.welcome).toBeVisible()
|
||||
await expect(comfyPage.appMode.buildAppButton).toBeVisible()
|
||||
await expect(comfyPage.appMode.getStarted).toBeHidden()
|
||||
await expect(comfyPage.appMode.emptyWorkflowText).toBeHidden()
|
||||
})
|
||||
|
||||
test('Get started and build app are hidden when app has outputs', async ({
|
||||
test('Empty workflow and build app are hidden when app has outputs', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.appMode.enterAppModeWithInputs([['3', 'seed']])
|
||||
|
||||
await expect(comfyPage.appMode.linearWidgets).toBeVisible()
|
||||
await expect(comfyPage.appMode.getStarted).toBeHidden()
|
||||
await expect(comfyPage.appMode.emptyWorkflowText).toBeHidden()
|
||||
await expect(comfyPage.appMode.buildAppButton).toBeHidden()
|
||||
})
|
||||
|
||||
test('Clicking a featured template loads it into the graph', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.nodeOps.clearGraph()
|
||||
test('Back to workflow returns to graph mode', async ({ comfyPage }) => {
|
||||
await comfyPage.appMode.toggleAppMode()
|
||||
|
||||
await comfyPage.appMode.getStartedTemplateCards.first().click()
|
||||
await expect(comfyPage.appMode.welcome).toBeVisible()
|
||||
await comfyPage.appMode.backToWorkflowButton.click()
|
||||
|
||||
await expect(comfyPage.appMode.getStarted).toBeHidden()
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
|
||||
.toBeGreaterThan(0)
|
||||
await expect(comfyPage.canvas).toBeVisible()
|
||||
await expect(comfyPage.appMode.welcome).toBeHidden()
|
||||
})
|
||||
|
||||
test('Discover all templates opens template selector', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
test('Load template opens template selector', async ({ comfyPage }) => {
|
||||
await comfyPage.nodeOps.clearGraph()
|
||||
await comfyPage.appMode.toggleAppMode()
|
||||
|
||||
await expect(comfyPage.appMode.getStarted).toBeVisible()
|
||||
await comfyPage.appMode.getStartedDiscoverButton.click()
|
||||
await expect(comfyPage.appMode.welcome).toBeVisible()
|
||||
await comfyPage.appMode.loadTemplateButton.click()
|
||||
|
||||
await expect(comfyPage.templates.content).toBeVisible()
|
||||
})
|
||||
|
||||
test('Remote order flag reorders the featured templates', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.nodeOps.clearGraph()
|
||||
await comfyPage.appMode.toggleAppMode()
|
||||
await expect(comfyPage.appMode.getStarted).toBeVisible()
|
||||
|
||||
const naturalOrder = await comfyPage.appMode.getStartedTemplateNames()
|
||||
expect(naturalOrder.length).toBeGreaterThan(1)
|
||||
|
||||
const reversed = [...naturalOrder].reverse()
|
||||
await comfyPage.featureFlags.setFlags({
|
||||
'app-mode-template-order': { templateIds: reversed }
|
||||
})
|
||||
|
||||
// Snapshot mode reads the payload on mount, so remount the page by leaving
|
||||
// and re-entering app mode.
|
||||
await comfyPage.appMode.toggleAppMode()
|
||||
await comfyPage.appMode.toggleAppMode()
|
||||
await expect(comfyPage.appMode.getStarted).toBeVisible()
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.appMode.getStartedTemplateNames())
|
||||
.toEqual(reversed)
|
||||
})
|
||||
|
||||
test('Invalid remote order payload falls back to the default order', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.nodeOps.clearGraph()
|
||||
await comfyPage.appMode.toggleAppMode()
|
||||
await expect(comfyPage.appMode.getStarted).toBeVisible()
|
||||
|
||||
const naturalOrder = await comfyPage.appMode.getStartedTemplateNames()
|
||||
expect(naturalOrder.length).toBeGreaterThan(0)
|
||||
|
||||
await comfyPage.featureFlags.setFlags({
|
||||
'app-mode-template-order': { templateIds: 'not-an-array' }
|
||||
})
|
||||
|
||||
await comfyPage.appMode.toggleAppMode()
|
||||
await comfyPage.appMode.toggleAppMode()
|
||||
await expect(comfyPage.appMode.getStarted).toBeVisible()
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.appMode.getStartedTemplateNames())
|
||||
.toEqual(naturalOrder)
|
||||
})
|
||||
|
||||
test('Empty workflow dialog blocks entering builder on an empty graph', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { toLinkId } from '@/types/linkId'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
@@ -16,10 +15,9 @@ test.describe('Graph', { tag: ['@smoke', '@canvas'] }, () => {
|
||||
await comfyPage.workflow.loadWorkflow('inputs/input_order_swap')
|
||||
await expect
|
||||
.poll(() =>
|
||||
comfyPage.page.evaluate(
|
||||
(linkId) => window.app!.graph!.links.get(linkId)?.target_slot,
|
||||
toLinkId(1)
|
||||
)
|
||||
comfyPage.page.evaluate(() => {
|
||||
return window.app!.graph!.links.get(1)?.target_slot
|
||||
})
|
||||
)
|
||||
.toBe(1)
|
||||
})
|
||||
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
test.describe('Linear Mode', { tag: '@ui' }, () => {
|
||||
test('Displays linear controls when app mode active', async ({
|
||||
@@ -17,9 +16,7 @@ test.describe('Linear Mode', { tag: '@ui' }, () => {
|
||||
test('Run button visible in linear mode', async ({ comfyPage }) => {
|
||||
await comfyPage.appMode.enterAppModeWithInputs([])
|
||||
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.linear.runButton)
|
||||
).toBeVisible()
|
||||
await expect(comfyPage.page.getByTestId('linear-run-button')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Workflow info section visible', async ({ comfyPage }) => {
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
# Remote User Data (PostHog payloads)
|
||||
|
||||
Fetch per-user / per-cohort JSON from PostHog feature-flag payloads to tune UI
|
||||
behavior (element ordering, which content to surface, survey shape, …) from the
|
||||
PostHog dashboard **without a frontend release**.
|
||||
|
||||
This is for personalization hints only. Every key has a hardcoded default that
|
||||
must always be shippable — never gate anything critical on a remote value.
|
||||
|
||||
## For engineers
|
||||
|
||||
### Consuming a value
|
||||
|
||||
```ts
|
||||
import { z } from 'zod'
|
||||
|
||||
import { useRemoteUserData } from '@/platform/remoteUserData/useRemoteUserData'
|
||||
|
||||
const { data, isLoaded } = useRemoteUserData({
|
||||
key: 'app-mode-template-order',
|
||||
schema: z.object({ templateIds: z.array(z.string()) }),
|
||||
defaultValue: { templateIds: DEFAULT_APP_MODE_TEMPLATE_IDS }
|
||||
// mode: 'snapshot' (default) | 'reactive'
|
||||
})
|
||||
```
|
||||
|
||||
- `data` — validated payload, or `defaultValue` if absent/invalid. Never throws.
|
||||
- `isLoaded` — shared readiness signal. Instantly `true` in OSS/desktop or when
|
||||
PostHog is disabled; in cloud it is `false` until the first authoritative flag
|
||||
response (after auth resolves), then `true` forever. Gate UI that must not
|
||||
render-then-reorder on this (show a skeleton while `false`). A blocked or slow
|
||||
`/flags` request holds `false` for up to the ~3s backstop, so only gate
|
||||
reorderable secondary content — render primary, above-the-fold UI on defaults
|
||||
immediately rather than behind a skeleton.
|
||||
|
||||
### Registering a key
|
||||
|
||||
Add the flag key to `REMOTE_USER_DATA_KEYS` in
|
||||
`src/platform/remoteUserData/keys.ts`. Registration is what makes the cloud
|
||||
provider fetch that key's payload, and keeps the flag inventory greppable.
|
||||
|
||||
### snapshot vs reactive
|
||||
|
||||
- **`snapshot`** (default) — resolves once (when `isLoaded` flips true, or
|
||||
immediately if already loaded) then freezes for the instance's lifetime. Later
|
||||
reloads, including values arriving after the timeout backstop, do not touch it.
|
||||
Use for anything the user interacts with mid-flow: surveys, welcome tiles,
|
||||
modal content. A fresh instance (e.g. reopening a flow) takes a fresh snapshot.
|
||||
- **`reactive`** — tracks every flag reload. Opt-in, for passive UI where a rare
|
||||
late update is harmless (e.g. sidebar ordering).
|
||||
|
||||
Snapshot is the default because it cannot mutate mid-interaction.
|
||||
|
||||
### Ordering payloads
|
||||
|
||||
For "ordered list of ids" payloads whose ids reference content that ships
|
||||
separately, resolve through `resolvePrioritizedIds(payloadIds, defaultIds,
|
||||
validIds, limit)`: it drops ids missing from the registry and backfills from the
|
||||
defaults, so a stale or typo'd payload can never produce an empty/broken list.
|
||||
|
||||
### Segmenting telemetry
|
||||
|
||||
Under `snapshot`, a user whose `/flags` response beat the ~3s timeout sees the
|
||||
targeted config; one whose didn't sees defaults. Give flow-driving payloads a
|
||||
`version`/`variant` field and attach it to the flow's telemetry events so
|
||||
analysis segments by what the user actually saw.
|
||||
|
||||
### Dev override
|
||||
|
||||
In dev builds only:
|
||||
|
||||
```js
|
||||
localStorage.setItem(
|
||||
'ff:app-mode-template-order',
|
||||
JSON.stringify({ templateIds: ['flux-schnell'] })
|
||||
)
|
||||
localStorage.removeItem('ff:app-mode-template-order')
|
||||
```
|
||||
|
||||
### Constraints
|
||||
|
||||
- Values exist only in cloud builds; OSS/desktop always get defaults at zero cost.
|
||||
- Targeting by cohort/person properties only matches **identified** (logged-in)
|
||||
users. Anonymous users get percentage rollout at best.
|
||||
|
||||
## For marketing / product
|
||||
|
||||
You change behavior entirely from the PostHog dashboard — no deploy.
|
||||
|
||||
1. **Feature Flags → New feature flag.** Use the exact key an engineer registered
|
||||
(e.g. `app-mode-template-order`). Kebab-case, describes what it tunes.
|
||||
2. **Release conditions** — target by cohort, person properties (e.g.
|
||||
`subscription_tier`, or survey answers already set on the person), or a
|
||||
percentage rollout. Person-property targeting only reaches logged-in users.
|
||||
3. **Payload** — add the JSON payload. Edit it anytime; changes take effect on
|
||||
users' next session without a release.
|
||||
4. **A/B experiments** — a multivariate flag carries one payload per variant, so
|
||||
variants can be measured against existing telemetry events.
|
||||
|
||||
### Payload shapes per key
|
||||
|
||||
| Key | JSON shape | Notes |
|
||||
| ------------------------- | ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `app-mode-template-order` | `{ "templateIds": ["flux-schnell", "sdxl-turbo", …] }` | Ordered template ids for the app-mode welcome screen. Unknown ids are dropped and the default list backfills, so a typo can't blank the screen. Valid template ids come from the template registry — ask an engineer for the current list. |
|
||||
|
||||
Keep every payload valid JSON — a malformed or unexpected payload is ignored and
|
||||
the user silently gets the shipped default.
|
||||
@@ -11,7 +11,6 @@
|
||||
--color-charcoal-600: #262729;
|
||||
--color-charcoal-700: #202121;
|
||||
--color-charcoal-800: #171718;
|
||||
--color-charcoal-900: #141414;
|
||||
|
||||
--color-neutral-550: #636363;
|
||||
|
||||
|
||||
@@ -457,7 +457,6 @@
|
||||
);
|
||||
--color-interface-menu-surface: var(--interface-menu-surface);
|
||||
--color-interface-menu-stroke: var(--interface-menu-stroke);
|
||||
--color-interface-canvas-background: var(--color-charcoal-900);
|
||||
--color-interface-panel-surface: var(--interface-panel-surface);
|
||||
--color-interface-panel-hover-surface: var(--interface-panel-hover-surface);
|
||||
--color-interface-panel-selected-surface: var(
|
||||
|
||||
@@ -430,14 +430,6 @@ import { useTemplateFiltering } from '@/composables/useTemplateFiltering'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useTemplateWorkflows } from '@/platform/workflow/templates/composables/useTemplateWorkflows'
|
||||
import type { TemplateInfo } from '@/platform/workflow/templates/types/template'
|
||||
import {
|
||||
getBaseThumbnailSrc,
|
||||
getEffectiveSourceModule,
|
||||
getOverlayThumbnailSrc,
|
||||
getTemplateDescription,
|
||||
getTemplateTitle,
|
||||
isAppTemplate
|
||||
} from '@/platform/workflow/templates/utils/templateUtil'
|
||||
import { useWorkflowTemplatesStore } from '@/platform/workflow/templates/repositories/workflowTemplatesStore'
|
||||
import type { NavGroupData, NavItemData } from '@/types/navTypes'
|
||||
import { OnCloseKey } from '@/types/widgetTypes'
|
||||
@@ -476,7 +468,28 @@ provide(OnCloseKey, onClose)
|
||||
|
||||
// Workflow templates store and composable
|
||||
const workflowTemplatesStore = useWorkflowTemplatesStore()
|
||||
const { loadTemplates, loadWorkflowTemplate } = useTemplateWorkflows()
|
||||
const {
|
||||
loadTemplates,
|
||||
loadWorkflowTemplate,
|
||||
getTemplateThumbnailUrl,
|
||||
getTemplateTitle,
|
||||
getTemplateDescription
|
||||
} = useTemplateWorkflows()
|
||||
|
||||
const getEffectiveSourceModule = (template: TemplateInfo) =>
|
||||
template.sourceModule || 'default'
|
||||
|
||||
const isAppTemplate = (template: TemplateInfo) => template.name.endsWith('.app')
|
||||
|
||||
const getBaseThumbnailSrc = (template: TemplateInfo) => {
|
||||
const sm = getEffectiveSourceModule(template)
|
||||
return getTemplateThumbnailUrl(template, sm, sm === 'default' ? '1' : '')
|
||||
}
|
||||
|
||||
const getOverlayThumbnailSrc = (template: TemplateInfo) => {
|
||||
const sm = getEffectiveSourceModule(template)
|
||||
return getTemplateThumbnailUrl(template, sm, sm === 'default' ? '2' : '')
|
||||
}
|
||||
|
||||
// Open tutorial in new tab
|
||||
const openTutorial = (template: TemplateInfo) => {
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
size="unset"
|
||||
class="min-h-8 rounded-lg px-3 py-2 text-xs font-normal"
|
||||
data-testid="error-overlay-see-errors"
|
||||
@click="viewErrorsInGraph"
|
||||
@click="seeErrors"
|
||||
>
|
||||
{{
|
||||
appMode
|
||||
@@ -67,18 +67,31 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useErrorOverlayState } from '@/components/error/useErrorOverlayState'
|
||||
import { useViewErrorsInGraph } from '@/composables/useViewErrorsInGraph'
|
||||
|
||||
const { appMode = false } = defineProps<{ appMode?: boolean }>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
const { viewErrorsInGraph } = useViewErrorsInGraph()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
|
||||
const { isVisible, overlayMessage, overlayTitle } = useErrorOverlayState()
|
||||
|
||||
function dismiss() {
|
||||
executionErrorStore.dismissErrorOverlay()
|
||||
}
|
||||
|
||||
function seeErrors() {
|
||||
canvasStore.linearMode = false
|
||||
if (canvasStore.canvas) {
|
||||
canvasStore.canvas.deselectAll()
|
||||
canvasStore.updateSelectedItems()
|
||||
}
|
||||
|
||||
rightSidePanelStore.openPanel('errors')
|
||||
executionErrorStore.dismissErrorOverlay()
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import { LGraph, LGraphCanvas, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { createMockCanvasRenderingContext2D } from '@/utils/__tests__/litegraphTestUtils'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
|
||||
import { useViewErrorsInGraph } from './useViewErrorsInGraph'
|
||||
|
||||
const apiMock = vi.hoisted(() => ({
|
||||
getSettings: vi.fn(),
|
||||
storeSetting: vi.fn(),
|
||||
storeSettings: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: apiMock
|
||||
}))
|
||||
|
||||
const appMock = vi.hoisted(() => ({
|
||||
ui: {
|
||||
settings: {
|
||||
dispatchChange: vi.fn()
|
||||
}
|
||||
},
|
||||
rootGraph: {
|
||||
events: new EventTarget(),
|
||||
nodes: []
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: appMock
|
||||
}))
|
||||
|
||||
function createSelectedCanvas() {
|
||||
const graph = new LGraph()
|
||||
const canvasElement = document.createElement('canvas')
|
||||
canvasElement.width = 800
|
||||
canvasElement.height = 600
|
||||
canvasElement.getContext = vi
|
||||
.fn()
|
||||
.mockReturnValue(createMockCanvasRenderingContext2D())
|
||||
|
||||
const canvas = new LGraphCanvas(canvasElement, graph, {
|
||||
skip_events: true,
|
||||
skip_render: true
|
||||
})
|
||||
const node = new LGraphNode('Selected Node')
|
||||
graph.add(node)
|
||||
canvas.selectedItems.add(node)
|
||||
node.selected = true
|
||||
|
||||
return { canvas, node }
|
||||
}
|
||||
|
||||
describe('useViewErrorsInGraph', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
setActivePinia(createPinia())
|
||||
apiMock.getSettings.mockResolvedValue({})
|
||||
apiMock.storeSetting.mockResolvedValue(undefined)
|
||||
apiMock.storeSettings.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
it('opens graph errors and clears app-mode error UI state', () => {
|
||||
const canvasStore = useCanvasStore()
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const { canvas, node } = createSelectedCanvas()
|
||||
workflowStore.activeWorkflow = {
|
||||
activeMode: 'app'
|
||||
} as typeof workflowStore.activeWorkflow
|
||||
canvasStore.canvas = canvas
|
||||
canvasStore.selectedItems = [node]
|
||||
executionErrorStore.showErrorOverlay()
|
||||
|
||||
useViewErrorsInGraph().viewErrorsInGraph()
|
||||
|
||||
expect(node.selected).toBe(false)
|
||||
expect(canvasStore.linearMode).toBe(false)
|
||||
expect(canvasStore.selectedItems).toEqual([])
|
||||
expect(rightSidePanelStore.activeTab).toBe('errors')
|
||||
expect(rightSidePanelStore.isOpen).toBe(true)
|
||||
expect(executionErrorStore.isErrorOverlayOpen).toBe(false)
|
||||
})
|
||||
|
||||
it('opens graph errors when the canvas is not initialized', () => {
|
||||
const canvasStore = useCanvasStore()
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
canvasStore.canvas = null
|
||||
executionErrorStore.showErrorOverlay()
|
||||
|
||||
expect(() => useViewErrorsInGraph().viewErrorsInGraph()).not.toThrow()
|
||||
|
||||
expect(rightSidePanelStore.activeTab).toBe('errors')
|
||||
expect(rightSidePanelStore.isOpen).toBe(true)
|
||||
expect(executionErrorStore.isErrorOverlayOpen).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,22 +0,0 @@
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
|
||||
export function useViewErrorsInGraph() {
|
||||
const canvasStore = useCanvasStore()
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
|
||||
function viewErrorsInGraph() {
|
||||
canvasStore.linearMode = false
|
||||
if (canvasStore.canvas) {
|
||||
canvasStore.canvas.deselectAll()
|
||||
canvasStore.updateSelectedItems()
|
||||
}
|
||||
|
||||
rightSidePanelStore.openPanel('errors')
|
||||
executionErrorStore.dismissErrorOverlay()
|
||||
}
|
||||
|
||||
return { viewErrorsInGraph }
|
||||
}
|
||||
@@ -3711,21 +3711,14 @@
|
||||
"viewGraph": "View node graph",
|
||||
"mobileNoWorkflow": "This workflow hasn't been built for app mode. Try a different one.",
|
||||
"welcome": {
|
||||
"title": "Your app is ready to run",
|
||||
"description": "Set your inputs then run to get started"
|
||||
},
|
||||
"getStarted": {
|
||||
"title": "Get started with Apps",
|
||||
"subtitle": "Pick an app template to get started. Each one is built on a workflow.",
|
||||
"templates": "Templates",
|
||||
"importWorkflow": "Import workflow",
|
||||
"discoverAll": "Discover all templates",
|
||||
"loadFailed": "Couldn't load this template. Please try again."
|
||||
},
|
||||
"buildPrompt": {
|
||||
"title": "Make this workflow an App",
|
||||
"description": "Pick which nodes become inputs and outputs, and we'll generate a simple form anyone can run.",
|
||||
"button": "Build your App"
|
||||
"title": "App Mode",
|
||||
"message": "A simplified view that hides the node graph so you can focus on creating.",
|
||||
"controls": "Your outputs appear at the bottom, your controls are on the right. Everything else stays out of the way.",
|
||||
"sharing": "Share your workflow as a simple tool anyone can use. Export it from the tab menu and when others open it, they'll see App Mode. No node graph knowledge needed.",
|
||||
"getStarted": "Click {runButton} to get started.",
|
||||
"buildApp": "Build app",
|
||||
"noOutputs": "An app needs at least {count} to be usable.",
|
||||
"oneOutput": "1 output"
|
||||
},
|
||||
"appModeToolbar": {
|
||||
"appBuilder": "App builder",
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
/**
|
||||
* PostHog feature-flag keys whose JSON payloads are exposed via
|
||||
* {@link useRemoteUserData}. The cloud provider only collects payloads for keys
|
||||
* listed here.
|
||||
*/
|
||||
export const REMOTE_USER_DATA_KEYS = ['app-mode-template-order'] as const
|
||||
|
||||
export type RemoteUserDataKey = (typeof REMOTE_USER_DATA_KEYS)[number]
|
||||
@@ -1,43 +0,0 @@
|
||||
import { computed, ref, shallowRef } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
/**
|
||||
* Bridge between the cloud PostHog provider (which produces per-user/per-cohort
|
||||
* flag payloads) and {@link useRemoteUserData} consumers (which run in any
|
||||
* build). The provider registers a source; consumers read it and fall back to
|
||||
* defaults when none was registered.
|
||||
*
|
||||
* Must never import posthog-js — it stays in OSS/desktop bundles.
|
||||
*/
|
||||
export interface PayloadSource {
|
||||
payloads: Ref<Record<string, unknown>>
|
||||
}
|
||||
|
||||
// shallowRef so reactive-mode consumers track source registration without
|
||||
// unwrapping the nested `payloads` ref.
|
||||
const _payloadSource = shallowRef<PayloadSource | null>(null)
|
||||
|
||||
/**
|
||||
* Ready by default: without a PostHog token no source ever registers, so
|
||||
* defaults are final and consumers must not wait. The cloud provider marks it
|
||||
* pending, then ready after the first flag response or a timeout.
|
||||
*/
|
||||
const _ready = ref(true)
|
||||
|
||||
export const remoteUserDataReady = computed(() => _ready.value)
|
||||
|
||||
export function setPayloadSource(source: PayloadSource | null): void {
|
||||
_payloadSource.value = source
|
||||
}
|
||||
|
||||
export function getPayloadSource(): PayloadSource | null {
|
||||
return _payloadSource.value
|
||||
}
|
||||
|
||||
export function markRemoteUserDataPending(): void {
|
||||
_ready.value = false
|
||||
}
|
||||
|
||||
export function markRemoteUserDataReady(): void {
|
||||
_ready.value = true
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { resolvePrioritizedIds } from './resolvePrioritizedIds'
|
||||
|
||||
const valid = new Set(['a', 'b', 'c', 'd'])
|
||||
|
||||
describe('resolvePrioritizedIds', () => {
|
||||
it('honors payload order and caps at n', () => {
|
||||
expect(
|
||||
resolvePrioritizedIds(['c', 'a'], ['a', 'b', 'd'], valid, 3)
|
||||
).toEqual(['c', 'a', 'b'])
|
||||
})
|
||||
|
||||
it('drops ids that are not in the valid set', () => {
|
||||
expect(resolvePrioritizedIds(['ghost', 'b'], ['a'], valid, 5)).toEqual([
|
||||
'b',
|
||||
'a'
|
||||
])
|
||||
})
|
||||
|
||||
it('backfills from defaults up to n', () => {
|
||||
expect(resolvePrioritizedIds([], ['a', 'b', 'c'], valid, 2)).toEqual([
|
||||
'a',
|
||||
'b'
|
||||
])
|
||||
})
|
||||
|
||||
it('deduplicates across payload and defaults', () => {
|
||||
expect(resolvePrioritizedIds(['a', 'a'], ['a', 'b'], valid, 5)).toEqual([
|
||||
'a',
|
||||
'b'
|
||||
])
|
||||
})
|
||||
|
||||
it('never yields an empty list when defaults are valid', () => {
|
||||
expect(resolvePrioritizedIds(['ghost'], ['a', 'b'], valid, 2)).toEqual([
|
||||
'a',
|
||||
'b'
|
||||
])
|
||||
})
|
||||
|
||||
it('yields an empty list when nothing valid remains', () => {
|
||||
expect(resolvePrioritizedIds(['ghost'], ['also-ghost'], valid, 5)).toEqual(
|
||||
[]
|
||||
)
|
||||
expect(resolvePrioritizedIds(['a'], ['b'], valid, 0)).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -1,26 +0,0 @@
|
||||
/**
|
||||
* Resolves a payload-driven ordering of ids into a safe, deduplicated list.
|
||||
*
|
||||
* Payload ids reference content (e.g. templates) that ships independently of the
|
||||
* flag, so a stale or typo'd payload must never produce an empty or broken list:
|
||||
* ids absent from `validIds` are dropped, then `defaultIds` backfill up to
|
||||
* `limit`. Payload order wins; defaults fill the remainder.
|
||||
*/
|
||||
export function resolvePrioritizedIds(
|
||||
payloadIds: readonly string[],
|
||||
defaultIds: readonly string[],
|
||||
validIds: ReadonlySet<string>,
|
||||
limit: number
|
||||
): string[] {
|
||||
const result: string[] = []
|
||||
const seen = new Set<string>()
|
||||
|
||||
for (const id of [...payloadIds, ...defaultIds]) {
|
||||
if (result.length >= limit) break
|
||||
if (seen.has(id) || !validIds.has(id)) continue
|
||||
seen.add(id)
|
||||
result.push(id)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
@@ -1,167 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick, ref } from 'vue'
|
||||
import { z } from 'zod'
|
||||
|
||||
import {
|
||||
markRemoteUserDataPending,
|
||||
markRemoteUserDataReady,
|
||||
remoteUserDataReady,
|
||||
setPayloadSource
|
||||
} from './payloadSource'
|
||||
import { useRemoteUserData } from './useRemoteUserData'
|
||||
|
||||
const KEY = 'app-mode-template-order'
|
||||
const schema = z.object({ templateIds: z.array(z.string()) })
|
||||
const defaultValue = { templateIds: ['default-a', 'default-b'] }
|
||||
|
||||
function registerSource(initial: Record<string, unknown> = {}) {
|
||||
const payloads = ref<Record<string, unknown>>(initial)
|
||||
setPayloadSource({ payloads })
|
||||
return payloads
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setPayloadSource(null)
|
||||
markRemoteUserDataReady()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
localStorage.clear()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('useRemoteUserData', () => {
|
||||
it('returns the default and is loaded when no source is registered', () => {
|
||||
const { data, isLoaded } = useRemoteUserData({
|
||||
key: KEY,
|
||||
schema,
|
||||
defaultValue
|
||||
})
|
||||
|
||||
expect(data.value).toEqual(defaultValue)
|
||||
expect(isLoaded.value).toBe(true)
|
||||
})
|
||||
|
||||
it('resolves a valid payload from the source', () => {
|
||||
registerSource({ [KEY]: { templateIds: ['x', 'y'] } })
|
||||
|
||||
const { data } = useRemoteUserData({ key: KEY, schema, defaultValue })
|
||||
|
||||
expect(data.value).toEqual({ templateIds: ['x', 'y'] })
|
||||
})
|
||||
|
||||
it('falls back to the default and warns on an invalid payload', () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
registerSource({ [KEY]: { templateIds: 'not-an-array' } })
|
||||
|
||||
const { data } = useRemoteUserData({ key: KEY, schema, defaultValue })
|
||||
|
||||
expect(data.value).toEqual(defaultValue)
|
||||
expect(warn).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('prefers a dev override over the source payload', () => {
|
||||
registerSource({ [KEY]: { templateIds: ['from-source'] } })
|
||||
localStorage.setItem(
|
||||
`ff:${KEY}`,
|
||||
JSON.stringify({ templateIds: ['from-override'] })
|
||||
)
|
||||
|
||||
const { data } = useRemoteUserData({ key: KEY, schema, defaultValue })
|
||||
|
||||
expect(data.value).toEqual({ templateIds: ['from-override'] })
|
||||
})
|
||||
|
||||
describe('reactive mode', () => {
|
||||
it('tracks payload reloads', async () => {
|
||||
const payloads = registerSource({ [KEY]: { templateIds: ['v1'] } })
|
||||
|
||||
const { data } = useRemoteUserData({
|
||||
key: KEY,
|
||||
schema,
|
||||
defaultValue,
|
||||
mode: 'reactive'
|
||||
})
|
||||
expect(data.value).toEqual({ templateIds: ['v1'] })
|
||||
|
||||
payloads.value = { [KEY]: { templateIds: ['v2'] } }
|
||||
await nextTick()
|
||||
|
||||
expect(data.value).toEqual({ templateIds: ['v2'] })
|
||||
})
|
||||
|
||||
it('warns once for a persistently invalid payload across reloads', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const payloads = registerSource({ [KEY]: { templateIds: 'bad' } })
|
||||
|
||||
const { data } = useRemoteUserData({
|
||||
key: KEY,
|
||||
schema,
|
||||
defaultValue,
|
||||
mode: 'reactive'
|
||||
})
|
||||
expect(data.value).toEqual(defaultValue)
|
||||
|
||||
payloads.value = { [KEY]: { templateIds: 'bad' } }
|
||||
await nextTick()
|
||||
expect(data.value).toEqual(defaultValue)
|
||||
|
||||
expect(warn).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
describe('snapshot mode', () => {
|
||||
it('snapshots at creation when already loaded', () => {
|
||||
registerSource({ [KEY]: { templateIds: ['at-create'] } })
|
||||
|
||||
const { data } = useRemoteUserData({ key: KEY, schema, defaultValue })
|
||||
|
||||
expect(data.value).toEqual({ templateIds: ['at-create'] })
|
||||
})
|
||||
|
||||
it('resolves once when readiness flips, then freezes across reloads', async () => {
|
||||
markRemoteUserDataPending()
|
||||
const payloads = registerSource()
|
||||
|
||||
const { data } = useRemoteUserData({ key: KEY, schema, defaultValue })
|
||||
expect(data.value).toEqual(defaultValue)
|
||||
|
||||
payloads.value = { [KEY]: { templateIds: ['authoritative'] } }
|
||||
markRemoteUserDataReady()
|
||||
await nextTick()
|
||||
expect(data.value).toEqual({ templateIds: ['authoritative'] })
|
||||
|
||||
payloads.value = { [KEY]: { templateIds: ['late-reload'] } }
|
||||
await nextTick()
|
||||
expect(data.value).toEqual({ templateIds: ['authoritative'] })
|
||||
})
|
||||
|
||||
it('keeps defaults resolved at a timeout flip even when values arrive later', async () => {
|
||||
markRemoteUserDataPending()
|
||||
const payloads = registerSource()
|
||||
|
||||
const { data } = useRemoteUserData({ key: KEY, schema, defaultValue })
|
||||
|
||||
markRemoteUserDataReady()
|
||||
await nextTick()
|
||||
expect(data.value).toEqual(defaultValue)
|
||||
|
||||
payloads.value = { [KEY]: { templateIds: ['too-late'] } }
|
||||
await nextTick()
|
||||
expect(data.value).toEqual(defaultValue)
|
||||
})
|
||||
})
|
||||
|
||||
describe('readiness', () => {
|
||||
it('only flips once and never back', async () => {
|
||||
markRemoteUserDataPending()
|
||||
expect(remoteUserDataReady.value).toBe(false)
|
||||
|
||||
markRemoteUserDataReady()
|
||||
expect(remoteUserDataReady.value).toBe(true)
|
||||
|
||||
markRemoteUserDataReady()
|
||||
expect(remoteUserDataReady.value).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,88 +0,0 @@
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
import type { ZodType } from 'zod'
|
||||
|
||||
import { getDevOverride } from '@/utils/devFeatureFlagOverride'
|
||||
|
||||
import type { RemoteUserDataKey } from './keys'
|
||||
import { getPayloadSource, remoteUserDataReady } from './payloadSource'
|
||||
|
||||
/**
|
||||
* `snapshot` (default): `data` resolves once when readiness flips true (or
|
||||
* immediately if already ready) and is then frozen. Use for anything the user
|
||||
* interacts with mid-flow (surveys, welcome tiles, modal content).
|
||||
*
|
||||
* `reactive`: `data` tracks every flag reload. Use only where a late update is
|
||||
* harmless (e.g. sidebar ordering).
|
||||
*/
|
||||
type RemoteUserDataMode = 'snapshot' | 'reactive'
|
||||
|
||||
interface UseRemoteUserDataOptions<T> {
|
||||
key: RemoteUserDataKey
|
||||
schema: ZodType<T>
|
||||
defaultValue: T
|
||||
mode?: RemoteUserDataMode
|
||||
}
|
||||
|
||||
interface UseRemoteUserDataResult<T> {
|
||||
data: Readonly<Ref<T>>
|
||||
isLoaded: Readonly<Ref<boolean>>
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a per-user/per-cohort JSON payload for `key`, validated against `schema`,
|
||||
* falling back to `defaultValue`. `isLoaded` is the shared readiness signal:
|
||||
* instantly true when no PostHog source exists, otherwise true once the first
|
||||
* authoritative flag response arrives.
|
||||
*
|
||||
* Never throws — a hand-edited payload that fails validation logs a warning and
|
||||
* resolves to the default.
|
||||
*/
|
||||
export function useRemoteUserData<T>(
|
||||
options: UseRemoteUserDataOptions<T>
|
||||
): UseRemoteUserDataResult<T> {
|
||||
const { key, schema, defaultValue, mode = 'snapshot' } = options
|
||||
|
||||
// Reactive mode re-resolves on every flag reload; dedupe by serialized value
|
||||
// so a persistently invalid payload warns once rather than on each reload.
|
||||
let lastWarnedRaw: string | undefined
|
||||
function resolve(): T {
|
||||
const override = getDevOverride<unknown>(key)
|
||||
const raw =
|
||||
override !== undefined
|
||||
? override
|
||||
: getPayloadSource()?.payloads.value[key]
|
||||
if (raw === undefined) return defaultValue
|
||||
|
||||
const parsed = schema.safeParse(raw)
|
||||
if (parsed.success) return parsed.data
|
||||
|
||||
const rawKey = JSON.stringify(raw)
|
||||
if (rawKey !== lastWarnedRaw) {
|
||||
lastWarnedRaw = rawKey
|
||||
console.warn(
|
||||
`[remoteUserData] Invalid payload for "${key}":`,
|
||||
parsed.error
|
||||
)
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
if (mode === 'reactive') {
|
||||
return { data: computed(resolve), isLoaded: remoteUserDataReady }
|
||||
}
|
||||
|
||||
const data = ref(
|
||||
remoteUserDataReady.value ? resolve() : defaultValue
|
||||
) as Ref<T>
|
||||
|
||||
if (!remoteUserDataReady.value) {
|
||||
const stop = watch(remoteUserDataReady, (ready) => {
|
||||
if (!ready) return
|
||||
data.value = resolve()
|
||||
stop()
|
||||
})
|
||||
}
|
||||
|
||||
return { data, isLoaded: remoteUserDataReady }
|
||||
}
|
||||
@@ -15,14 +15,9 @@ const hoisted = vi.hoisted(() => {
|
||||
const mockReset = vi.fn()
|
||||
const mockOnUserResolved = vi.fn()
|
||||
const mockOnUserLogout = vi.fn()
|
||||
const mockOnFeatureFlags = vi.fn()
|
||||
const mockGetFeatureFlagResult = vi.fn()
|
||||
const mockReloadFeatureFlags = vi.fn()
|
||||
const refs = {
|
||||
tier: null as unknown as Ref<string | null>,
|
||||
remoteConfig: null as unknown as Ref<Record<string, unknown> | null>,
|
||||
resolvedUserInfo: null as unknown as Ref<{ id: string } | null>,
|
||||
isInitialized: null as unknown as Ref<boolean>
|
||||
remoteConfig: null as unknown as Ref<Record<string, unknown> | null>
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -35,9 +30,6 @@ const hoisted = vi.hoisted(() => {
|
||||
mockReset,
|
||||
mockOnUserResolved,
|
||||
mockOnUserLogout,
|
||||
mockOnFeatureFlags,
|
||||
mockGetFeatureFlagResult,
|
||||
mockReloadFeatureFlags,
|
||||
refs,
|
||||
mockPosthog: {
|
||||
default: {
|
||||
@@ -46,10 +38,7 @@ const hoisted = vi.hoisted(() => {
|
||||
identify: mockIdentify,
|
||||
register: mockRegister,
|
||||
people: { set: mockPeopleSet, set_once: mockPeopleSetOnce },
|
||||
reset: mockReset,
|
||||
onFeatureFlags: mockOnFeatureFlags,
|
||||
getFeatureFlagResult: mockGetFeatureFlagResult,
|
||||
reloadFeatureFlags: mockReloadFeatureFlags
|
||||
reset: mockReset
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -58,16 +47,7 @@ const hoisted = vi.hoisted(() => {
|
||||
vi.mock('@/composables/auth/useCurrentUser', () => ({
|
||||
useCurrentUser: () => ({
|
||||
onUserResolved: hoisted.mockOnUserResolved,
|
||||
onUserLogout: hoisted.mockOnUserLogout,
|
||||
resolvedUserInfo: hoisted.refs.resolvedUserInfo
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/authStore', () => ({
|
||||
useAuthStore: () => ({
|
||||
get isInitialized() {
|
||||
return hoisted.refs.isInitialized.value
|
||||
}
|
||||
onUserLogout: hoisted.mockOnUserLogout
|
||||
})
|
||||
}))
|
||||
|
||||
@@ -85,13 +65,6 @@ vi.mock('@/composables/billing/useBillingContext', async () => {
|
||||
return { useBillingContext: () => ({ tier: hoisted.refs.tier }) }
|
||||
})
|
||||
|
||||
import {
|
||||
getPayloadSource,
|
||||
markRemoteUserDataReady,
|
||||
remoteUserDataReady,
|
||||
setPayloadSource
|
||||
} from '@/platform/remoteUserData/payloadSource'
|
||||
|
||||
import { PostHogTelemetryProvider } from './PostHogTelemetryProvider'
|
||||
|
||||
function createProvider(
|
||||
@@ -106,24 +79,16 @@ function createProvider(
|
||||
|
||||
describe('PostHogTelemetryProvider', () => {
|
||||
beforeEach(() => {
|
||||
// Keep each provider's 3s readiness backstop from firing in a later test.
|
||||
vi.useFakeTimers()
|
||||
vi.clearAllMocks()
|
||||
hoisted.refs.remoteConfig.value = null
|
||||
// Fresh tier ref per test: each provider registers an undisposed tier
|
||||
// watch, so a shared ref would leak watchers across tests.
|
||||
hoisted.refs.tier = ref<string | null>(null)
|
||||
hoisted.refs.resolvedUserInfo = ref<{ id: string } | null>(null)
|
||||
hoisted.refs.isInitialized = ref(false)
|
||||
window.__CONFIG__ = {
|
||||
posthog_project_token: 'phc_test_token'
|
||||
} as typeof window.__CONFIG__
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('initialization', () => {
|
||||
it('disables itself when posthog_project_token is not provided', async () => {
|
||||
const provider = createProvider({ posthog_project_token: undefined })
|
||||
@@ -669,124 +634,6 @@ describe('PostHogTelemetryProvider', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('remote user data', () => {
|
||||
beforeEach(() => {
|
||||
hoisted.mockGetFeatureFlagResult.mockReturnValue(undefined)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
setPayloadSource(null)
|
||||
markRemoteUserDataReady()
|
||||
})
|
||||
|
||||
it('disables the anonymous first-load flag fetch on init', async () => {
|
||||
createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
expect(hoisted.mockInit).toHaveBeenCalledWith(
|
||||
'phc_test_token',
|
||||
expect.objectContaining({
|
||||
advanced_disable_feature_flags_on_first_load: true
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('registers a reactive payload source', async () => {
|
||||
createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
expect(getPayloadSource()).not.toBeNull()
|
||||
})
|
||||
|
||||
it('stays pending until the first flag response, then becomes ready', async () => {
|
||||
createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
expect(remoteUserDataReady.value).toBe(false)
|
||||
|
||||
const onFlags = hoisted.mockOnFeatureFlags.mock.calls[0][0]
|
||||
onFlags()
|
||||
|
||||
expect(remoteUserDataReady.value).toBe(true)
|
||||
})
|
||||
|
||||
it('collects known-key payloads on the flag response', async () => {
|
||||
hoisted.mockGetFeatureFlagResult.mockImplementation((key: string) =>
|
||||
key === 'app-mode-template-order'
|
||||
? {
|
||||
key,
|
||||
enabled: true,
|
||||
variant: undefined,
|
||||
payload: { templateIds: ['a'] }
|
||||
}
|
||||
: undefined
|
||||
)
|
||||
createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
hoisted.mockOnFeatureFlags.mock.calls[0][0]()
|
||||
|
||||
expect(getPayloadSource()?.payloads.value).toEqual({
|
||||
'app-mode-template-order': { templateIds: ['a'] }
|
||||
})
|
||||
})
|
||||
|
||||
it('reloads flags when auth settles anonymous', async () => {
|
||||
createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
hoisted.refs.isInitialized.value = true
|
||||
// Two flushes: one for the `until` watcher, one for its awaiting continuation.
|
||||
await nextTick()
|
||||
await nextTick()
|
||||
|
||||
expect(hoisted.mockReloadFeatureFlags).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('reloads flags when auth is already settled anonymous before init', async () => {
|
||||
hoisted.refs.isInitialized.value = true
|
||||
|
||||
createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
await nextTick()
|
||||
|
||||
expect(hoisted.mockReloadFeatureFlags).toHaveBeenCalledOnce()
|
||||
expect(hoisted.mockOnUserResolved).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not reload flags when auth settles with a user (identify drives it)', async () => {
|
||||
hoisted.refs.resolvedUserInfo.value = { id: 'user-1' }
|
||||
createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
hoisted.refs.isInitialized.value = true
|
||||
await nextTick()
|
||||
await nextTick()
|
||||
|
||||
expect(hoisted.mockReloadFeatureFlags).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('marks ready via the timeout backstop when no flags arrive', async () => {
|
||||
createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
expect(remoteUserDataReady.value).toBe(false)
|
||||
|
||||
vi.advanceTimersByTime(3000)
|
||||
|
||||
expect(remoteUserDataReady.value).toBe(true)
|
||||
})
|
||||
|
||||
it('marks ready when PostHog fails to load', async () => {
|
||||
hoisted.mockInit.mockImplementationOnce(() => {
|
||||
throw new Error('init boom')
|
||||
})
|
||||
createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
expect(remoteUserDataReady.value).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('page view', () => {
|
||||
it('captures legacy page view event with page_name property', async () => {
|
||||
const provider = createProvider()
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { until } from '@vueuse/core'
|
||||
import type { PostHog } from 'posthog-js'
|
||||
import { ref, watch } from 'vue'
|
||||
import { watch } from 'vue'
|
||||
import type { WatchStopHandle } from 'vue'
|
||||
|
||||
import { createPostHogBeforeSend } from '@comfyorg/shared-frontend-utils/piiUtil'
|
||||
@@ -9,13 +8,6 @@ import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
|
||||
import type { RemoteConfig } from '@/platform/remoteConfig/types'
|
||||
import { REMOTE_USER_DATA_KEYS } from '@/platform/remoteUserData/keys'
|
||||
import {
|
||||
markRemoteUserDataPending,
|
||||
markRemoteUserDataReady,
|
||||
setPayloadSource
|
||||
} from '@/platform/remoteUserData/payloadSource'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
|
||||
import type {
|
||||
AuthMetadata,
|
||||
@@ -91,19 +83,6 @@ function readDesktopEntryProps(): DesktopEntryProps | null {
|
||||
return props
|
||||
}
|
||||
|
||||
// Fall back to defaults if no flag response lands, so a blocked /flags request
|
||||
// or slow auth can't leave gated UI pending forever.
|
||||
const REMOTE_USER_DATA_READY_TIMEOUT_MS = 3000
|
||||
|
||||
function collectPayloads(posthog: PostHog): Record<string, unknown> {
|
||||
const payloads: Record<string, unknown> = {}
|
||||
for (const key of REMOTE_USER_DATA_KEYS) {
|
||||
const payload = posthog.getFeatureFlagResult(key)?.payload
|
||||
if (payload !== undefined) payloads[key] = payload
|
||||
}
|
||||
return payloads
|
||||
}
|
||||
|
||||
/**
|
||||
* PostHog Telemetry Provider - Cloud Build Implementation
|
||||
*
|
||||
@@ -122,8 +101,6 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
|
||||
private disabledEvents = new Set<TelemetryEventName>(DEFAULT_DISABLED_EVENTS)
|
||||
private desktopEntryProps: DesktopEntryProps | null = null
|
||||
private stopSubscriptionTierWatch: WatchStopHandle | null = null
|
||||
private remoteUserDataReadyTimeout: ReturnType<typeof setTimeout> | null =
|
||||
null
|
||||
|
||||
constructor() {
|
||||
this.configureDisabledEvents(
|
||||
@@ -139,12 +116,6 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
|
||||
|
||||
const apiKey = window.__CONFIG__?.posthog_project_token
|
||||
if (apiKey) {
|
||||
// Registered before the async posthog import so late consumers always
|
||||
// see a source; onFeatureFlags mutates this ref later.
|
||||
const payloads = ref<Record<string, unknown>>({})
|
||||
setPayloadSource({ payloads })
|
||||
this.armRemoteUserDataReadiness()
|
||||
|
||||
try {
|
||||
void import('posthog-js')
|
||||
.then((posthogModule) => {
|
||||
@@ -161,9 +132,6 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
|
||||
debug: import.meta.env.VITE_POSTHOG_DEBUG === 'true',
|
||||
...serverConfig,
|
||||
person_profiles: 'identified_only',
|
||||
// Fetch flags only after auth resolves, so the first payloads are
|
||||
// already cohort/person-targeted rather than anonymous-then-reordered.
|
||||
advanced_disable_feature_flags_on_first_load: true,
|
||||
// cookie_domain omitted: posthog-js sets a first-party cross-subdomain cookie
|
||||
// automatically when persistence includes 'cookie' (the default).
|
||||
// Explicit override interacts badly with posthog-js#3578 where reset() fails
|
||||
@@ -174,13 +142,7 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
|
||||
this.flushEventQueue()
|
||||
this.registerDesktopEntryProps()
|
||||
|
||||
this.posthog.onFeatureFlags(() => {
|
||||
payloads.value = collectPayloads(this.posthog!)
|
||||
this.settleRemoteUserDataReady()
|
||||
})
|
||||
|
||||
const currentUser = useCurrentUser()
|
||||
void this.reloadFeatureFlagsWhenAnonymous(currentUser)
|
||||
currentUser.onUserResolved((user) => {
|
||||
if (this.posthog && user.id) {
|
||||
this.posthog.identify(user.id)
|
||||
@@ -204,12 +166,10 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
|
||||
.catch((error) => {
|
||||
console.error('Failed to load PostHog:', error)
|
||||
this.isEnabled = false
|
||||
this.settleRemoteUserDataReady()
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize PostHog:', error)
|
||||
this.isEnabled = false
|
||||
this.settleRemoteUserDataReady()
|
||||
}
|
||||
} else {
|
||||
console.warn('PostHog API key not provided in runtime config')
|
||||
@@ -217,34 +177,6 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
|
||||
}
|
||||
}
|
||||
|
||||
private armRemoteUserDataReadiness(): void {
|
||||
markRemoteUserDataPending()
|
||||
this.remoteUserDataReadyTimeout = setTimeout(
|
||||
() => this.settleRemoteUserDataReady(),
|
||||
REMOTE_USER_DATA_READY_TIMEOUT_MS
|
||||
)
|
||||
}
|
||||
|
||||
private settleRemoteUserDataReady(): void {
|
||||
if (this.remoteUserDataReadyTimeout !== null) {
|
||||
clearTimeout(this.remoteUserDataReadyTimeout)
|
||||
this.remoteUserDataReadyTimeout = null
|
||||
}
|
||||
markRemoteUserDataReady()
|
||||
}
|
||||
|
||||
// identify() drives the flag fetch for logged-in users; anonymous users are
|
||||
// never identified, so trigger their fetch once auth settles.
|
||||
private async reloadFeatureFlagsWhenAnonymous(
|
||||
currentUser: ReturnType<typeof useCurrentUser>
|
||||
): Promise<void> {
|
||||
const authStore = useAuthStore()
|
||||
await until(() => authStore.isInitialized).toBe(true)
|
||||
if (!currentUser.resolvedUserInfo.value) {
|
||||
this.posthog?.reloadFeatureFlags()
|
||||
}
|
||||
}
|
||||
|
||||
private flushEventQueue(): void {
|
||||
if (!this.isInitialized || !this.posthog) return
|
||||
|
||||
|
||||
@@ -162,6 +162,114 @@ describe('useTemplateWorkflows', () => {
|
||||
expect(selectedTemplate.value).toEqual(category)
|
||||
})
|
||||
|
||||
it('should format template thumbnails correctly for default templates', () => {
|
||||
const { getTemplateThumbnailUrl } = useTemplateWorkflows()
|
||||
const template = {
|
||||
name: 'test-template',
|
||||
mediaSubtype: 'jpg',
|
||||
mediaType: 'image',
|
||||
description: 'Test template'
|
||||
}
|
||||
|
||||
const url = getTemplateThumbnailUrl(template, 'default', '1')
|
||||
|
||||
expect(url).toBe('mock-file-url/templates/test-template-1.jpg')
|
||||
})
|
||||
|
||||
it('should format template thumbnails correctly for custom templates', () => {
|
||||
const { getTemplateThumbnailUrl } = useTemplateWorkflows()
|
||||
const template = {
|
||||
name: 'test-template',
|
||||
mediaSubtype: 'jpg',
|
||||
mediaType: 'image',
|
||||
description: 'Test template'
|
||||
}
|
||||
|
||||
const url = getTemplateThumbnailUrl(template, 'custom-module')
|
||||
|
||||
expect(url).toBe(
|
||||
'mock-api-url/workflow_templates/custom-module/test-template.jpg'
|
||||
)
|
||||
})
|
||||
|
||||
it('should format template titles correctly', () => {
|
||||
const { getTemplateTitle } = useTemplateWorkflows()
|
||||
|
||||
// Default template with localized title
|
||||
const titleWithLocalized = getTemplateTitle(
|
||||
{
|
||||
name: 'test',
|
||||
localizedTitle: 'Localized Title',
|
||||
mediaType: 'image',
|
||||
mediaSubtype: 'jpg',
|
||||
description: 'Test'
|
||||
},
|
||||
'default'
|
||||
)
|
||||
expect(titleWithLocalized).toBe('Localized Title')
|
||||
|
||||
// Default template without localized title
|
||||
const titleWithFallback = getTemplateTitle(
|
||||
{
|
||||
name: 'test',
|
||||
title: 'Title',
|
||||
mediaType: 'image',
|
||||
mediaSubtype: 'jpg',
|
||||
description: 'Test'
|
||||
},
|
||||
'default'
|
||||
)
|
||||
expect(titleWithFallback).toBe('Title')
|
||||
|
||||
// Custom template
|
||||
const customTitle = getTemplateTitle(
|
||||
{
|
||||
name: 'test-template',
|
||||
title: 'Custom Title',
|
||||
mediaType: 'image',
|
||||
mediaSubtype: 'jpg',
|
||||
description: 'Test'
|
||||
},
|
||||
'custom-module'
|
||||
)
|
||||
expect(customTitle).toBe('Custom Title')
|
||||
|
||||
// Fallback to name
|
||||
const nameOnly = getTemplateTitle(
|
||||
{
|
||||
name: 'name-only',
|
||||
mediaType: 'image',
|
||||
mediaSubtype: 'jpg',
|
||||
description: 'Test'
|
||||
},
|
||||
'custom-module'
|
||||
)
|
||||
expect(nameOnly).toBe('name-only')
|
||||
})
|
||||
|
||||
it('should format template descriptions correctly', () => {
|
||||
const { getTemplateDescription } = useTemplateWorkflows()
|
||||
|
||||
// Default template with localized description
|
||||
const descWithLocalized = getTemplateDescription({
|
||||
name: 'test',
|
||||
localizedDescription: 'Localized Description',
|
||||
mediaType: 'image',
|
||||
mediaSubtype: 'jpg',
|
||||
description: 'Test'
|
||||
})
|
||||
expect(descWithLocalized).toBe('Localized Description')
|
||||
|
||||
// Custom template with description
|
||||
const customDesc = getTemplateDescription({
|
||||
name: 'test',
|
||||
description: 'custom-template_description',
|
||||
mediaType: 'image',
|
||||
mediaSubtype: 'jpg'
|
||||
})
|
||||
expect(customDesc).toBe('custom template description')
|
||||
})
|
||||
|
||||
it('should load a template from the "All" category', async () => {
|
||||
const { loadWorkflowTemplate, loadingTemplateId } = useTemplateWorkflows()
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useWorkflowTemplatesStore } from '@/platform/workflow/templates/repositories/workflowTemplatesStore'
|
||||
import type {
|
||||
TemplateGroup,
|
||||
TemplateInfo,
|
||||
WorkflowTemplates
|
||||
} from '@/platform/workflow/templates/types/template'
|
||||
import { api } from '@/scripts/api'
|
||||
@@ -54,6 +55,45 @@ export function useTemplateWorkflows() {
|
||||
return category !== null
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets template thumbnail URL
|
||||
*/
|
||||
const getTemplateThumbnailUrl = (
|
||||
template: TemplateInfo,
|
||||
sourceModule: string,
|
||||
index = '1'
|
||||
) => {
|
||||
const basePath =
|
||||
sourceModule === 'default'
|
||||
? api.fileURL(`/templates/${template.name}`)
|
||||
: api.apiURL(`/workflow_templates/${sourceModule}/${template.name}`)
|
||||
|
||||
const indexSuffix = sourceModule === 'default' && index ? `-${index}` : ''
|
||||
return `${basePath}${indexSuffix}.${template.mediaSubtype}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets formatted template title
|
||||
*/
|
||||
const getTemplateTitle = (template: TemplateInfo, sourceModule: string) => {
|
||||
const fallback =
|
||||
template.title ?? template.name ?? `${sourceModule} Template`
|
||||
return sourceModule === 'default'
|
||||
? (template.localizedTitle ?? fallback)
|
||||
: fallback
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets formatted template description
|
||||
*/
|
||||
const getTemplateDescription = (template: TemplateInfo) => {
|
||||
return (
|
||||
(template.localizedDescription || template.description)
|
||||
?.replace(/[-_]/g, ' ')
|
||||
.trim() ?? ''
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads a workflow template
|
||||
*/
|
||||
@@ -137,6 +177,9 @@ export function useTemplateWorkflows() {
|
||||
loadTemplates,
|
||||
selectFirstTemplateCategory,
|
||||
selectTemplateCategory,
|
||||
getTemplateThumbnailUrl,
|
||||
getTemplateTitle,
|
||||
getTemplateDescription,
|
||||
loadWorkflowTemplate
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,118 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { TemplateInfo } from '@/platform/workflow/templates/types/template'
|
||||
import {
|
||||
getBaseThumbnailSrc,
|
||||
getEffectiveSourceModule,
|
||||
getOverlayThumbnailSrc,
|
||||
getTemplateDescription,
|
||||
getTemplateTitle,
|
||||
isAppTemplate
|
||||
} from '@/platform/workflow/templates/utils/templateUtil'
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
fileURL: (path: string) => `mock-file-url${path}`,
|
||||
apiURL: (path: string) => `mock-api-url${path}`
|
||||
}
|
||||
}))
|
||||
|
||||
function makeTemplate(overrides: Partial<TemplateInfo> = {}): TemplateInfo {
|
||||
return {
|
||||
name: 'test-template',
|
||||
mediaType: 'image',
|
||||
mediaSubtype: 'jpg',
|
||||
description: 'Test template',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('getEffectiveSourceModule', () => {
|
||||
it('returns the template source module when set', () => {
|
||||
expect(
|
||||
getEffectiveSourceModule(makeTemplate({ sourceModule: 'custom-module' }))
|
||||
).toBe('custom-module')
|
||||
})
|
||||
|
||||
it('defaults to the frontend-provided set when unset or empty', () => {
|
||||
expect(getEffectiveSourceModule(makeTemplate())).toBe('default')
|
||||
expect(getEffectiveSourceModule(makeTemplate({ sourceModule: '' }))).toBe(
|
||||
'default'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isAppTemplate', () => {
|
||||
it('detects the .app name suffix', () => {
|
||||
expect(isAppTemplate(makeTemplate({ name: 'flux.app' }))).toBe(true)
|
||||
expect(isAppTemplate(makeTemplate({ name: 'flux' }))).toBe(false)
|
||||
expect(isAppTemplate(makeTemplate({ name: 'app.flux' }))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('thumbnail sources', () => {
|
||||
it('appends -1/-2 index suffixes for default templates', () => {
|
||||
const template = makeTemplate()
|
||||
expect(getBaseThumbnailSrc(template)).toBe(
|
||||
'mock-file-url/templates/test-template-1.jpg'
|
||||
)
|
||||
expect(getOverlayThumbnailSrc(template)).toBe(
|
||||
'mock-file-url/templates/test-template-2.jpg'
|
||||
)
|
||||
})
|
||||
|
||||
it('uses the unsuffixed API path for custom module templates', () => {
|
||||
const template = makeTemplate({ sourceModule: 'custom-module' })
|
||||
const expected =
|
||||
'mock-api-url/workflow_templates/custom-module/test-template.jpg'
|
||||
expect(getBaseThumbnailSrc(template)).toBe(expected)
|
||||
expect(getOverlayThumbnailSrc(template)).toBe(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getTemplateTitle', () => {
|
||||
it('prefers the localized title for default templates', () => {
|
||||
expect(
|
||||
getTemplateTitle(
|
||||
makeTemplate({ title: 'Title', localizedTitle: 'Localized Title' }),
|
||||
'default'
|
||||
)
|
||||
).toBe('Localized Title')
|
||||
})
|
||||
|
||||
it('falls back to title then name', () => {
|
||||
expect(getTemplateTitle(makeTemplate({ title: 'Title' }), 'default')).toBe(
|
||||
'Title'
|
||||
)
|
||||
expect(getTemplateTitle(makeTemplate(), 'custom-module')).toBe(
|
||||
'test-template'
|
||||
)
|
||||
})
|
||||
|
||||
it('ignores the localized title for custom module templates', () => {
|
||||
expect(
|
||||
getTemplateTitle(
|
||||
makeTemplate({ title: 'Title', localizedTitle: 'Localized Title' }),
|
||||
'custom-module'
|
||||
)
|
||||
).toBe('Title')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getTemplateDescription', () => {
|
||||
it('prefers the localized description', () => {
|
||||
expect(
|
||||
getTemplateDescription(
|
||||
makeTemplate({ localizedDescription: 'Localized Description' })
|
||||
)
|
||||
).toBe('Localized Description')
|
||||
})
|
||||
|
||||
it('replaces dashes and underscores with spaces', () => {
|
||||
expect(
|
||||
getTemplateDescription(
|
||||
makeTemplate({ description: 'custom-template_description' })
|
||||
)
|
||||
).toBe('custom template description')
|
||||
})
|
||||
})
|
||||
@@ -1,78 +0,0 @@
|
||||
import type { TemplateInfo } from '@/platform/workflow/templates/types/template'
|
||||
import { api } from '@/scripts/api'
|
||||
|
||||
/**
|
||||
* Source module a template loads from, defaulting to the frontend-provided set.
|
||||
*/
|
||||
export function getEffectiveSourceModule(template: TemplateInfo): string {
|
||||
return template.sourceModule || 'default'
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a template targets App mode (name suffixed with `.app`).
|
||||
*/
|
||||
export function isAppTemplate(template: TemplateInfo): boolean {
|
||||
return template.name.endsWith('.app')
|
||||
}
|
||||
|
||||
function getTemplateThumbnailUrl(
|
||||
template: TemplateInfo,
|
||||
sourceModule: string,
|
||||
index = '1'
|
||||
): string {
|
||||
const basePath =
|
||||
sourceModule === 'default'
|
||||
? api.fileURL(`/templates/${template.name}`)
|
||||
: api.apiURL(`/workflow_templates/${sourceModule}/${template.name}`)
|
||||
|
||||
const indexSuffix = sourceModule === 'default' && index ? `-${index}` : ''
|
||||
return `${basePath}${indexSuffix}.${template.mediaSubtype}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Primary thumbnail URL for a template.
|
||||
*/
|
||||
export function getBaseThumbnailSrc(template: TemplateInfo): string {
|
||||
const sourceModule = getEffectiveSourceModule(template)
|
||||
return getTemplateThumbnailUrl(
|
||||
template,
|
||||
sourceModule,
|
||||
sourceModule === 'default' ? '1' : ''
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Secondary/hover thumbnail URL for a template.
|
||||
*/
|
||||
export function getOverlayThumbnailSrc(template: TemplateInfo): string {
|
||||
const sourceModule = getEffectiveSourceModule(template)
|
||||
return getTemplateThumbnailUrl(
|
||||
template,
|
||||
sourceModule,
|
||||
sourceModule === 'default' ? '2' : ''
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Formatted template title, preferring the localized title for default templates.
|
||||
*/
|
||||
export function getTemplateTitle(
|
||||
template: TemplateInfo,
|
||||
sourceModule: string
|
||||
): string {
|
||||
const fallback = template.title ?? template.name ?? `${sourceModule} Template`
|
||||
return sourceModule === 'default'
|
||||
? (template.localizedTitle ?? fallback)
|
||||
: fallback
|
||||
}
|
||||
|
||||
/**
|
||||
* Formatted template description, preferring the localized description.
|
||||
*/
|
||||
export function getTemplateDescription(template: TemplateInfo): string {
|
||||
return (
|
||||
(template.localizedDescription || template.description)
|
||||
?.replace(/[-_]/g, ' ')
|
||||
.trim() ?? ''
|
||||
)
|
||||
}
|
||||
@@ -1,208 +0,0 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { render, screen, within } from '@testing-library/vue'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { NodeError } from '@/schemas/apiSchema'
|
||||
import LinearControls from '@/renderer/extensions/linearMode/LinearControls.vue'
|
||||
import { LINEAR_RUN_ERROR_WARNING_DESCRIPTION_ID } from '@/renderer/extensions/linearMode/linearRunErrorWarningIds'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
|
||||
const billingMock = vi.hoisted(() => ({
|
||||
isActiveSubscription: true
|
||||
}))
|
||||
|
||||
const overlayMock = vi.hoisted(() => ({
|
||||
overlayMessage: 'KSampler is missing a required input: model',
|
||||
overlayTitle: 'Required input missing'
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/billing/useBillingContext', () => ({
|
||||
useBillingContext: () => ({
|
||||
isActiveSubscription: billingMock.isActiveSubscription
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/components/error/useErrorOverlayState', () => ({
|
||||
useErrorOverlayState: () => ({
|
||||
overlayMessage: overlayMock.overlayMessage,
|
||||
overlayTitle: overlayMock.overlayTitle
|
||||
})
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
linearMode: {
|
||||
error: {
|
||||
goto: 'Show errors in graph'
|
||||
},
|
||||
mobileNoWorkflow: 'No workflow',
|
||||
runCount: 'Run count',
|
||||
viewJob: 'View job'
|
||||
},
|
||||
menu: {
|
||||
run: 'Run'
|
||||
},
|
||||
menuLabels: {
|
||||
publish: 'Publish'
|
||||
},
|
||||
queue: {
|
||||
jobAddedToQueue: 'Job added to queue',
|
||||
jobQueueing: 'Queueing'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const nodeErrors: Record<string, NodeError> = {
|
||||
'1': {
|
||||
class_type: 'TestNode',
|
||||
dependent_outputs: [],
|
||||
errors: [
|
||||
{
|
||||
type: 'required_input_missing',
|
||||
message: 'Missing input',
|
||||
details: '',
|
||||
extra_info: { input_name: 'prompt' }
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
function renderControls({
|
||||
hasError = false,
|
||||
isActiveSubscription = true,
|
||||
mobile = false
|
||||
}: {
|
||||
hasError?: boolean
|
||||
isActiveSubscription?: boolean
|
||||
mobile?: boolean
|
||||
} = {}) {
|
||||
billingMock.isActiveSubscription = isActiveSubscription
|
||||
|
||||
const pinia = createTestingPinia({
|
||||
createSpy: vi.fn,
|
||||
stubActions: false
|
||||
})
|
||||
setActivePinia(pinia)
|
||||
|
||||
useAppModeStore().selectedOutputs = [toNodeId(1)]
|
||||
if (hasError) {
|
||||
useExecutionErrorStore().lastNodeErrors = nodeErrors
|
||||
}
|
||||
|
||||
const toastTarget = document.createElement('div')
|
||||
|
||||
return render(LinearControls, {
|
||||
props: { mobile, toastTo: toastTarget },
|
||||
global: {
|
||||
plugins: [pinia, i18n],
|
||||
stubs: {
|
||||
AppModeWidgetList: true,
|
||||
Loader: true,
|
||||
PartnerNodesList: true,
|
||||
Popover: {
|
||||
template: '<div><slot name="button" /><slot /></div>'
|
||||
},
|
||||
ScrubableNumberInput: true,
|
||||
SubscribeToRunButton: true
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('LinearControls', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
billingMock.isActiveSubscription = true
|
||||
overlayMock.overlayMessage = 'KSampler is missing a required input: model'
|
||||
overlayMock.overlayTitle = 'Required input missing'
|
||||
})
|
||||
|
||||
it.for([
|
||||
{ label: 'desktop', mobile: false },
|
||||
{ label: 'mobile', mobile: true }
|
||||
])('shows a workflow error warning in $label controls', ({ mobile }) => {
|
||||
renderControls({ hasError: true, mobile })
|
||||
|
||||
const warning = screen.getByRole('status')
|
||||
expect(
|
||||
within(warning).getByText('Required input missing')
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
within(warning).getByText('KSampler is missing a required input: model')
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
within(warning).getByRole('button', { name: 'Show errors in graph' })
|
||||
).toBeInTheDocument()
|
||||
expect(within(warning).queryByLabelText('Close')).not.toBeInTheDocument()
|
||||
const runButton = screen.getByRole('button', { name: 'Run' })
|
||||
expect(runButton).toHaveAttribute(
|
||||
'aria-describedby',
|
||||
LINEAR_RUN_ERROR_WARNING_DESCRIPTION_ID
|
||||
)
|
||||
const description = screen.getByTestId(
|
||||
'linear-validation-warning-description'
|
||||
)
|
||||
expect(description).toHaveAttribute(
|
||||
'id',
|
||||
LINEAR_RUN_ERROR_WARNING_DESCRIPTION_ID
|
||||
)
|
||||
expect(description).toHaveTextContent('Required input missing')
|
||||
expect(description).toHaveTextContent(
|
||||
'KSampler is missing a required input: model'
|
||||
)
|
||||
expect(description).not.toHaveTextContent('Show errors in graph')
|
||||
})
|
||||
|
||||
it.for([
|
||||
{ label: 'desktop', mobile: false },
|
||||
{ label: 'mobile', mobile: true }
|
||||
])(
|
||||
'does not show the workflow error warning in $label controls without graph errors',
|
||||
({ mobile }) => {
|
||||
renderControls({ mobile })
|
||||
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Show errors in graph' })
|
||||
).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Run' })).not.toHaveAttribute(
|
||||
'aria-describedby'
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
it.for([
|
||||
{ label: 'desktop', mobile: false },
|
||||
{ label: 'mobile', mobile: true }
|
||||
])(
|
||||
'does not show the workflow error warning in $label controls without an active subscription',
|
||||
({ mobile }) => {
|
||||
renderControls({
|
||||
hasError: true,
|
||||
isActiveSubscription: false,
|
||||
mobile
|
||||
})
|
||||
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument()
|
||||
}
|
||||
)
|
||||
|
||||
it('does not show the warning when the error copy is empty', () => {
|
||||
overlayMock.overlayMessage = ''
|
||||
|
||||
renderControls({ hasError: true })
|
||||
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Run' })).not.toHaveAttribute(
|
||||
'aria-describedby'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,11 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { useTimeout } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed, ref, toValue, useTemplateRef } from 'vue'
|
||||
import { ref, useTemplateRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import AppModeWidgetList from '@/components/builder/AppModeWidgetList.vue'
|
||||
import { useErrorOverlayState } from '@/components/error/useErrorOverlayState'
|
||||
import Loader from '@/components/loader/Loader.vue'
|
||||
import ScrubableNumberInput from '@/components/common/ScrubableNumberInput.vue'
|
||||
import Popover from '@/components/ui/Popover.vue'
|
||||
@@ -15,15 +14,11 @@ import SubscribeToRunButton from '@/platform/cloud/subscription/components/Subsc
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import LinearRunErrorWarning from '@/renderer/extensions/linearMode/LinearRunErrorWarning.vue'
|
||||
import { LINEAR_RUN_ERROR_WARNING_DESCRIPTION_ID } from '@/renderer/extensions/linearMode/linearRunErrorWarningIds'
|
||||
import PartnerNodesList from '@/renderer/extensions/linearMode/PartnerNodesList.vue'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useQueueSettingsStore } from '@/stores/queueStore'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
|
||||
const { t } = useI18n()
|
||||
const commandStore = useCommandStore()
|
||||
const { batchCount } = storeToRefs(useQueueSettingsStore())
|
||||
@@ -33,8 +28,6 @@ const workflowStore = useWorkflowStore()
|
||||
const { isBuilderMode } = useAppMode()
|
||||
const appModeStore = useAppModeStore()
|
||||
const { hasOutputs } = storeToRefs(appModeStore)
|
||||
const { hasAnyError } = storeToRefs(useExecutionErrorStore())
|
||||
const { overlayMessage } = useErrorOverlayState()
|
||||
|
||||
const { toastTo, mobile } = defineProps<{
|
||||
toastTo?: string | HTMLElement
|
||||
@@ -50,13 +43,6 @@ const { ready: jobToastTimeout, start: resetJobToastTimeout } = useTimeout(
|
||||
{ controls: true, immediate: false }
|
||||
)
|
||||
const widgetListRef = useTemplateRef('widgetListRef')
|
||||
const linearRunButtonTestId = 'linear-run-button'
|
||||
const showRunErrorWarning = computed(
|
||||
() =>
|
||||
hasAnyError.value &&
|
||||
toValue(isActiveSubscription) &&
|
||||
toValue(overlayMessage).trim().length > 0
|
||||
)
|
||||
|
||||
//TODO: refactor out of this file.
|
||||
//code length is small, but changes should propagate
|
||||
@@ -148,10 +134,9 @@ function handleDragDrop() {
|
||||
<PartnerNodesList v-if="!mobile" />
|
||||
<section
|
||||
v-if="mobile"
|
||||
:data-testid="linearRunButtonTestId"
|
||||
data-testid="linear-run-button"
|
||||
class="border-t border-node-component-border p-4 pb-6"
|
||||
>
|
||||
<LinearRunErrorWarning v-if="showRunErrorWarning" />
|
||||
<SubscribeToRunButton
|
||||
v-if="!isActiveSubscription"
|
||||
class="mt-4 w-full"
|
||||
@@ -181,24 +166,18 @@ function handleDragDrop() {
|
||||
variant="primary"
|
||||
class="grow"
|
||||
size="lg"
|
||||
:aria-describedby="
|
||||
showRunErrorWarning
|
||||
? LINEAR_RUN_ERROR_WARNING_DESCRIPTION_ID
|
||||
: undefined
|
||||
"
|
||||
@click="runButtonClick"
|
||||
>
|
||||
<i aria-hidden="true" class="icon-[lucide--play]" />
|
||||
<i class="icon-[lucide--play]" />
|
||||
{{ t('menu.run') }}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
<section
|
||||
v-else
|
||||
:data-testid="linearRunButtonTestId"
|
||||
data-testid="linear-run-button"
|
||||
class="border-t border-node-component-border p-4 pb-6"
|
||||
>
|
||||
<LinearRunErrorWarning v-if="showRunErrorWarning" />
|
||||
<div
|
||||
class="m-1 mb-2 text-node-component-slot-text"
|
||||
v-text="t('linearMode.runCount')"
|
||||
@@ -219,14 +198,9 @@ function handleDragDrop() {
|
||||
variant="primary"
|
||||
class="mt-4 w-full text-sm"
|
||||
size="lg"
|
||||
:aria-describedby="
|
||||
showRunErrorWarning
|
||||
? LINEAR_RUN_ERROR_WARNING_DESCRIPTION_ID
|
||||
: undefined
|
||||
"
|
||||
@click="runButtonClick"
|
||||
>
|
||||
<i aria-hidden="true" class="icon-[lucide--play]" />
|
||||
<i class="icon-[lucide--play]" />
|
||||
{{ t('menu.run') }}
|
||||
</Button>
|
||||
</section>
|
||||
|
||||
@@ -1,250 +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 { ref } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import {
|
||||
markRemoteUserDataPending,
|
||||
markRemoteUserDataReady,
|
||||
setPayloadSource
|
||||
} from '@/platform/remoteUserData/payloadSource'
|
||||
import type { TemplateInfo } from '@/platform/workflow/templates/types/template'
|
||||
|
||||
import LinearGetStarted from './LinearGetStarted.vue'
|
||||
|
||||
const {
|
||||
templatesState,
|
||||
loadTemplates,
|
||||
loadWorkflowTemplate,
|
||||
showDialog,
|
||||
executeCommand,
|
||||
addToast
|
||||
} = vi.hoisted(() => ({
|
||||
templatesState: {
|
||||
isTemplatesLoaded: true,
|
||||
loadingTemplateId: null as string | null,
|
||||
enhancedTemplates: [] as TemplateInfo[]
|
||||
},
|
||||
loadTemplates: vi.fn(),
|
||||
loadWorkflowTemplate: vi.fn(),
|
||||
showDialog: vi.fn(),
|
||||
executeCommand: vi.fn(),
|
||||
addToast: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock(
|
||||
'@/platform/workflow/templates/composables/useTemplateWorkflows',
|
||||
async () => {
|
||||
const { computed } = await import('vue')
|
||||
return {
|
||||
useTemplateWorkflows: () => ({
|
||||
isTemplatesLoaded: computed(() => templatesState.isTemplatesLoaded),
|
||||
loadingTemplateId: computed(() => templatesState.loadingTemplateId),
|
||||
loadTemplates,
|
||||
loadWorkflowTemplate
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
vi.mock(
|
||||
'@/platform/workflow/templates/repositories/workflowTemplatesStore',
|
||||
() => ({
|
||||
useWorkflowTemplatesStore: () => ({
|
||||
get enhancedTemplates() {
|
||||
return templatesState.enhancedTemplates
|
||||
}
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock('@/composables/useWorkflowTemplateSelectorDialog', () => ({
|
||||
useWorkflowTemplateSelectorDialog: () => ({ show: showDialog })
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/commandStore', () => ({
|
||||
useCommandStore: () => ({ execute: executeCommand })
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/updates/common/toastStore', () => ({
|
||||
useToastStore: () => ({ add: addToast })
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
fileURL: (path: string) => path,
|
||||
apiURL: (path: string) => path
|
||||
}
|
||||
}))
|
||||
|
||||
function makeTemplate(name: string, sourceModule?: string): TemplateInfo {
|
||||
return {
|
||||
name,
|
||||
mediaType: 'image',
|
||||
mediaSubtype: 'webp',
|
||||
description: '',
|
||||
...(sourceModule && { sourceModule })
|
||||
}
|
||||
}
|
||||
|
||||
function registerOrder(templateIds: string[]) {
|
||||
setPayloadSource({
|
||||
payloads: ref({ 'app-mode-template-order': { templateIds } })
|
||||
})
|
||||
}
|
||||
|
||||
function renderedTemplateNames(): (string | undefined)[] {
|
||||
return screen
|
||||
.getAllByTestId('linear-get-started-template')
|
||||
.map((card) => card.textContent?.trim())
|
||||
}
|
||||
|
||||
const i18n = createI18n({ legacy: false, locale: 'en', missingWarn: false })
|
||||
|
||||
function renderComponent() {
|
||||
return render(LinearGetStarted, {
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
stubs: {
|
||||
LazyImage: { template: '<div />' }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('LinearGetStarted', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
setPayloadSource(null)
|
||||
markRemoteUserDataReady()
|
||||
templatesState.isTemplatesLoaded = true
|
||||
templatesState.loadingTemplateId = null
|
||||
templatesState.enhancedTemplates = [
|
||||
makeTemplate('a.app'),
|
||||
makeTemplate('b.app', 'mymod'),
|
||||
makeTemplate('c'),
|
||||
makeTemplate('d.app'),
|
||||
makeTemplate('e.app'),
|
||||
makeTemplate('f.app')
|
||||
]
|
||||
loadWorkflowTemplate.mockResolvedValue(true)
|
||||
})
|
||||
|
||||
it('loads templates on mount', () => {
|
||||
renderComponent()
|
||||
expect(loadTemplates).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows only the first four app templates', () => {
|
||||
renderComponent()
|
||||
const cards = screen.getAllByTestId('linear-get-started-template')
|
||||
expect(cards).toHaveLength(4)
|
||||
expect(screen.getByText('a.app')).toBeInTheDocument()
|
||||
expect(screen.getByText('e.app')).toBeInTheDocument()
|
||||
expect(screen.queryByText('f.app')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('c')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('falls back to the first four templates when none target app mode', () => {
|
||||
templatesState.enhancedTemplates = [
|
||||
makeTemplate('one'),
|
||||
makeTemplate('two'),
|
||||
makeTemplate('three'),
|
||||
makeTemplate('four'),
|
||||
makeTemplate('five')
|
||||
]
|
||||
renderComponent()
|
||||
const cards = screen.getAllByTestId('linear-get-started-template')
|
||||
expect(cards).toHaveLength(4)
|
||||
expect(screen.getByText('one')).toBeInTheDocument()
|
||||
expect(screen.queryByText('five')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('orders featured templates by the remote payload, backfilling defaults', () => {
|
||||
registerOrder(['d.app', 'b.app'])
|
||||
renderComponent()
|
||||
expect(renderedTemplateNames()).toEqual([
|
||||
'd.app',
|
||||
'b.app',
|
||||
'a.app',
|
||||
'e.app'
|
||||
])
|
||||
})
|
||||
|
||||
it('drops unknown ids from the remote order', () => {
|
||||
registerOrder(['ghost', 'e.app'])
|
||||
renderComponent()
|
||||
expect(renderedTemplateNames()).toEqual([
|
||||
'e.app',
|
||||
'a.app',
|
||||
'b.app',
|
||||
'd.app'
|
||||
])
|
||||
})
|
||||
|
||||
it('shows skeletons until the remote order is ready', () => {
|
||||
markRemoteUserDataPending()
|
||||
registerOrder(['a.app'])
|
||||
renderComponent()
|
||||
expect(screen.queryAllByTestId('linear-get-started-template')).toHaveLength(
|
||||
0
|
||||
)
|
||||
})
|
||||
|
||||
it('loads a template with its source module when a card is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderComponent()
|
||||
const cards = screen.getAllByTestId('linear-get-started-template')
|
||||
await user.click(cards[1])
|
||||
expect(loadWorkflowTemplate).toHaveBeenCalledWith('b.app', 'mymod')
|
||||
})
|
||||
|
||||
it('defaults the source module when a card has none', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderComponent()
|
||||
const cards = screen.getAllByTestId('linear-get-started-template')
|
||||
await user.click(cards[0])
|
||||
expect(loadWorkflowTemplate).toHaveBeenCalledWith('a.app', 'default')
|
||||
})
|
||||
|
||||
it('disables cards and actions while a template is loading', async () => {
|
||||
const user = userEvent.setup()
|
||||
templatesState.loadingTemplateId = 'a.app'
|
||||
renderComponent()
|
||||
const cards = screen.getAllByTestId('linear-get-started-template')
|
||||
await user.click(cards[1])
|
||||
expect(loadWorkflowTemplate).not.toHaveBeenCalled()
|
||||
expect(screen.getByTestId('linear-get-started-import')).toBeDisabled()
|
||||
expect(screen.getByTestId('linear-get-started-discover')).toBeDisabled()
|
||||
})
|
||||
|
||||
it('shows an error toast when loading a template fails', async () => {
|
||||
const user = userEvent.setup()
|
||||
loadWorkflowTemplate.mockResolvedValue(false)
|
||||
renderComponent()
|
||||
await user.click(screen.getAllByTestId('linear-get-started-template')[0])
|
||||
await vi.waitFor(() =>
|
||||
expect(addToast).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
severity: 'error',
|
||||
detail: 'linearMode.getStarted.loadFailed'
|
||||
})
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
it('opens a workflow via the command store when import is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderComponent()
|
||||
await user.click(screen.getByTestId('linear-get-started-import'))
|
||||
expect(executeCommand).toHaveBeenCalledWith('Comfy.OpenWorkflow')
|
||||
})
|
||||
|
||||
it('opens the template selector when discover all is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderComponent()
|
||||
await user.click(screen.getByTestId('linear-get-started-discover'))
|
||||
expect(showDialog).toHaveBeenCalledWith('appbuilder')
|
||||
})
|
||||
})
|
||||
@@ -1,183 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { z } from 'zod'
|
||||
|
||||
import LazyImage from '@/components/common/LazyImage.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useWorkflowTemplateSelectorDialog } from '@/composables/useWorkflowTemplateSelectorDialog'
|
||||
import { resolvePrioritizedIds } from '@/platform/remoteUserData/resolvePrioritizedIds'
|
||||
import { useRemoteUserData } from '@/platform/remoteUserData/useRemoteUserData'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { useTemplateWorkflows } from '@/platform/workflow/templates/composables/useTemplateWorkflows'
|
||||
import { useWorkflowTemplatesStore } from '@/platform/workflow/templates/repositories/workflowTemplatesStore'
|
||||
import type { TemplateInfo } from '@/platform/workflow/templates/types/template'
|
||||
import {
|
||||
getBaseThumbnailSrc,
|
||||
getEffectiveSourceModule,
|
||||
getTemplateTitle,
|
||||
isAppTemplate
|
||||
} from '@/platform/workflow/templates/utils/templateUtil'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
|
||||
const FEATURED_COUNT = 4
|
||||
|
||||
const { t } = useI18n()
|
||||
const templatesStore = useWorkflowTemplatesStore()
|
||||
const toastStore = useToastStore()
|
||||
const commandStore = useCommandStore()
|
||||
const {
|
||||
isTemplatesLoaded,
|
||||
loadingTemplateId,
|
||||
loadTemplates,
|
||||
loadWorkflowTemplate
|
||||
} = useTemplateWorkflows()
|
||||
const templateSelectorDialog = useWorkflowTemplateSelectorDialog()
|
||||
|
||||
const { data: templateOrder, isLoaded: isOrderLoaded } = useRemoteUserData({
|
||||
key: 'app-mode-template-order',
|
||||
schema: z.object({ templateIds: z.array(z.string()) }),
|
||||
defaultValue: { templateIds: [] }
|
||||
})
|
||||
|
||||
onMounted(() => void loadTemplates())
|
||||
|
||||
const featuredTemplates = computed(() => {
|
||||
const all = templatesStore.enhancedTemplates
|
||||
const apps = all.filter(isAppTemplate)
|
||||
const candidates = apps.length ? apps : all
|
||||
const byName = new Map(
|
||||
candidates.map((template) => [template.name, template])
|
||||
)
|
||||
const orderedNames = resolvePrioritizedIds(
|
||||
templateOrder.value.templateIds,
|
||||
candidates.map((template) => template.name),
|
||||
new Set(byName.keys()),
|
||||
FEATURED_COUNT
|
||||
)
|
||||
return orderedNames.map((name) => byName.get(name)!)
|
||||
})
|
||||
|
||||
const isFeaturedReady = computed(
|
||||
() => isTemplatesLoaded.value && isOrderLoaded.value
|
||||
)
|
||||
|
||||
const isLoadingTemplate = computed(() => loadingTemplateId.value !== null)
|
||||
|
||||
function titleOf(template: TemplateInfo) {
|
||||
return getTemplateTitle(template, getEffectiveSourceModule(template))
|
||||
}
|
||||
|
||||
async function selectTemplate(template: TemplateInfo) {
|
||||
const loaded = await loadWorkflowTemplate(
|
||||
template.name,
|
||||
getEffectiveSourceModule(template)
|
||||
)
|
||||
if (!loaded) {
|
||||
toastStore.add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: t('linearMode.getStarted.loadFailed')
|
||||
})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-testid="linear-get-started"
|
||||
class="flex size-full min-h-0 flex-col items-center overflow-y-auto px-8 pt-[clamp(96px,18vh,200px)] pb-16"
|
||||
>
|
||||
<div class="flex w-full max-w-4xl flex-col items-center gap-8">
|
||||
<div class="flex flex-col items-center gap-1 text-center">
|
||||
<h1 class="text-5xl leading-none font-medium text-base-foreground">
|
||||
{{ t('linearMode.getStarted.title') }}
|
||||
</h1>
|
||||
<p class="max-w-lg text-sm/relaxed text-muted-foreground">
|
||||
{{ t('linearMode.getStarted.subtitle') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center justify-center gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="textonly"
|
||||
size="md"
|
||||
class="rounded-full bg-interface-menu-component-surface-selected px-3 hover:bg-interface-menu-component-surface-selected"
|
||||
>
|
||||
<i class="icon-[lucide--layout-template] size-3.5" />
|
||||
{{ t('linearMode.getStarted.templates') }}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="textonly"
|
||||
size="md"
|
||||
class="rounded-full bg-interface-menu-component-surface-hovered px-3 opacity-70 hover:bg-interface-menu-component-surface-selected hover:opacity-100"
|
||||
data-testid="linear-get-started-import"
|
||||
:disabled="isLoadingTemplate"
|
||||
@click="commandStore.execute('Comfy.OpenWorkflow')"
|
||||
>
|
||||
<i class="icon-[lucide--upload] size-3.5" />
|
||||
{{ t('linearMode.getStarted.importWorkflow') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col items-center gap-8">
|
||||
<div class="flex flex-wrap items-center justify-center gap-5">
|
||||
<template v-if="isFeaturedReady">
|
||||
<button
|
||||
v-for="template in featuredTemplates"
|
||||
:key="template.name"
|
||||
type="button"
|
||||
data-testid="linear-get-started-template"
|
||||
:data-template-name="template.name"
|
||||
class="group relative flex size-50 cursor-pointer appearance-none flex-col overflow-hidden rounded-2xl border-none bg-base-background p-0 text-left disabled:cursor-default"
|
||||
:disabled="isLoadingTemplate"
|
||||
@click="selectTemplate(template)"
|
||||
>
|
||||
<div
|
||||
class="absolute inset-0 overflow-hidden rounded-2xl bg-dialog-surface"
|
||||
>
|
||||
<LazyImage
|
||||
:src="getBaseThumbnailSrc(template)"
|
||||
alt=""
|
||||
image-class="size-full object-cover transition-transform duration-300 ease-out group-hover:scale-105"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="pointer-events-none absolute inset-0 rounded-2xl bg-linear-to-b from-black/40 via-transparent to-black/50"
|
||||
/>
|
||||
<i
|
||||
v-if="loadingTemplateId === template.name"
|
||||
class="absolute inset-0 z-20 m-auto icon-[lucide--loader-2] size-8 animate-spin text-white"
|
||||
/>
|
||||
<span
|
||||
class="relative z-10 mt-auto w-full truncate p-3 text-sm font-semibold text-white"
|
||||
>
|
||||
{{ titleOf(template) }}
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div
|
||||
v-for="n in FEATURED_COUNT"
|
||||
:key="n"
|
||||
class="size-50 animate-pulse rounded-2xl bg-dialog-surface"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="lg"
|
||||
data-testid="linear-get-started-discover"
|
||||
:disabled="isLoadingTemplate"
|
||||
@click="templateSelectorDialog.show('appbuilder')"
|
||||
>
|
||||
{{ t('linearMode.getStarted.discoverAll') }}
|
||||
<i class="icon-[lucide--arrow-right] size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,92 +0,0 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import LinearRunErrorWarning from '@/renderer/extensions/linearMode/LinearRunErrorWarning.vue'
|
||||
import { LINEAR_RUN_ERROR_WARNING_DESCRIPTION_ID } from '@/renderer/extensions/linearMode/linearRunErrorWarningIds'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
overlayMessage: 'KSampler is missing a required input: model',
|
||||
overlayTitle: 'Required input missing',
|
||||
viewErrorsInGraph: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/components/error/useErrorOverlayState', () => ({
|
||||
useErrorOverlayState: () => ({
|
||||
overlayMessage: mocks.overlayMessage,
|
||||
overlayTitle: mocks.overlayTitle
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useViewErrorsInGraph', () => ({
|
||||
useViewErrorsInGraph: () => ({
|
||||
viewErrorsInGraph: mocks.viewErrorsInGraph
|
||||
})
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
linearMode: {
|
||||
error: {
|
||||
goto: 'Show errors in graph'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function renderWarning() {
|
||||
const user = userEvent.setup()
|
||||
const result = render(LinearRunErrorWarning, {
|
||||
global: { plugins: [i18n] }
|
||||
})
|
||||
|
||||
return { ...result, user }
|
||||
}
|
||||
|
||||
describe('LinearRunErrorWarning', () => {
|
||||
beforeEach(() => {
|
||||
mocks.viewErrorsInGraph.mockReset()
|
||||
})
|
||||
|
||||
it('shows the current error overlay title and message without a close action', () => {
|
||||
renderWarning()
|
||||
|
||||
const warning = screen.getByRole('status')
|
||||
expect(warning).toHaveTextContent('Required input missing')
|
||||
expect(warning).toHaveTextContent(
|
||||
'KSampler is missing a required input: model'
|
||||
)
|
||||
expect(screen.getByText('Required input missing')).toHaveAttribute(
|
||||
'title',
|
||||
'Required input missing'
|
||||
)
|
||||
const description = screen.getByTestId(
|
||||
'linear-validation-warning-description'
|
||||
)
|
||||
expect(description).toHaveAttribute(
|
||||
'id',
|
||||
LINEAR_RUN_ERROR_WARNING_DESCRIPTION_ID
|
||||
)
|
||||
expect(description).toHaveTextContent('Required input missing')
|
||||
expect(description).toHaveTextContent(
|
||||
'KSampler is missing a required input: model'
|
||||
)
|
||||
expect(description).not.toHaveTextContent('Show errors in graph')
|
||||
expect(screen.queryByLabelText('Close')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opens graph errors when the action is clicked', async () => {
|
||||
const { user } = renderWarning()
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'Show errors in graph' })
|
||||
)
|
||||
|
||||
expect(mocks.viewErrorsInGraph).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
@@ -1,63 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useErrorOverlayState } from '@/components/error/useErrorOverlayState'
|
||||
import { useViewErrorsInGraph } from '@/composables/useViewErrorsInGraph'
|
||||
import { LINEAR_RUN_ERROR_WARNING_DESCRIPTION_ID } from '@/renderer/extensions/linearMode/linearRunErrorWarningIds'
|
||||
|
||||
const { t } = useI18n()
|
||||
const { viewErrorsInGraph } = useViewErrorsInGraph()
|
||||
const { overlayMessage, overlayTitle } = useErrorOverlayState()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
role="status"
|
||||
data-testid="linear-validation-warning"
|
||||
class="mb-3 flex w-full flex-col gap-2 overflow-hidden rounded-lg border border-l-4 border-border-default border-l-destructive-background bg-base-background p-3 shadow-interface transition-colors duration-200 ease-in-out"
|
||||
>
|
||||
<div
|
||||
:id="LINEAR_RUN_ERROR_WARNING_DESCRIPTION_ID"
|
||||
data-testid="linear-validation-warning-description"
|
||||
class="flex flex-col gap-2"
|
||||
>
|
||||
<div class="flex w-full items-start gap-2">
|
||||
<i
|
||||
aria-hidden="true"
|
||||
class="mt-0.5 icon-[lucide--circle-x] size-4 shrink-0 text-destructive-background"
|
||||
/>
|
||||
<span
|
||||
class="min-w-0 flex-1 truncate text-sm text-base-foreground"
|
||||
:title="overlayTitle"
|
||||
>
|
||||
{{ overlayTitle }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex w-full items-start gap-2"
|
||||
data-testid="linear-validation-warning-message"
|
||||
>
|
||||
<span class="size-4 shrink-0" aria-hidden="true" />
|
||||
<p
|
||||
class="m-0 line-clamp-3 min-w-0 flex-1 text-sm/snug wrap-break-word whitespace-pre-wrap text-muted-foreground"
|
||||
>
|
||||
{{ overlayMessage }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex w-full items-center justify-end pt-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="unset"
|
||||
class="min-h-8 rounded-lg px-3 py-2 text-xs font-normal"
|
||||
data-testid="linear-view-errors"
|
||||
@click="viewErrorsInGraph"
|
||||
>
|
||||
{{ t('linearMode.error.goto') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -5,53 +5,62 @@ import { createI18n } from 'vue-i18n'
|
||||
|
||||
import LinearWelcome from './LinearWelcome.vue'
|
||||
|
||||
const { appModeState, enterBuilder } = vi.hoisted(() => ({
|
||||
appModeState: { hasNodes: false, hasOutputs: false },
|
||||
enterBuilder: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/appModeStore', async () => {
|
||||
const { computed, reactive } = await import('vue')
|
||||
const { hasNodes, hasOutputs, enterBuilder } = vi.hoisted(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const { ref } = require('vue')
|
||||
return {
|
||||
useAppModeStore: () =>
|
||||
reactive({
|
||||
hasNodes: computed(() => appModeState.hasNodes),
|
||||
hasOutputs: computed(() => appModeState.hasOutputs),
|
||||
enterBuilder
|
||||
})
|
||||
hasNodes: ref(false),
|
||||
hasOutputs: ref(false),
|
||||
enterBuilder: vi.fn()
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/composables/useAppMode', () => ({
|
||||
useAppMode: () => ({ setMode: vi.fn() })
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useWorkflowTemplateSelectorDialog', () => ({
|
||||
useWorkflowTemplateSelectorDialog: () => ({ show: vi.fn() })
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/appModeStore', () => ({
|
||||
useAppModeStore: () => ({
|
||||
hasNodes,
|
||||
hasOutputs,
|
||||
enterBuilder
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
|
||||
useWorkflowStore: () => ({
|
||||
activeWorkflow: null
|
||||
})
|
||||
}))
|
||||
|
||||
const i18n = createI18n({ legacy: false, locale: 'en', missingWarn: false })
|
||||
|
||||
function renderComponent(
|
||||
opts: { hasNodes?: boolean; hasOutputs?: boolean } = {}
|
||||
) {
|
||||
appModeState.hasNodes = opts.hasNodes ?? false
|
||||
appModeState.hasOutputs = opts.hasOutputs ?? false
|
||||
hasNodes.value = opts.hasNodes ?? false
|
||||
hasOutputs.value = opts.hasOutputs ?? false
|
||||
return render(LinearWelcome, {
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
stubs: {
|
||||
LinearGetStarted: {
|
||||
template: '<div data-testid="get-started-stub" />'
|
||||
}
|
||||
}
|
||||
}
|
||||
global: { plugins: [i18n] }
|
||||
})
|
||||
}
|
||||
|
||||
describe('LinearWelcome', () => {
|
||||
beforeEach(() => {
|
||||
appModeState.hasNodes = false
|
||||
appModeState.hasOutputs = false
|
||||
hasNodes.value = false
|
||||
hasOutputs.value = false
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('shows the get started page when there are no nodes', () => {
|
||||
it('shows empty workflow text when there are no nodes', () => {
|
||||
renderComponent({ hasNodes: false })
|
||||
expect(screen.getByTestId('get-started-stub')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('linear-welcome')).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByTestId('linear-welcome-empty-workflow')
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByTestId('linear-welcome-build-app')
|
||||
).not.toBeInTheDocument()
|
||||
@@ -59,17 +68,10 @@ describe('LinearWelcome', () => {
|
||||
|
||||
it('shows build app button when there are nodes but no outputs', () => {
|
||||
renderComponent({ hasNodes: true, hasOutputs: false })
|
||||
expect(screen.queryByTestId('get-started-stub')).not.toBeInTheDocument()
|
||||
expect(screen.getByTestId('linear-welcome-build-app')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the ready-to-run card without the build button when the app has outputs', () => {
|
||||
renderComponent({ hasNodes: true, hasOutputs: true })
|
||||
expect(screen.getByTestId('linear-welcome')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByTestId('linear-welcome-build-app')
|
||||
screen.queryByTestId('linear-welcome-empty-workflow')
|
||||
).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('get-started-stub')).not.toBeInTheDocument()
|
||||
expect(screen.getByTestId('linear-welcome-build-app')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('clicking build app button calls enterBuilder', async () => {
|
||||
|
||||
@@ -1,68 +1,108 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import { useWorkflowTemplateSelectorDialog } from '@/composables/useWorkflowTemplateSelectorDialog'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import LinearGetStarted from '@/renderer/extensions/linearMode/LinearGetStarted.vue'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
|
||||
const { t } = useI18n()
|
||||
const { setMode } = useAppMode()
|
||||
const appModeStore = useAppModeStore()
|
||||
const { hasOutputs, hasNodes } = storeToRefs(appModeStore)
|
||||
const showGetStarted = computed(() => !hasOutputs.value && !hasNodes.value)
|
||||
|
||||
const card = computed(() =>
|
||||
hasOutputs.value
|
||||
? {
|
||||
icon: 'icon-[lucide--play]',
|
||||
title: t('linearMode.welcome.title'),
|
||||
description: t('linearMode.welcome.description')
|
||||
}
|
||||
: {
|
||||
icon: 'icon-[lucide--panels-top-left]',
|
||||
title: t('linearMode.buildPrompt.title'),
|
||||
description: t('linearMode.buildPrompt.description')
|
||||
}
|
||||
const workflowStore = useWorkflowStore()
|
||||
const isAppDefault = computed(
|
||||
() => workflowStore.activeWorkflow?.initialMode === 'app'
|
||||
)
|
||||
const templateSelectorDialog = useWorkflowTemplateSelectorDialog()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<LinearGetStarted v-if="showGetStarted" />
|
||||
<div
|
||||
v-else
|
||||
role="article"
|
||||
data-testid="linear-welcome"
|
||||
class="flex size-full flex-col items-center justify-center p-8 text-center"
|
||||
class="mx-auto flex h-full max-w-lg flex-col items-center justify-center gap-6 p-8 text-center"
|
||||
>
|
||||
<div class="flex w-full max-w-md flex-col items-center gap-6">
|
||||
<div
|
||||
class="flex w-full flex-col gap-5 rounded-2xl border border-border-subtle bg-base-background p-5 text-left"
|
||||
<div class="flex flex-col gap-2">
|
||||
<h2 class="text-3xl font-semibold text-muted-foreground">
|
||||
{{ t('linearMode.welcome.title') }}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div class="flex max-w-md flex-col gap-3 text-[14px] text-muted-foreground">
|
||||
<p class="mt-0">{{ t('linearMode.welcome.message') }}</p>
|
||||
<p class="mt-0">{{ t('linearMode.welcome.controls') }}</p>
|
||||
<p class="mt-0">{{ t('linearMode.welcome.sharing') }}</p>
|
||||
</div>
|
||||
<div v-if="hasOutputs" class="flex flex-row gap-2 text-[14px]">
|
||||
<p class="mt-0 text-base-foreground">
|
||||
<i18n-t keypath="linearMode.welcome.getStarted" tag="span">
|
||||
<template #runButton>
|
||||
<span
|
||||
class="mx-0.5 inline-flex -translate-y-0.5 transform cursor-default items-center rounded-sm bg-primary-background px-3.5 py-0.5 text-2xs font-medium text-base-foreground"
|
||||
>
|
||||
{{ t('menu.run') }}
|
||||
</span>
|
||||
</template>
|
||||
</i18n-t>
|
||||
</p>
|
||||
</div>
|
||||
<template v-else>
|
||||
<p
|
||||
v-if="!hasNodes"
|
||||
data-testid="linear-welcome-empty-workflow"
|
||||
class="mt-0 max-w-md text-sm text-base-foreground"
|
||||
>
|
||||
<div
|
||||
class="flex size-12 items-center justify-center rounded-xl bg-secondary-background-hover"
|
||||
>
|
||||
<i :class="cn(card.icon, 'size-6 text-base-foreground')" />
|
||||
</div>
|
||||
<h2 class="m-0 p-0 text-xl font-semibold text-base-foreground">
|
||||
{{ card.title }}
|
||||
</h2>
|
||||
<p class="m-0 p-0 text-sm/relaxed text-base-foreground">
|
||||
{{ card.description }}
|
||||
</p>
|
||||
{{ t('linearMode.emptyWorkflowExplanation') }}
|
||||
</p>
|
||||
<p
|
||||
v-if="hasNodes && isAppDefault"
|
||||
class="mt-0 max-w-md text-sm text-base-foreground"
|
||||
>
|
||||
<i18n-t keypath="linearMode.welcome.noOutputs" tag="span">
|
||||
<template #count>
|
||||
<span class="font-bold text-warning-background">{{
|
||||
t('linearMode.welcome.oneOutput')
|
||||
}}</span>
|
||||
</template>
|
||||
</i18n-t>
|
||||
</p>
|
||||
<div class="flex flex-row gap-2">
|
||||
<Button
|
||||
v-if="!hasOutputs"
|
||||
data-testid="linear-welcome-build-app"
|
||||
variant="inverted"
|
||||
data-testid="linear-welcome-back-to-workflow"
|
||||
variant="textonly"
|
||||
size="lg"
|
||||
@click="setMode('graph')"
|
||||
>
|
||||
{{ t('linearMode.backToWorkflow') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="!hasNodes"
|
||||
data-testid="linear-welcome-load-template"
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
@click="templateSelectorDialog.show('appbuilder')"
|
||||
>
|
||||
{{ t('linearMode.loadTemplate') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-else
|
||||
data-testid="linear-welcome-build-app"
|
||||
variant="primary"
|
||||
size="lg"
|
||||
class="w-full"
|
||||
@click="appModeStore.enterBuilder()"
|
||||
>
|
||||
<i class="icon-[lucide--hammer]" />
|
||||
{{ t('linearMode.buildPrompt.button') }}
|
||||
{{ t('linearMode.welcome.buildApp') }}
|
||||
<div
|
||||
class="absolute -top-2 -right-2 rounded-full bg-base-foreground px-1 text-2xs text-base-background"
|
||||
>
|
||||
{{ t('g.experimental') }}
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
export const LINEAR_RUN_ERROR_WARNING_DESCRIPTION_ID =
|
||||
'linear-run-error-warning'
|
||||
26
src/stores/electronDownloadStore.nonDesktop.test.ts
Normal file
26
src/stores/electronDownloadStore.nonDesktop.test.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useElectronDownloadStore } from '@/stores/electronDownloadStore'
|
||||
|
||||
const electronAPI = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({ isDesktop: false }))
|
||||
vi.mock('@/utils/envUtil', () => ({ electronAPI }))
|
||||
|
||||
describe('electronDownloadStore outside desktop', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
electronAPI.mockClear()
|
||||
})
|
||||
|
||||
it('skips the Electron bridge when not running on desktop', async () => {
|
||||
const store = useElectronDownloadStore()
|
||||
|
||||
await store.initialize()
|
||||
|
||||
expect(electronAPI).not.toHaveBeenCalled()
|
||||
expect(store.downloads).toEqual([])
|
||||
})
|
||||
})
|
||||
103
src/stores/electronDownloadStore.test.ts
Normal file
103
src/stores/electronDownloadStore.test.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { DownloadStatus } from '@comfyorg/comfyui-electron-types'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useElectronDownloadStore } from '@/stores/electronDownloadStore'
|
||||
|
||||
const downloadManagerMock = vi.hoisted(() => ({
|
||||
cancelDownload: vi.fn(),
|
||||
getAllDownloads: vi.fn(),
|
||||
onDownloadProgress: vi.fn(),
|
||||
pauseDownload: vi.fn(),
|
||||
resumeDownload: vi.fn(),
|
||||
startDownload: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
isDesktop: true
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/envUtil', () => ({
|
||||
electronAPI: () => ({
|
||||
DownloadManager: downloadManagerMock
|
||||
})
|
||||
}))
|
||||
|
||||
describe('electronDownloadStore', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
Object.values(downloadManagerMock).forEach((mock) => mock.mockReset())
|
||||
downloadManagerMock.getAllDownloads.mockResolvedValue([
|
||||
{
|
||||
filename: 'done.bin',
|
||||
status: DownloadStatus.COMPLETED,
|
||||
url: 'https://example.com/done.bin'
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('loads existing downloads and applies progress updates by URL', async () => {
|
||||
let progressCallback:
|
||||
| Parameters<typeof downloadManagerMock.onDownloadProgress>[0]
|
||||
| undefined
|
||||
downloadManagerMock.onDownloadProgress.mockImplementation((callback) => {
|
||||
progressCallback = callback
|
||||
})
|
||||
const store = useElectronDownloadStore()
|
||||
|
||||
await store.initialize()
|
||||
progressCallback?.({
|
||||
filename: 'model.bin',
|
||||
progress: 25,
|
||||
savePath: '/tmp/model.bin',
|
||||
status: DownloadStatus.IN_PROGRESS,
|
||||
url: 'https://example.com/model.bin'
|
||||
})
|
||||
progressCallback?.({
|
||||
filename: 'model.bin',
|
||||
progress: 50,
|
||||
savePath: '/tmp/model.bin',
|
||||
status: DownloadStatus.IN_PROGRESS,
|
||||
url: 'https://example.com/model.bin'
|
||||
})
|
||||
|
||||
expect(store.findByUrl('https://example.com/done.bin')?.status).toBe(
|
||||
DownloadStatus.COMPLETED
|
||||
)
|
||||
expect(store.findByUrl('https://example.com/model.bin')).toMatchObject({
|
||||
filename: 'model.bin',
|
||||
progress: 50,
|
||||
status: DownloadStatus.IN_PROGRESS
|
||||
})
|
||||
expect(store.inProgressDownloads).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('delegates download controls to the Electron bridge', async () => {
|
||||
const store = useElectronDownloadStore()
|
||||
|
||||
await store.start({
|
||||
filename: 'model.bin',
|
||||
savePath: '/tmp/model.bin',
|
||||
url: 'https://example.com/model.bin'
|
||||
})
|
||||
await store.pause('https://example.com/model.bin')
|
||||
await store.resume('https://example.com/model.bin')
|
||||
await store.cancel('https://example.com/model.bin')
|
||||
|
||||
expect(downloadManagerMock.startDownload).toHaveBeenCalledWith(
|
||||
'https://example.com/model.bin',
|
||||
'/tmp/model.bin',
|
||||
'model.bin'
|
||||
)
|
||||
expect(downloadManagerMock.pauseDownload).toHaveBeenCalledWith(
|
||||
'https://example.com/model.bin'
|
||||
)
|
||||
expect(downloadManagerMock.resumeDownload).toHaveBeenCalledWith(
|
||||
'https://example.com/model.bin'
|
||||
)
|
||||
expect(downloadManagerMock.cancelDownload).toHaveBeenCalledWith(
|
||||
'https://example.com/model.bin'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -6,7 +6,7 @@ import type { SystemStats } from '@/schemas/apiSchema'
|
||||
import { api } from '@/scripts/api'
|
||||
import { useSystemStatsStore } from '@/stores/systemStatsStore'
|
||||
|
||||
const mockData = vi.hoisted(() => ({ isDesktop: false }))
|
||||
const mockData = vi.hoisted(() => ({ isCloud: false, isDesktop: false }))
|
||||
|
||||
// Mock the API
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
@@ -19,7 +19,9 @@ vi.mock('@/platform/distribution/types', () => ({
|
||||
get isDesktop() {
|
||||
return mockData.isDesktop
|
||||
},
|
||||
isCloud: false
|
||||
get isCloud() {
|
||||
return mockData.isCloud
|
||||
}
|
||||
}))
|
||||
|
||||
describe('useSystemStatsStore', () => {
|
||||
@@ -138,6 +140,7 @@ describe('useSystemStatsStore', () => {
|
||||
describe('getFormFactor', () => {
|
||||
beforeEach(() => {
|
||||
// Reset systemStats for each test
|
||||
mockData.isCloud = false
|
||||
store.systemStats = null
|
||||
})
|
||||
|
||||
@@ -162,6 +165,12 @@ describe('useSystemStatsStore', () => {
|
||||
expect(store.getFormFactor()).toBe('other')
|
||||
})
|
||||
|
||||
it('should return "cloud" in cloud mode', () => {
|
||||
mockData.isCloud = true
|
||||
|
||||
expect(store.getFormFactor()).toBe('cloud')
|
||||
})
|
||||
|
||||
describe('desktop environment', () => {
|
||||
beforeEach(() => {
|
||||
mockData.isDesktop = true
|
||||
|
||||
@@ -90,6 +90,12 @@ describe('templateRankingStore', () => {
|
||||
})
|
||||
|
||||
describe('computePopularScore', () => {
|
||||
it('normalizes usage against itself before a largest score is loaded', () => {
|
||||
const store = useTemplateRankingStore()
|
||||
|
||||
expect(store.computePopularScore('2024-01-01', 10)).toBeGreaterThan(0.8)
|
||||
})
|
||||
|
||||
it('does not use searchRank', () => {
|
||||
const store = useTemplateRankingStore()
|
||||
store.largestUsageScore = 100
|
||||
|
||||
25
src/stores/topbarBadgeStore.test.ts
Normal file
25
src/stores/topbarBadgeStore.test.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { useExtensionStore } from '@/stores/extensionStore'
|
||||
import { useTopbarBadgeStore } from '@/stores/topbarBadgeStore'
|
||||
|
||||
describe('topbarBadgeStore', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
it('collects topbar badges from registered extensions', () => {
|
||||
const extensionStore = useExtensionStore()
|
||||
extensionStore.registerExtension({
|
||||
name: 'badges',
|
||||
topbarBadges: [{ text: 'Beta', label: 'BETA' }]
|
||||
})
|
||||
extensionStore.registerExtension({ name: 'plain' })
|
||||
|
||||
const store = useTopbarBadgeStore()
|
||||
|
||||
expect(store.badges).toEqual([{ text: 'Beta', label: 'BETA' }])
|
||||
})
|
||||
})
|
||||
@@ -116,6 +116,33 @@ describe('useUserFileStore', () => {
|
||||
"Failed to load file 'file1.txt': 404 Not Found"
|
||||
)
|
||||
})
|
||||
|
||||
it('should skip loading temporary and already loaded files', async () => {
|
||||
const temporaryFile = UserFile.createTemporary('draft.txt')
|
||||
const loadedFile = new UserFile('file1.txt', 123, 100)
|
||||
loadedFile.content = 'content'
|
||||
loadedFile.originalContent = 'content'
|
||||
|
||||
await temporaryFile.load()
|
||||
await loadedFile.load()
|
||||
|
||||
expect(api.getUserData).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should force reload loaded files', async () => {
|
||||
const file = new UserFile('file1.txt', 123, 100)
|
||||
file.content = 'old'
|
||||
file.originalContent = 'old'
|
||||
vi.mocked(api.getUserData).mockResolvedValue({
|
||||
status: 200,
|
||||
text: () => Promise.resolve('new')
|
||||
} as Response)
|
||||
|
||||
await file.load({ force: true })
|
||||
|
||||
expect(api.getUserData).toHaveBeenCalledWith('file1.txt')
|
||||
expect(file.content).toBe('new')
|
||||
})
|
||||
})
|
||||
|
||||
describe('save', () => {
|
||||
@@ -148,6 +175,60 @@ describe('useUserFileStore', () => {
|
||||
|
||||
expect(api.storeUserData).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should save unmodified files when forced', async () => {
|
||||
const file = new UserFile('file1.txt', 123, 100)
|
||||
file.content = 'content'
|
||||
file.originalContent = 'content'
|
||||
vi.mocked(api.storeUserData).mockResolvedValue({
|
||||
status: 200,
|
||||
json: () => Promise.resolve('file1.txt')
|
||||
} as Response)
|
||||
|
||||
await file.save({ force: true })
|
||||
|
||||
expect(api.storeUserData).toHaveBeenCalledWith('file1.txt', 'content', {
|
||||
throwOnError: true,
|
||||
full_info: true,
|
||||
overwrite: true
|
||||
})
|
||||
expect(file.lastModified).toBe(123)
|
||||
expect(file.size).toBe(100)
|
||||
})
|
||||
|
||||
it('should normalize string modified times', async () => {
|
||||
const file = new UserFile('file1.txt', 123, 100)
|
||||
file.content = 'modified content'
|
||||
file.originalContent = 'original content'
|
||||
vi.mocked(api.storeUserData).mockResolvedValue({
|
||||
status: 200,
|
||||
json: () =>
|
||||
Promise.resolve({ modified: '2024-01-02T03:04:05Z', size: 200 })
|
||||
} as Response)
|
||||
|
||||
await file.save()
|
||||
|
||||
expect(file.lastModified).toBe(
|
||||
new Date('2024-01-02T03:04:05Z').getTime()
|
||||
)
|
||||
expect(file.size).toBe(200)
|
||||
})
|
||||
|
||||
it('should fall back when modified time is invalid', async () => {
|
||||
const dateNow = vi.spyOn(Date, 'now').mockReturnValue(999)
|
||||
const file = new UserFile('file1.txt', 123, 100)
|
||||
file.content = 'modified content'
|
||||
file.originalContent = 'original content'
|
||||
vi.mocked(api.storeUserData).mockResolvedValue({
|
||||
status: 200,
|
||||
json: () => Promise.resolve({ modified: 'bad date', size: 200 })
|
||||
} as Response)
|
||||
|
||||
await file.save()
|
||||
|
||||
expect(file.lastModified).toBe(999)
|
||||
dateNow.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('delete', () => {
|
||||
@@ -161,6 +242,26 @@ describe('useUserFileStore', () => {
|
||||
|
||||
expect(api.deleteUserData).toHaveBeenCalledWith('file1.txt')
|
||||
})
|
||||
|
||||
it('should skip deleting temporary files', async () => {
|
||||
const file = UserFile.createTemporary('draft.txt')
|
||||
|
||||
await file.delete()
|
||||
|
||||
expect(api.deleteUserData).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should throw when delete fails', async () => {
|
||||
const file = new UserFile('file1.txt', 123, 100)
|
||||
vi.mocked(api.deleteUserData).mockResolvedValue({
|
||||
status: 500,
|
||||
statusText: 'Server Error'
|
||||
} as Response)
|
||||
|
||||
await expect(file.delete()).rejects.toThrow(
|
||||
"Failed to delete file 'file1.txt': 500 Server Error"
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('rename', () => {
|
||||
@@ -181,6 +282,41 @@ describe('useUserFileStore', () => {
|
||||
expect(file.lastModified).toBe(456)
|
||||
expect(file.size).toBe(200)
|
||||
})
|
||||
|
||||
it('should rename temporary files locally', async () => {
|
||||
const file = UserFile.createTemporary('draft.txt')
|
||||
|
||||
await file.rename('renamed.txt')
|
||||
|
||||
expect(api.moveUserData).not.toHaveBeenCalled()
|
||||
expect(file.path).toBe('renamed.txt')
|
||||
})
|
||||
|
||||
it('should throw when rename fails', async () => {
|
||||
const file = new UserFile('file1.txt', 123, 100)
|
||||
vi.mocked(api.moveUserData).mockResolvedValue({
|
||||
status: 409,
|
||||
statusText: 'Conflict'
|
||||
} as Response)
|
||||
|
||||
await expect(file.rename('newfile.txt')).rejects.toThrow(
|
||||
"Failed to rename file 'file1.txt': 409 Conflict"
|
||||
)
|
||||
})
|
||||
|
||||
it('should leave metadata unchanged when rename returns a string', async () => {
|
||||
const file = new UserFile('file1.txt', 123, 100)
|
||||
vi.mocked(api.moveUserData).mockResolvedValue({
|
||||
status: 200,
|
||||
json: () => Promise.resolve('newfile.txt')
|
||||
} as Response)
|
||||
|
||||
await file.rename('newfile.txt')
|
||||
|
||||
expect(file.path).toBe('newfile.txt')
|
||||
expect(file.lastModified).toBe(123)
|
||||
expect(file.size).toBe(100)
|
||||
})
|
||||
})
|
||||
|
||||
describe('saveAs', () => {
|
||||
@@ -207,6 +343,25 @@ describe('useUserFileStore', () => {
|
||||
expect(newFile.size).toBe(200)
|
||||
expect(newFile.content).toBe('file content')
|
||||
})
|
||||
|
||||
it('should save temporary files in place', async () => {
|
||||
const file = UserFile.createTemporary('draft.txt')
|
||||
file.content = 'file content'
|
||||
vi.mocked(api.storeUserData).mockResolvedValue({
|
||||
status: 200,
|
||||
json: () => Promise.resolve({ modified: 456, size: 200 })
|
||||
} as Response)
|
||||
|
||||
const newFile = await file.saveAs('newfile.txt')
|
||||
|
||||
expect(api.storeUserData).toHaveBeenCalledWith(
|
||||
'draft.txt',
|
||||
'file content',
|
||||
{ throwOnError: true, full_info: true, overwrite: false }
|
||||
)
|
||||
expect(newFile).toBe(file)
|
||||
expect(newFile.path).toBe('draft.txt')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,61 +1,72 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useUserStore } from './userStore'
|
||||
|
||||
const getUserConfig = vi.fn()
|
||||
const apiMock = vi.hoisted(() => ({
|
||||
createUser: vi.fn(),
|
||||
getUserConfig: vi.fn(),
|
||||
user: undefined as string | undefined
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
getUserConfig: (...args: unknown[]) => getUserConfig(...args)
|
||||
}
|
||||
api: apiMock
|
||||
}))
|
||||
|
||||
describe('userStore', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
getUserConfig.mockReset()
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
apiMock.createUser.mockReset()
|
||||
apiMock.getUserConfig.mockReset()
|
||||
apiMock.user = undefined
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
describe('initialize', () => {
|
||||
it('returns an empty user list before initialization', () => {
|
||||
const store = useUserStore()
|
||||
|
||||
expect(store.users).toEqual([])
|
||||
})
|
||||
|
||||
it('fetches user config on first call', async () => {
|
||||
getUserConfig.mockResolvedValue({})
|
||||
apiMock.getUserConfig.mockResolvedValue({})
|
||||
const store = useUserStore()
|
||||
|
||||
await store.initialize()
|
||||
|
||||
expect(getUserConfig).toHaveBeenCalledTimes(1)
|
||||
expect(apiMock.getUserConfig).toHaveBeenCalledTimes(1)
|
||||
expect(store.initialized).toBe(true)
|
||||
})
|
||||
|
||||
it('is a no-op once already initialized', async () => {
|
||||
getUserConfig.mockResolvedValue({})
|
||||
apiMock.getUserConfig.mockResolvedValue({})
|
||||
const store = useUserStore()
|
||||
await store.initialize()
|
||||
getUserConfig.mockClear()
|
||||
apiMock.getUserConfig.mockClear()
|
||||
|
||||
await store.initialize()
|
||||
|
||||
expect(getUserConfig).not.toHaveBeenCalled()
|
||||
expect(apiMock.getUserConfig).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('retries on a subsequent call when the first fetch failed', async () => {
|
||||
getUserConfig.mockRejectedValueOnce(new Error('network down'))
|
||||
getUserConfig.mockResolvedValueOnce({})
|
||||
apiMock.getUserConfig.mockRejectedValueOnce(new Error('network down'))
|
||||
apiMock.getUserConfig.mockResolvedValueOnce({})
|
||||
const store = useUserStore()
|
||||
|
||||
await expect(store.initialize()).rejects.toThrow('network down')
|
||||
expect(store.initialized).toBe(false)
|
||||
await expect(store.initialize()).resolves.toBeUndefined()
|
||||
|
||||
expect(getUserConfig).toHaveBeenCalledTimes(2)
|
||||
expect(apiMock.getUserConfig).toHaveBeenCalledTimes(2)
|
||||
expect(store.initialized).toBe(true)
|
||||
})
|
||||
|
||||
it('deduplicates concurrent calls before the first fetch resolves', async () => {
|
||||
let resolveConfig: (value: unknown) => void = () => {}
|
||||
getUserConfig.mockImplementation(
|
||||
apiMock.getUserConfig.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveConfig = resolve
|
||||
@@ -68,7 +79,100 @@ describe('userStore', () => {
|
||||
resolveConfig({})
|
||||
await Promise.all([a, b])
|
||||
|
||||
expect(getUserConfig).toHaveBeenCalledTimes(1)
|
||||
expect(apiMock.getUserConfig).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('derives multi-user state and restores the current user from storage', async () => {
|
||||
localStorage['Comfy.userId'] = 'user-2'
|
||||
apiMock.getUserConfig.mockResolvedValue({
|
||||
users: { 'user-1': 'Ada', 'user-2': 'Grace' }
|
||||
})
|
||||
const store = useUserStore()
|
||||
|
||||
await store.initialize()
|
||||
|
||||
expect(store.isMultiUserServer).toBe(true)
|
||||
expect(store.needsLogin).toBe(false)
|
||||
expect(store.users).toEqual([
|
||||
{ userId: 'user-1', username: 'Ada' },
|
||||
{ userId: 'user-2', username: 'Grace' }
|
||||
])
|
||||
expect(store.currentUser).toEqual({ userId: 'user-2', username: 'Grace' })
|
||||
await vi.waitFor(() => expect(apiMock.user).toBe('user-2'))
|
||||
})
|
||||
|
||||
it('requires login on multi-user servers without a stored user', async () => {
|
||||
apiMock.getUserConfig.mockResolvedValue({
|
||||
users: { 'user-1': 'Ada' }
|
||||
})
|
||||
const store = useUserStore()
|
||||
|
||||
await store.initialize()
|
||||
|
||||
expect(store.needsLogin).toBe(true)
|
||||
expect(store.currentUser).toBeNull()
|
||||
expect(apiMock.user).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('createUser', () => {
|
||||
it('returns the created user id with the requested username', async () => {
|
||||
apiMock.createUser.mockResolvedValue({
|
||||
json: () => Promise.resolve('user-1'),
|
||||
status: 201
|
||||
})
|
||||
const store = useUserStore()
|
||||
|
||||
await expect(store.createUser('Ada')).resolves.toEqual({
|
||||
userId: 'user-1',
|
||||
username: 'Ada'
|
||||
})
|
||||
})
|
||||
|
||||
it('throws API errors returned by user creation', async () => {
|
||||
apiMock.createUser.mockResolvedValue({
|
||||
json: () => Promise.resolve({ error: 'name taken' }),
|
||||
status: 409,
|
||||
statusText: 'Conflict'
|
||||
})
|
||||
const store = useUserStore()
|
||||
|
||||
await expect(store.createUser('Ada')).rejects.toThrow('name taken')
|
||||
})
|
||||
|
||||
it('throws a fallback error when user creation has no error body', async () => {
|
||||
apiMock.createUser.mockResolvedValue({
|
||||
json: () => Promise.resolve({}),
|
||||
status: 500,
|
||||
statusText: 'Server Error'
|
||||
})
|
||||
const store = useUserStore()
|
||||
|
||||
await expect(store.createUser('Ada')).rejects.toThrow(
|
||||
'Error creating user: 500 Server Error'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('login/logout', () => {
|
||||
it('persists login identity and clears it on logout', async () => {
|
||||
const store = useUserStore()
|
||||
|
||||
await store.login({ userId: 'user-1', username: 'Ada' })
|
||||
expect(localStorage['Comfy.userId']).toBe('user-1')
|
||||
expect(localStorage['Comfy.userName']).toBe('Ada')
|
||||
|
||||
await store.logout()
|
||||
expect(localStorage['Comfy.userId']).toBeUndefined()
|
||||
expect(localStorage['Comfy.userName']).toBeUndefined()
|
||||
})
|
||||
|
||||
it('does not set api.user when login happens before user config loads', async () => {
|
||||
const store = useUserStore()
|
||||
|
||||
await store.login({ userId: 'user-1', username: 'Ada' })
|
||||
|
||||
expect(apiMock.user).toBeUndefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
258
src/stores/workspace/favoritedWidgetsStore.test.ts
Normal file
258
src/stores/workspace/favoritedWidgetsStore.test.ts
Normal file
@@ -0,0 +1,258 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { useFavoritedWidgetsStore } from '@/stores/workspace/favoritedWidgetsStore'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
|
||||
const { mockState } = vi.hoisted(() => ({
|
||||
mockState: {
|
||||
graph: null as { extra: Record<string, unknown> } | null,
|
||||
nodes: {} as Record<string, unknown>,
|
||||
setDirty: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
get rootGraph() {
|
||||
return mockState.graph
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
|
||||
useWorkflowStore: () => ({
|
||||
activeWorkflow: undefined,
|
||||
nodeToNodeLocatorId: (node: { id: unknown }) => String(node.id),
|
||||
nodeIdToNodeLocatorId: (id: unknown) => String(id)
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => ({ canvas: { setDirty: mockState.setDirty } })
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/graphTraversalUtil', () => ({
|
||||
getNodeByLocatorId: (_graph: unknown, id: string) =>
|
||||
mockState.nodes[id] ?? null
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/nodeTitleUtil', () => ({
|
||||
resolveNodeDisplayName: (node: { title?: string }) => node.title ?? 'Node'
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n', () => ({
|
||||
st: (_key: string, fallback: string) => fallback
|
||||
}))
|
||||
|
||||
interface FakeWidget {
|
||||
name: string
|
||||
label?: string
|
||||
}
|
||||
|
||||
function makeWidget({ name, label }: FakeWidget): IBaseWidget {
|
||||
return {
|
||||
name,
|
||||
label,
|
||||
options: {},
|
||||
type: 'number',
|
||||
y: 0
|
||||
} as IBaseWidget
|
||||
}
|
||||
|
||||
function makeNode(id: number, widgets: FakeWidget[] = [], title = 'My Node') {
|
||||
const node = new LGraphNode(title)
|
||||
node.id = toNodeId(id)
|
||||
node.title = title
|
||||
node.widgets = widgets.map(makeWidget)
|
||||
return node
|
||||
}
|
||||
|
||||
function registerNode(node: { id: unknown }) {
|
||||
mockState.nodes[String(node.id)] = node
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
mockState.graph = { extra: {} }
|
||||
mockState.nodes = {}
|
||||
mockState.setDirty = vi.fn()
|
||||
})
|
||||
|
||||
describe('favoritedWidgetsStore', () => {
|
||||
it('adds a favorite, marks workflow dirty, and persists to graph.extra', () => {
|
||||
const store = useFavoritedWidgetsStore()
|
||||
const node = makeNode(1, [{ name: 'seed' }])
|
||||
registerNode(node)
|
||||
|
||||
store.addFavorite(node, 'seed')
|
||||
|
||||
expect(store.isFavorited(node, 'seed')).toBe(true)
|
||||
expect(mockState.setDirty).toHaveBeenCalledWith(true, true)
|
||||
expect(mockState.graph?.extra.favoritedWidgets).toEqual({
|
||||
favorites: [{ nodeLocatorId: '1', widgetName: 'seed' }]
|
||||
})
|
||||
})
|
||||
|
||||
it('does not add the same favorite twice', () => {
|
||||
const store = useFavoritedWidgetsStore()
|
||||
const node = makeNode(1, [{ name: 'seed' }])
|
||||
registerNode(node)
|
||||
|
||||
store.addFavorite(node, 'seed')
|
||||
const persisted = structuredClone(mockState.graph?.extra.favoritedWidgets)
|
||||
const dirtyCalls = mockState.setDirty.mock.calls.length
|
||||
|
||||
store.addFavorite(node, 'seed')
|
||||
|
||||
expect(store.favoritedWidgets).toHaveLength(1)
|
||||
expect(mockState.graph?.extra.favoritedWidgets).toEqual(persisted)
|
||||
expect(mockState.setDirty).toHaveBeenCalledTimes(dirtyCalls)
|
||||
})
|
||||
|
||||
it('removes a favorite and treats removing an absent one as a no-op', () => {
|
||||
const store = useFavoritedWidgetsStore()
|
||||
const node = makeNode(1, [{ name: 'seed' }])
|
||||
registerNode(node)
|
||||
store.addFavorite(node, 'seed')
|
||||
const persisted = structuredClone(mockState.graph?.extra.favoritedWidgets)
|
||||
const dirtyCalls = mockState.setDirty.mock.calls.length
|
||||
|
||||
store.removeFavorite(node, 'missing')
|
||||
expect(store.isFavorited(node, 'seed')).toBe(true)
|
||||
expect(mockState.graph?.extra.favoritedWidgets).toEqual(persisted)
|
||||
expect(mockState.setDirty).toHaveBeenCalledTimes(dirtyCalls)
|
||||
|
||||
store.removeFavorite(node, 'seed')
|
||||
expect(store.isFavorited(node, 'seed')).toBe(false)
|
||||
})
|
||||
|
||||
it('toggles favorite state in both directions', () => {
|
||||
const store = useFavoritedWidgetsStore()
|
||||
const node = makeNode(1, [{ name: 'seed' }])
|
||||
registerNode(node)
|
||||
|
||||
store.toggleFavorite(node, 'seed')
|
||||
expect(store.isFavorited(node, 'seed')).toBe(true)
|
||||
|
||||
store.toggleFavorite(node, 'seed')
|
||||
expect(store.isFavorited(node, 'seed')).toBe(false)
|
||||
})
|
||||
|
||||
it('resolves a valid favorite to a node/widget with a composed label', () => {
|
||||
const store = useFavoritedWidgetsStore()
|
||||
const node = makeNode(7, [{ name: 'cfg', label: 'CFG Scale' }], 'KSampler')
|
||||
registerNode(node)
|
||||
|
||||
store.addFavorite(node, 'cfg')
|
||||
|
||||
const [resolved] = store.favoritedWidgets
|
||||
expect(resolved.label).toBe('KSampler / CFG Scale')
|
||||
expect(store.validFavoritedWidgets).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('labels favorites whose node was deleted and excludes them from valid', () => {
|
||||
const store = useFavoritedWidgetsStore()
|
||||
const node = makeNode(2, [{ name: 'seed' }])
|
||||
registerNode(node)
|
||||
store.addFavorite(node, 'seed')
|
||||
|
||||
delete mockState.nodes['2']
|
||||
|
||||
expect(store.favoritedWidgets[0].label).toContain('(node deleted)')
|
||||
expect(store.validFavoritedWidgets).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('labels favorites whose widget no longer exists', () => {
|
||||
const store = useFavoritedWidgetsStore()
|
||||
const node = makeNode(3, [{ name: 'seed' }])
|
||||
registerNode(node)
|
||||
store.addFavorite(node, 'seed')
|
||||
|
||||
mockState.nodes['3'] = makeNode(3, [], 'My Node')
|
||||
|
||||
expect(store.favoritedWidgets[0].label).toContain('(widget not found)')
|
||||
})
|
||||
|
||||
it('prunes invalid favorites while keeping valid ones', () => {
|
||||
const store = useFavoritedWidgetsStore()
|
||||
const valid = makeNode(1, [{ name: 'seed' }])
|
||||
const stale = makeNode(2, [{ name: 'steps' }])
|
||||
registerNode(valid)
|
||||
registerNode(stale)
|
||||
store.addFavorite(valid, 'seed')
|
||||
store.addFavorite(stale, 'steps')
|
||||
|
||||
delete mockState.nodes['2']
|
||||
store.pruneInvalidFavorites()
|
||||
|
||||
expect(store.favoritedWidgets).toHaveLength(1)
|
||||
expect(store.isFavorited(valid, 'seed')).toBe(true)
|
||||
})
|
||||
|
||||
it('reorders favorites to match the provided order', () => {
|
||||
const store = useFavoritedWidgetsStore()
|
||||
const a = makeNode(1, [{ name: 'seed' }])
|
||||
const b = makeNode(2, [{ name: 'steps' }])
|
||||
registerNode(a)
|
||||
registerNode(b)
|
||||
store.addFavorite(a, 'seed')
|
||||
store.addFavorite(b, 'steps')
|
||||
|
||||
store.reorderFavorites([...store.validFavoritedWidgets].reverse())
|
||||
|
||||
expect(store.favoritedWidgets.map((fw) => fw.nodeLocatorId)).toEqual([
|
||||
'2',
|
||||
'1'
|
||||
])
|
||||
})
|
||||
|
||||
it('clears all favorites', () => {
|
||||
const store = useFavoritedWidgetsStore()
|
||||
const node = makeNode(1, [{ name: 'seed' }])
|
||||
registerNode(node)
|
||||
store.addFavorite(node, 'seed')
|
||||
|
||||
store.clearFavorites()
|
||||
|
||||
expect(store.favoritedWidgets).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('loads favorites from graph.extra on init, normalizing legacy nodeId entries', () => {
|
||||
mockState.graph = {
|
||||
extra: {
|
||||
favoritedWidgets: {
|
||||
favorites: [
|
||||
{ nodeLocatorId: '1', widgetName: 'seed' },
|
||||
{ nodeId: 2, widgetName: 'steps' },
|
||||
{ widgetName: 'no-node' }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
registerNode(makeNode(1, [{ name: 'seed' }]))
|
||||
registerNode(makeNode(2, [{ name: 'steps' }]))
|
||||
|
||||
const store = useFavoritedWidgetsStore()
|
||||
|
||||
expect(store.favoritedWidgets.map((fw) => fw.nodeLocatorId)).toEqual([
|
||||
'1',
|
||||
'2'
|
||||
])
|
||||
})
|
||||
|
||||
it('labels existing favorites when the graph is not loaded', () => {
|
||||
const node = makeNode(1, [{ name: 'seed' }])
|
||||
registerNode(node)
|
||||
const store = useFavoritedWidgetsStore()
|
||||
store.addFavorite(node, 'seed')
|
||||
|
||||
mockState.graph = null
|
||||
|
||||
expect(store.favoritedWidgets[0].label).toContain('(graph not loaded)')
|
||||
store.clearFavorites()
|
||||
expect(store.favoritedWidgets).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
115
src/stores/workspaceStore.test.ts
Normal file
115
src/stores/workspaceStore.test.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
|
||||
const storeMocks = vi.hoisted(() => ({
|
||||
apiKeyAuthStore: {
|
||||
isAuthenticated: false
|
||||
},
|
||||
authStore: {
|
||||
currentUser: null as null | { uid: string }
|
||||
},
|
||||
commandStore: {
|
||||
commands: [],
|
||||
execute: vi.fn()
|
||||
},
|
||||
executionErrorStore: {
|
||||
lastExecutionError: null,
|
||||
lastNodeErrors: null
|
||||
},
|
||||
queueSettingsStore: {},
|
||||
settingStore: {
|
||||
settingsById: {},
|
||||
get: vi.fn(),
|
||||
set: vi.fn()
|
||||
},
|
||||
sidebarTabStore: {
|
||||
registerSidebarTab: vi.fn(),
|
||||
unregisterSidebarTab: vi.fn(),
|
||||
sidebarTabs: []
|
||||
},
|
||||
toastStore: {},
|
||||
workflowStore: {}
|
||||
}))
|
||||
|
||||
vi.mock('@vueuse/core', () => ({
|
||||
useMagicKeys: () => ({ shift: false })
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => storeMocks.settingStore
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/updates/common/toastStore', () => ({
|
||||
useToastStore: () => storeMocks.toastStore
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
|
||||
useWorkflowStore: () => storeMocks.workflowStore
|
||||
}))
|
||||
|
||||
vi.mock('@/services/colorPaletteService', () => ({
|
||||
useColorPaletteService: () => ({})
|
||||
}))
|
||||
|
||||
vi.mock('@/services/dialogService', () => ({
|
||||
useDialogService: () => ({})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/apiKeyAuthStore', () => ({
|
||||
useApiKeyAuthStore: () => storeMocks.apiKeyAuthStore
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/authStore', () => ({
|
||||
useAuthStore: () => storeMocks.authStore
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/commandStore', () => ({
|
||||
useCommandStore: () => storeMocks.commandStore
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/executionErrorStore', () => ({
|
||||
useExecutionErrorStore: () => storeMocks.executionErrorStore
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/queueStore', () => ({
|
||||
useQueueSettingsStore: () => storeMocks.queueSettingsStore
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/workspace/bottomPanelStore', () => ({
|
||||
useBottomPanelStore: () => ({})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/workspace/sidebarTabStore', () => ({
|
||||
useSidebarTabStore: () => storeMocks.sidebarTabStore
|
||||
}))
|
||||
|
||||
describe('useWorkspaceStore', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
storeMocks.apiKeyAuthStore.isAuthenticated = false
|
||||
storeMocks.authStore.currentUser = null
|
||||
})
|
||||
|
||||
it('reports logged out when neither auth source is active', () => {
|
||||
const store = useWorkspaceStore()
|
||||
|
||||
expect(store.user.isLoggedIn).toBe(false)
|
||||
})
|
||||
|
||||
it('reports logged in for API-key auth', () => {
|
||||
storeMocks.apiKeyAuthStore.isAuthenticated = true
|
||||
const store = useWorkspaceStore()
|
||||
|
||||
expect(store.user.isLoggedIn).toBe(true)
|
||||
})
|
||||
|
||||
it('reports logged in for Firebase auth', () => {
|
||||
storeMocks.authStore.currentUser = { uid: 'user-1' }
|
||||
const store = useWorkspaceStore()
|
||||
|
||||
expect(store.user.isLoggedIn).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -9,6 +9,7 @@ import { computed, useTemplateRef } from 'vue'
|
||||
import AppBuilder from '@/components/builder/AppBuilder.vue'
|
||||
import AppModeToolbar from '@/components/appMode/AppModeToolbar.vue'
|
||||
import ExtensionSlot from '@/components/common/ExtensionSlot.vue'
|
||||
import ErrorOverlay from '@/components/error/ErrorOverlay.vue'
|
||||
import TopbarBadges from '@/components/topbar/TopbarBadges.vue'
|
||||
import TopbarSubscribeButton from '@/components/topbar/TopbarSubscribeButton.vue'
|
||||
import WorkflowTabs from '@/components/topbar/WorkflowTabs.vue'
|
||||
@@ -110,7 +111,7 @@ function dragDrop(e: DragEvent) {
|
||||
</div>
|
||||
<Splitter
|
||||
:key="splitterKey"
|
||||
class="h-[calc(100%-var(--workflow-tabs-height))] w-full border-none bg-base-background"
|
||||
class="bg-comfy-menu-secondary-bg h-[calc(100%-var(--workflow-tabs-height))] w-full border-none"
|
||||
@resizestart="$event.originalEvent.preventDefault()"
|
||||
@resizeend="onResizeEnd"
|
||||
>
|
||||
@@ -145,9 +146,10 @@ function dragDrop(e: DragEvent) {
|
||||
/>
|
||||
</SplitterPanel>
|
||||
<SplitterPanel
|
||||
id="linearCenterPanel"
|
||||
data-testid="linear-center-panel"
|
||||
:size="CENTER_PANEL_SIZE"
|
||||
class="relative flex min-w-[20vw] flex-col gap-4 bg-interface-canvas-background text-muted-foreground outline-none"
|
||||
class="relative flex min-w-[20vw] flex-col gap-4 text-muted-foreground outline-none"
|
||||
@drop="dragDrop"
|
||||
>
|
||||
<LinearProgressBar
|
||||
@@ -163,6 +165,7 @@ function dragDrop(e: DragEvent) {
|
||||
</div>
|
||||
<div ref="bottomLeftRef" class="absolute bottom-7 left-4 z-20" />
|
||||
<div ref="bottomRightRef" class="absolute right-4 bottom-7 z-20" />
|
||||
<div class="absolute top-4 right-4 z-20"><ErrorOverlay app-mode /></div>
|
||||
</SplitterPanel>
|
||||
<SplitterPanel
|
||||
v-if="hasRightPanel"
|
||||
|
||||
Reference in New Issue
Block a user