From 4fdc9b69fa697ad7df0a20717dadb110c91b09dc Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Thu, 5 Nov 2026 16:54:28 -0500 Subject: [PATCH] chore: add web workflow regression tests (#33016) --- .../workflows/[workflowId]/page.spec.ts | 59 +++++++++++++++++++ web/test/common/data.ts | 49 +++++++++++++++ web/test/common/factories.ts | 37 ++++++++++++ web/test/common/handlers.ts | 14 ++++- 4 files changed, 158 insertions(+), 1 deletion(-) create mode 100644 web/src/routes/(user)/workflows/[workflowId]/page.spec.ts diff --git a/web/src/routes/(user)/workflows/[workflowId]/page.spec.ts b/web/src/routes/(user)/workflows/[workflowId]/page.spec.ts new file mode 100644 index 000000000..3548a7c3f --- /dev/null +++ b/web/src/routes/(user)/workflows/[workflowId]/page.spec.ts @@ -0,0 +1,59 @@ +import { Route } from '#lib/route.ts'; +import type { MockData } from '#test/common/data.ts'; +import { factory } from '#test/unit/factories.ts'; +import { test } from '#test/unit/fixtures.ts'; +import type { MockRouter } from '#test/unit/mocks/router.ts'; +import { describe, expect } from 'vitest'; +import { page } from 'vitest/browser'; +import Page from './+page.svelte'; +import { load } from './+page.ts'; + +const favoriteStep = { method: 'immich#favorite', config: null, enabled: true }; + +const setup = async (data: MockData, router: MockRouter) => { + data.addPluginMethods(factory.pluginMethod({ key: favoriteStep.method, title: 'Mark as favorite' })); + const workflow = factory.workflow({ name: 'Favorites', steps: [favoriteStep] }); + data.addWorkflows(workflow); + await router.goto(Route.viewWorkflow(workflow), { workflowId: workflow.id }); + return workflow; +}; + +describe('workflow page', () => { + test('json view leaves out local step ids', async ({ data, router, render }) => { + await setup(data, router); + await render(Page, load); + + await page.getByRole('button', { name: 'JSON', exact: true }).click(); + + await expect.element(page.getByText(/immich#favorite/).first()).toBeVisible(); + await expect.element(page.getByText(/step-\d/)).not.toBeInTheDocument(); + }); + + test('duplicating sends the steps without local ids', async ({ data, router, render, settle, api }) => { + await setup(data, router); + await render(Page, load); + + await page.getByRole('button', { name: 'duplicate', exact: true }).click(); + await page.getByRole('button', { name: 'create', exact: true }).click(); + await settle(); + + expect(api.createWorkflow).toHaveBeenCalledWith({ + body: expect.objectContaining({ steps: [favoriteStep] }), + }); + }); + + test('saving a change hides the save button', async ({ data, router, render, settle, api }) => { + const workflow = await setup(data, router); + await render(Page, load); + + await page.getByPlaceholder('workflow_name').fill('Renamed'); + await page.getByRole('button', { name: 'save', exact: true }).click(); + await settle(); + + expect(api.updateWorkflow).toHaveBeenCalledWith({ + params: { id: workflow.id }, + body: expect.objectContaining({ name: 'Renamed' }), + }); + await expect.element(page.getByRole('button', { name: 'save', exact: true })).not.toBeInTheDocument(); + }); +}); diff --git a/web/test/common/data.ts b/web/test/common/data.ts index 4449a2181..2a0e951db 100644 --- a/web/test/common/data.ts +++ b/web/test/common/data.ts @@ -21,6 +21,7 @@ import { type MemoryUpdateDto, type MetadataSearchDto, type PersonResponseDto, + type PluginMethodResponseDto, type SearchResponseDto, type SharedLinkResponseDto, type StackResponseDto, @@ -33,6 +34,10 @@ import { type UserAdminResponseDto, type UserAdminUpdateDto, type UserPreferencesResponseDto, + type WorkflowCreateDto, + type WorkflowResponseDto, + type WorkflowStepDto, + type WorkflowUpdateDto, } from '@immich/sdk'; export type TimelineFilter = { @@ -57,6 +62,9 @@ const monthKey = (iso: string) => { type MaybeArray = T | T[]; const toList = (items: MaybeArray) => (Array.isArray(items) ? items : [items]); +const toWorkflowSteps = (steps: WorkflowStepDto[]) => + steps.map(({ method, config, enabled }) => ({ method, config, enabled })); + const hiddenVisibilities = new Set([AssetVisibility.Hidden, AssetVisibility.Locked]); export class MockData { @@ -89,6 +97,8 @@ export class MockData { private readonly sharedLinks = new Map(); private readonly tags = new Map(); private readonly activities = new Map(); + private readonly workflows = new Map(); + private readonly pluginMethods = new Map(); getPreferences() { return this.preferences; @@ -403,6 +413,45 @@ export class MockData { return this.activities.delete(id); } + addWorkflows(workflows: MaybeArray) { + for (const workflow of toList(workflows)) { + this.workflows.set(workflow.id, workflow); + } + return this; + } + + workflowList() { + return [...this.workflows.values()]; + } + + createWorkflow(dto: WorkflowCreateDto) { + const workflow = factory.workflow({ ...dto, steps: toWorkflowSteps(dto.steps ?? []) }); + this.workflows.set(workflow.id, workflow); + return workflow; + } + + updateWorkflow(id: string, dto: WorkflowUpdateDto) { + const workflow = this.workflows.get(id); + if (!workflow) { + return; + } + const steps = dto.steps ? toWorkflowSteps(dto.steps) : workflow.steps; + const updated = { ...workflow, ...dto, steps, updatedAt: new Date().toISOString() }; + this.workflows.set(id, updated); + return updated; + } + + addPluginMethods(methods: MaybeArray) { + for (const method of toList(methods)) { + this.pluginMethods.set(method.key, method); + } + return this; + } + + pluginMethodList() { + return [...this.pluginMethods.values()]; + } + updateAdminConfig(config: AdminConfigDto) { this.adminConfig = config; return config; diff --git a/web/test/common/factories.ts b/web/test/common/factories.ts index dfbc981df..32c08d45b 100644 --- a/web/test/common/factories.ts +++ b/web/test/common/factories.ts @@ -10,17 +10,21 @@ import { SharedLinkType, UserAvatarColor, UserStatus, + WorkflowTrigger, + WorkflowType, type ActivityResponseDto, type AlbumResponseDto, type AssetOcrResponseDto, type AssetResponseDto, type MemoryResponseDto, type PersonResponseDto, + type PluginMethodResponseDto, type SharedLinkResponseDto, type StackResponseDto, type TagResponseDto, type UserAdminResponseDto, type UserPreferencesResponseDto, + type WorkflowResponseDto, } from '@immich/sdk'; import { DateTime } from 'luxon'; @@ -29,6 +33,7 @@ export const adminUserId = '0d9a7a2e-0000-4000-8000-00000000ad01'; let assetIndex = 0; let personIndex = 0; let tagIndex = 0; +let pluginMethodIndex = 0; const random = new SeededRandom(7); const names = ['Alice Johnson', 'Bob Smith', 'Charlie Brown', 'Diana Prince', 'Eve Adams', 'Frank Castle']; @@ -270,6 +275,36 @@ const tag = (overrides: Partial = {}): TagResponseDto => { }; }; +const workflow = (overrides: Partial = {}): WorkflowResponseDto => { + const now = new Date().toISOString(); + return { + id: crypto.randomUUID(), + name: 'Workflow', + description: null, + enabled: true, + logging: false, + trigger: WorkflowTrigger.AssetCreate, + steps: [], + createdAt: now, + updatedAt: now, + ...overrides, + }; +}; + +const pluginMethod = (overrides: Partial = {}): PluginMethodResponseDto => { + const key = overrides.key ?? `plugin#method-${pluginMethodIndex++}`; + return { + key, + name: key, + title: key, + description: '', + hostFunctions: false, + types: [WorkflowType.AssetV1], + uiHints: [], + ...overrides, + }; +}; + const sharedLink = (overrides: Partial = {}): SharedLinkResponseDto => ({ id: crypto.randomUUID(), key: alphanumeric(64), @@ -518,6 +553,8 @@ export const factory = { ocr, tag, sharedLink, + workflow, + pluginMethod, memories, timeline, many, diff --git a/web/test/common/handlers.ts b/web/test/common/handlers.ts index 8894d12fd..a9d524bae 100644 --- a/web/test/common/handlers.ts +++ b/web/test/common/handlers.ts @@ -2,7 +2,7 @@ import type { MockData } from '#test/common/data.ts'; import { factory } from '#test/common/factories.ts'; import { fromBase64, minimalMp4, placeholderImage } from '#test/common/helpers.ts'; import type { Handlers } from '#test/common/types.ts'; -import { AssetUploadAction } from '@immich/sdk'; +import { AssetUploadAction, WorkflowTrigger, WorkflowType } from '@immich/sdk'; const notFound = { status: 404, json: { message: 'Not found', statusCode: 404 } }; const passwordRequired = { status: 401, json: { message: 'Password required', statusCode: 401 } }; @@ -174,6 +174,18 @@ export const createHandlers = (data: MockData): Handlers => { headers: { 'Content-Disposition': 'attachment; filename="immich.zip"' }, }), + searchWorkflows: () => ({ json: data.workflowList() }), + getWorkflowTriggers: () => ({ + json: Object.values(WorkflowTrigger).map((trigger) => ({ trigger, types: [WorkflowType.AssetV1] })), + }), + createWorkflow: ({ body }) => ({ status: 201, json: data.createWorkflow(body) }), + updateWorkflow: ({ params, body }) => { + const workflow = data.updateWorkflow(params.id, body); + return workflow ? { json: workflow } : notFound; + }, + searchPluginMethods: () => ({ json: data.pluginMethodList() }), + searchPluginTemplates: () => ({ json: [] }), + playAssetVideo: () => ({ body: minimalMp4, contentType: 'video/mp4' }), viewAsset: ({ params }) => media(params.id), downloadAsset: ({ params }) => media(params.id),