chore: add web workflow regression tests (#33016)

This commit is contained in:
Jason Rasmussen authored and Codex committed 2026-11-06 21:35:07 +03:00
1 parent 7459863a87
commit 4fdc9b69fa
4 files changed
+158 -1

No files matched your search

@@ -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();
});
});
+49
View File
@@ -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 | T[];
const toList = <T>(items: MaybeArray<T>) => (Array.isArray(items) ? items : [items]);
const toWorkflowSteps = (steps: WorkflowStepDto[]) =>
steps.map(({ method, config, enabled }) => ({ method, config, enabled }));
const hiddenVisibilities = new Set<AssetVisibility>([AssetVisibility.Hidden, AssetVisibility.Locked]);
export class MockData {
@@ -89,6 +97,8 @@ export class MockData {
private readonly sharedLinks = new Map<string, SharedLinkResponseDto>();
private readonly tags = new Map<string, TagResponseDto>();
private readonly activities = new Map<string, ActivityResponseDto>();
private readonly workflows = new Map<string, WorkflowResponseDto>();
private readonly pluginMethods = new Map<string, PluginMethodResponseDto>();
getPreferences() {
return this.preferences;
@@ -403,6 +413,45 @@ export class MockData {
return this.activities.delete(id);
}
addWorkflows(workflows: MaybeArray<WorkflowResponseDto>) {
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<PluginMethodResponseDto>) {
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;
+37
View File
@@ -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> = {}): TagResponseDto => {
};
};
const workflow = (overrides: Partial<WorkflowResponseDto> = {}): 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> = {}): 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> = {}): SharedLinkResponseDto => ({
id: crypto.randomUUID(),
key: alphanumeric(64),
@@ -518,6 +553,8 @@ export const factory = {
ocr,
tag,
sharedLink,
workflow,
pluginMethod,
memories,
timeline,
many,
+13 -1
View File
@@ -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),