@n8n/frontend-module-self-healing 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,48 @@
1
+ import type {
2
+ SaveSelfHealingConfigurationDto,
3
+ SelfHealingConfigurationDto,
4
+ SelfHealingConfigurationOptions,
5
+ UpdateSelfHealingConfigurationDto,
6
+ } from '@n8n/api-types';
7
+ import { makeRestApiRequest, type IRestApiContext } from '@n8n/rest-api-client';
8
+
9
+ const path = (projectId: string) => `/projects/${projectId}/self-healing-configurations`;
10
+
11
+ export const fetchConfigurations = async (
12
+ context: IRestApiContext,
13
+ projectId: string,
14
+ ): Promise<SelfHealingConfigurationDto[]> =>
15
+ await makeRestApiRequest(context, 'GET', path(projectId));
16
+
17
+ export const fetchConfigurationOptions = async (
18
+ context: IRestApiContext,
19
+ projectId: string,
20
+ ): Promise<SelfHealingConfigurationOptions> =>
21
+ await makeRestApiRequest(context, 'GET', `${path(projectId)}/options`);
22
+
23
+ export const saveConfiguration = async (
24
+ context: IRestApiContext,
25
+ projectId: string,
26
+ input: SaveSelfHealingConfigurationDto | UpdateSelfHealingConfigurationDto,
27
+ id?: number,
28
+ ): Promise<SelfHealingConfigurationDto> => {
29
+ if (id === undefined) {
30
+ return await makeRestApiRequest(context, 'POST', path(projectId), input);
31
+ }
32
+
33
+ return await makeRestApiRequest(context, 'PATCH', `${path(projectId)}/${id}`, input);
34
+ };
35
+
36
+ export const setConfigurationEnabled = async (
37
+ context: IRestApiContext,
38
+ projectId: string,
39
+ id: number,
40
+ enabled: boolean,
41
+ ): Promise<SelfHealingConfigurationDto> =>
42
+ await makeRestApiRequest(context, 'PATCH', `${path(projectId)}/${id}/enabled`, { enabled });
43
+
44
+ export const removeConfiguration = async (
45
+ context: IRestApiContext,
46
+ projectId: string,
47
+ id: number,
48
+ ): Promise<void> => await makeRestApiRequest(context, 'DELETE', `${path(projectId)}/${id}`);
@@ -0,0 +1,130 @@
1
+ import type { SelfHealingConfigurationDto } from '@n8n/api-types';
2
+
3
+ import * as api from './self-healing.api';
4
+ import { useSelfHealingStore } from './self-healing.store';
5
+
6
+ vi.mock('./self-healing.api');
7
+
8
+ const configuration: SelfHealingConfigurationDto = {
9
+ id: 1,
10
+ projectId: 'project',
11
+ scope: 'all',
12
+ enabled: false,
13
+ enabledById: null,
14
+ workflowIds: [],
15
+ reviewerIds: ['reviewer'],
16
+ lastActivity: null,
17
+ };
18
+
19
+ beforeEach(() => vi.resetAllMocks());
20
+
21
+ it('loads saved configurations and keeps project lists separate', async () => {
22
+ vi.mocked(api.fetchConfigurations).mockResolvedValue([configuration]);
23
+ vi.mocked(api.fetchConfigurationOptions).mockResolvedValue({ workflows: [], reviewers: [] });
24
+ const store = useSelfHealingStore();
25
+ await store.load('project');
26
+ expect(store.configurations.project).toEqual([configuration]);
27
+ expect(store.configurations.other).toBeUndefined();
28
+ });
29
+
30
+ it('changes enabled state only after the server accepts it', async () => {
31
+ const store = useSelfHealingStore();
32
+ store.configurations.project = [configuration];
33
+ vi.mocked(api.setConfigurationEnabled).mockRejectedValueOnce(new Error('No access'));
34
+ await expect(store.setEnabled('project', 1, true)).rejects.toThrow('No access');
35
+ expect(store.configurations.project[0].enabled).toBe(false);
36
+ vi.mocked(api.setConfigurationEnabled).mockResolvedValue({
37
+ ...configuration,
38
+ enabled: true,
39
+ enabledById: 'actor',
40
+ });
41
+ await store.setEnabled('project', 1, true);
42
+ expect(store.configurations.project[0]).toMatchObject({ enabled: true, enabledById: 'actor' });
43
+ });
44
+
45
+ it('removes a configuration from the list after the server removes it', async () => {
46
+ const store = useSelfHealingStore();
47
+ store.configurations.project = [configuration];
48
+ vi.mocked(api.removeConfiguration).mockResolvedValue();
49
+ await store.remove('project', 1);
50
+ expect(store.configurations.project).toEqual([]);
51
+ });
52
+
53
+ it.each(['save', 'setEnabled', 'remove'] as const)(
54
+ 'ignores an older list response after %s succeeds',
55
+ async (action) => {
56
+ const store = useSelfHealingStore();
57
+ store.configurations.project = [configuration];
58
+ const pending = Promise.withResolvers<SelfHealingConfigurationDto[]>();
59
+ vi.mocked(api.fetchConfigurations).mockReturnValue(pending.promise);
60
+ vi.mocked(api.fetchConfigurationOptions).mockResolvedValue({ workflows: [], reviewers: [] });
61
+ const load = store.load('project');
62
+ const updated = { ...configuration, enabled: true, enabledById: 'actor' };
63
+
64
+ if (action === 'save') {
65
+ vi.mocked(api.saveConfiguration).mockResolvedValue(updated);
66
+ await store.save('project', updated, configuration.id);
67
+ } else if (action === 'setEnabled') {
68
+ vi.mocked(api.setConfigurationEnabled).mockResolvedValue(updated);
69
+ await store.setEnabled('project', configuration.id, true);
70
+ } else {
71
+ vi.mocked(api.removeConfiguration).mockResolvedValue();
72
+ await store.remove('project', configuration.id);
73
+ }
74
+
75
+ pending.resolve([configuration]);
76
+ await load;
77
+
78
+ expect(store.configurations.project).toEqual(action === 'remove' ? [] : [updated]);
79
+ },
80
+ );
81
+
82
+ it('keeps the latest list when requests finish out of order', async () => {
83
+ const store = useSelfHealingStore();
84
+ const pending = Promise.withResolvers<SelfHealingConfigurationDto[]>();
85
+ const updated = { ...configuration, enabled: true, enabledById: 'actor' };
86
+ vi.mocked(api.fetchConfigurations)
87
+ .mockReturnValueOnce(pending.promise)
88
+ .mockResolvedValueOnce([updated]);
89
+ vi.mocked(api.fetchConfigurationOptions).mockResolvedValue({ workflows: [], reviewers: [] });
90
+
91
+ const firstLoad = store.load('project');
92
+ await store.load('project');
93
+ pending.resolve([configuration]);
94
+ await firstLoad;
95
+
96
+ expect(store.configurations.project).toEqual([updated]);
97
+ });
98
+
99
+ it.each(['save', 'setEnabled'] as const)(
100
+ 'keeps row order after %s updates an existing configuration',
101
+ async (action) => {
102
+ const store = useSelfHealingStore();
103
+ const second = { ...configuration, id: 2 };
104
+ const third = { ...configuration, id: 3 };
105
+ store.configurations.project = [configuration, second, third];
106
+ const updated = { ...configuration, enabled: true, enabledById: 'actor' };
107
+
108
+ if (action === 'save') {
109
+ vi.mocked(api.saveConfiguration).mockResolvedValue(updated);
110
+ await store.save('project', updated, configuration.id);
111
+ } else {
112
+ vi.mocked(api.setConfigurationEnabled).mockResolvedValue(updated);
113
+ await store.setEnabled('project', configuration.id, true);
114
+ }
115
+
116
+ expect(store.configurations.project).toEqual([updated, second, third]);
117
+ },
118
+ );
119
+
120
+ it('appends new configurations after existing rows', async () => {
121
+ const store = useSelfHealingStore();
122
+ const second = { ...configuration, id: 2 };
123
+ const created = { ...configuration, id: 3 };
124
+ store.configurations.project = [configuration, second];
125
+ vi.mocked(api.saveConfiguration).mockResolvedValue(created);
126
+
127
+ await store.save('project', created);
128
+
129
+ expect(store.configurations.project).toEqual([configuration, second, created]);
130
+ });
@@ -0,0 +1,75 @@
1
+ import type {
2
+ SaveSelfHealingConfigurationDto,
3
+ SelfHealingConfigurationDto,
4
+ SelfHealingConfigurationOptions,
5
+ UpdateSelfHealingConfigurationDto,
6
+ } from '@n8n/api-types';
7
+ import { useRootStore } from '@n8n/stores/useRootStore';
8
+ import { defineStore } from 'pinia';
9
+ import { ref } from 'vue';
10
+
11
+ import * as api from './self-healing.api';
12
+
13
+ export const useSelfHealingStore = defineStore('selfHealing', () => {
14
+ const root = useRootStore();
15
+ const configurations = ref<Record<string, SelfHealingConfigurationDto[]>>({});
16
+ const options = ref<Record<string, SelfHealingConfigurationOptions>>({});
17
+ // Only the latest pending load can replace a project's saved state.
18
+ const pendingLoads = new Map<string, symbol>();
19
+
20
+ async function load(projectId: string) {
21
+ const request = Symbol();
22
+ pendingLoads.set(projectId, request);
23
+
24
+ const [rows, choices] = await Promise.all([
25
+ api.fetchConfigurations(root.restApiContext, projectId),
26
+ api.fetchConfigurationOptions(root.restApiContext, projectId),
27
+ ]);
28
+
29
+ if (pendingLoads.get(projectId) !== request) return;
30
+ pendingLoads.delete(projectId);
31
+
32
+ configurations.value[projectId] = rows;
33
+ options.value[projectId] = choices;
34
+ }
35
+
36
+ function remember(projectId: string, configuration: SelfHealingConfigurationDto) {
37
+ pendingLoads.delete(projectId);
38
+ const rows = configurations.value[projectId] ?? [];
39
+ configurations.value[projectId] = rows.some(({ id }) => id === configuration.id)
40
+ ? rows.map((row) => (row.id === configuration.id ? configuration : row))
41
+ : [...rows, configuration];
42
+ }
43
+
44
+ async function save(
45
+ projectId: string,
46
+ input: SaveSelfHealingConfigurationDto | UpdateSelfHealingConfigurationDto,
47
+ id?: number,
48
+ ) {
49
+ const configuration = await api.saveConfiguration(root.restApiContext, projectId, input, id);
50
+ remember(projectId, configuration);
51
+
52
+ return configuration;
53
+ }
54
+
55
+ async function setEnabled(projectId: string, id: number, enabled: boolean) {
56
+ const configuration = await api.setConfigurationEnabled(
57
+ root.restApiContext,
58
+ projectId,
59
+ id,
60
+ enabled,
61
+ );
62
+ remember(projectId, configuration);
63
+ }
64
+
65
+ async function remove(projectId: string, id: number) {
66
+ await api.removeConfiguration(root.restApiContext, projectId, id);
67
+ pendingLoads.delete(projectId);
68
+
69
+ configurations.value[projectId] = (configurations.value[projectId] ?? []).filter(
70
+ (row) => row.id !== id,
71
+ );
72
+ }
73
+
74
+ return { configurations, options, load, save, setEnabled, remove };
75
+ });
@@ -0,0 +1,436 @@
1
+ import { render, fireEvent, waitFor, within } from '@testing-library/vue';
2
+ import type { SelfHealingConfigurationDto } from '@n8n/api-types';
3
+ import { waitAllPromises } from '@n8n/frontend-test-utils';
4
+ import { VIEWS } from '@n8n/frontend-constants/views';
5
+ import { createMemoryHistory, createRouter, RouterView, type Router } from 'vue-router';
6
+
7
+ import { useSelfHealingStore } from '../self-healing.store';
8
+ import SelfHealingConfigView from './SelfHealingConfigView.vue';
9
+
10
+ const { confirm, showError, showMessage } = vi.hoisted(() => ({
11
+ confirm: vi.fn(),
12
+ showError: vi.fn(),
13
+ showMessage: vi.fn(),
14
+ }));
15
+ vi.mock('@n8n/composables/useToast', () => ({
16
+ useToast: () => ({ showError, showMessage }),
17
+ }));
18
+ vi.mock('@n8n/design-system', async (importOriginal) => ({
19
+ ...(await importOriginal<typeof import('@n8n/design-system')>()),
20
+ useMessage: () => ({ confirm }),
21
+ }));
22
+ vi.mock('@n8n/stores/users.store', () => ({
23
+ useUsersStore: () => ({ currentUser: { id: 'creator' } }),
24
+ }));
25
+
26
+ const configuration: SelfHealingConfigurationDto = {
27
+ id: 1,
28
+ projectId: 'project',
29
+ scope: 'all',
30
+ workflowIds: [],
31
+ reviewerIds: ['creator'],
32
+ enabled: false,
33
+ enabledById: null,
34
+ lastActivity: null,
35
+ };
36
+
37
+ let router: Router;
38
+
39
+ async function renderView(configurationId: string) {
40
+ await router.push(`/projects/project/settings/self-healing/${configurationId}`);
41
+ return render(RouterView, { global: { plugins: [router] } });
42
+ }
43
+
44
+ beforeEach(() => {
45
+ vi.clearAllMocks();
46
+ router = createRouter({
47
+ history: createMemoryHistory(),
48
+ routes: [
49
+ {
50
+ path: '/projects/:projectId/settings/self-healing/:configurationId',
51
+ component: SelfHealingConfigView,
52
+ props: true,
53
+ },
54
+ {
55
+ path: '/projects/:projectId/settings',
56
+ name: VIEWS.PROJECT_SETTINGS,
57
+ component: { render: () => null },
58
+ },
59
+ ],
60
+ });
61
+ const store = useSelfHealingStore();
62
+ store.configurations.project = [];
63
+ store.options.project = {
64
+ workflows: [{ id: 'workflow', name: 'Production workflow' }],
65
+ reviewers: [
66
+ { id: 'creator', email: 'creator@example.com', firstName: 'Test', lastName: 'Reviewer' },
67
+ ],
68
+ };
69
+ vi.spyOn(store, 'load').mockResolvedValue();
70
+ });
71
+
72
+ it('creates an enabled configuration with the creator as reviewer', async () => {
73
+ const store = useSelfHealingStore();
74
+ const save = vi.spyOn(store, 'save').mockResolvedValue({
75
+ ...configuration,
76
+ enabled: true,
77
+ enabledById: 'creator',
78
+ });
79
+ const view = await renderView('new');
80
+ const button = await view.findByRole('button', { name: 'Create configuration' });
81
+ expect(button).toBeEnabled();
82
+ expect(view.queryByRole('switch')).not.toBeInTheDocument();
83
+ const reviewer = within(view.getByTestId('self-healing-selected-reviewer'));
84
+ expect(reviewer.getByText('Test Reviewer (you)')).toBeVisible();
85
+ expect(reviewer.getAllByText(/\(you\)/)).toHaveLength(1);
86
+ expect(reviewer.getByText('creator@example.com')).toBeVisible();
87
+ await fireEvent.click(button);
88
+ await waitFor(() =>
89
+ expect(save).toHaveBeenCalledWith(
90
+ 'project',
91
+ { scope: 'all', workflowIds: [], reviewerIds: ['creator'], enabled: true },
92
+ undefined,
93
+ ),
94
+ );
95
+ await waitFor(() =>
96
+ expect(router.currentRoute.value.fullPath).toBe('/projects/project/settings#self-healing'),
97
+ );
98
+ });
99
+
100
+ it('requires a workflow selection when an all-workflows configuration already exists', async () => {
101
+ const store = useSelfHealingStore();
102
+ store.configurations.project = [configuration];
103
+ const view = await renderView('new');
104
+ expect(await view.findByRole('button', { name: 'Create configuration' })).toBeDisabled();
105
+ expect(view.getByTestId('self-healing-workflows')).toBeVisible();
106
+ });
107
+
108
+ it('loads an existing configuration without offering to save unchanged values', async () => {
109
+ const store = useSelfHealingStore();
110
+ store.configurations.project = [{ ...configuration, enabled: true, enabledById: 'creator' }];
111
+ const view = await renderView('1');
112
+ expect(await view.findByRole('button', { name: 'Save changes' })).toBeDisabled();
113
+ });
114
+
115
+ it.each(['resolves', 'rejects'])(
116
+ 'keeps newer form edits when an obsolete load %s',
117
+ async (outcome) => {
118
+ const store = useSelfHealingStore();
119
+ store.configurations.project = [
120
+ configuration,
121
+ { ...configuration, id: 2, scope: 'selected', workflowIds: ['workflow'] },
122
+ ];
123
+ const pending = Promise.withResolvers<undefined>();
124
+ vi.mocked(store.load).mockReturnValueOnce(pending.promise).mockResolvedValueOnce();
125
+ const view = await renderView('1');
126
+ await router.push('/projects/project/settings/self-healing/2');
127
+ await fireEvent.click(await view.findByRole('button', { name: 'Remove reviewer' }));
128
+
129
+ if (outcome === 'rejects') {
130
+ pending.reject(new Error('Could not load the previous configuration'));
131
+ } else {
132
+ pending.resolve(undefined);
133
+ }
134
+ await waitAllPromises();
135
+
136
+ expect(view.queryByTestId('self-healing-selected-reviewer')).not.toBeInTheDocument();
137
+ expect(view.getByRole('button', { name: 'Discard changes' })).toBeEnabled();
138
+ expect(view.queryByRole('button', { name: 'Try again' })).not.toBeInTheDocument();
139
+ expect(showError).not.toHaveBeenCalled();
140
+ confirm.mockResolvedValueOnce('cancel');
141
+ await router.push('/projects/project/settings');
142
+ expect(confirm).toHaveBeenCalledOnce();
143
+ expect(router.currentRoute.value.params.configurationId).toBe('2');
144
+ },
145
+ );
146
+
147
+ it.each(['resolves', 'rejects'])(
148
+ 'keeps loading the current configuration when an obsolete load %s',
149
+ async (outcome) => {
150
+ const store = useSelfHealingStore();
151
+ store.configurations.project = [configuration, { ...configuration, id: 2 }];
152
+ const previous = Promise.withResolvers<undefined>();
153
+ const current = Promise.withResolvers<undefined>();
154
+ vi.mocked(store.load)
155
+ .mockReturnValueOnce(previous.promise)
156
+ .mockReturnValueOnce(current.promise);
157
+ const view = await renderView('1');
158
+ await router.push('/projects/project/settings/self-healing/2');
159
+
160
+ if (outcome === 'rejects') {
161
+ previous.reject(new Error('Could not load the previous configuration'));
162
+ } else {
163
+ previous.resolve(undefined);
164
+ }
165
+ await waitAllPromises();
166
+
167
+ expect(view.getByText('Loading configurations…')).toBeVisible();
168
+ expect(view.queryByRole('button', { name: 'Save changes' })).not.toBeInTheDocument();
169
+ expect(showError).not.toHaveBeenCalled();
170
+ current.resolve(undefined);
171
+ expect(await view.findByRole('button', { name: 'Save changes' })).toBeDisabled();
172
+ },
173
+ );
174
+
175
+ it.each([false, true])(
176
+ 'omits enabled=%s when saving edits and blocks changes during save',
177
+ async (enabled) => {
178
+ const store = useSelfHealingStore();
179
+ const selected = {
180
+ ...configuration,
181
+ enabled,
182
+ enabledById: enabled ? 'creator' : null,
183
+ scope: 'selected' as const,
184
+ workflowIds: ['workflow'],
185
+ };
186
+ store.configurations.project = [selected];
187
+ const pending = Promise.withResolvers<SelfHealingConfigurationDto>();
188
+ const save = vi.spyOn(store, 'save').mockReturnValue(pending.promise);
189
+ const view = await renderView('1');
190
+ const button = await view.findByRole('button', { name: 'Save changes' });
191
+ await fireEvent.click(view.getByRole('radio', { name: 'All' }));
192
+ await fireEvent.click(button);
193
+
194
+ for (const select of view.getAllByRole('combobox')) {
195
+ expect(select).toBeDisabled();
196
+ }
197
+ expect(view.getByRole('radio', { name: 'Select manually' })).toBeDisabled();
198
+ expect(view.getByRole('button', { name: 'Remove reviewer' })).toBeDisabled();
199
+ pending.resolve({ ...selected, scope: 'all', workflowIds: [] });
200
+ await waitFor(() => expect(button).toBeDisabled());
201
+ expect(router.currentRoute.value.path).toBe('/projects/project/settings/self-healing/1');
202
+ expect(save).toHaveBeenCalledWith(
203
+ 'project',
204
+ { scope: 'all', workflowIds: [], reviewerIds: ['creator'] },
205
+ 1,
206
+ );
207
+ },
208
+ );
209
+
210
+ it.each([
211
+ '/projects/project/settings',
212
+ '/projects/project/settings/self-healing/2',
213
+ '/projects/other/settings/self-healing/1',
214
+ ])('asks before discarding edits when navigating to %s', async (destination) => {
215
+ const store = useSelfHealingStore();
216
+ store.configurations.project = [
217
+ configuration,
218
+ { ...configuration, id: 2, scope: 'selected', workflowIds: ['workflow'] },
219
+ ];
220
+ store.configurations.other = [{ ...configuration, projectId: 'other' }];
221
+ store.options.other = store.options.project;
222
+ const view = await renderView('1');
223
+ const selected = await view.findByRole('radio', { name: 'Select manually' });
224
+ await fireEvent.click(selected);
225
+
226
+ confirm.mockResolvedValueOnce('cancel');
227
+ await router.push(destination);
228
+
229
+ expect(confirm).toHaveBeenCalledOnce();
230
+ expect(router.currentRoute.value.path).toBe('/projects/project/settings/self-healing/1');
231
+ expect(selected).toBeChecked();
232
+
233
+ confirm.mockResolvedValueOnce('confirm');
234
+ await router.push(destination);
235
+
236
+ expect(confirm).toHaveBeenCalledTimes(2);
237
+ expect(router.currentRoute.value.path).toBe(destination);
238
+ });
239
+
240
+ it('selects workflow rows and reviewers before saving', async () => {
241
+ const store = useSelfHealingStore();
242
+ const save = vi.spyOn(store, 'save').mockResolvedValue({
243
+ ...configuration,
244
+ scope: 'selected',
245
+ workflowIds: ['workflow'],
246
+ enabled: true,
247
+ enabledById: 'creator',
248
+ });
249
+ const view = await renderView('new');
250
+ await view.findByRole('button', { name: 'Create configuration' });
251
+ await fireEvent.click(view.getByRole('radio', { name: 'Select manually' }));
252
+ expect(view.getByRole('button', { name: 'Create configuration' })).toBeDisabled();
253
+ await fireEvent.click(view.getAllByRole('button', { name: 'Show popup' })[0]);
254
+ await fireEvent.click(await view.findByRole('option', { name: 'Production workflow' }));
255
+ expect(view.getByTestId('self-healing-selected-workflow')).toHaveTextContent(
256
+ 'Production workflow',
257
+ );
258
+ await fireEvent.click(view.getByRole('button', { name: 'Remove reviewer' }));
259
+ expect(view.getByRole('button', { name: 'Create configuration' })).toBeDisabled();
260
+ await fireEvent.click(view.getAllByRole('button', { name: 'Show popup' })[1]);
261
+ await fireEvent.click(await view.findByRole('option', { name: /Test Reviewer/ }));
262
+ await fireEvent.click(view.getByRole('button', { name: 'Create configuration' }));
263
+
264
+ expect(save).toHaveBeenCalledWith(
265
+ 'project',
266
+ {
267
+ scope: 'selected',
268
+ workflowIds: ['workflow'],
269
+ reviewerIds: ['creator'],
270
+ enabled: true,
271
+ },
272
+ undefined,
273
+ );
274
+ });
275
+
276
+ it('discards edits in place without changing the saved configuration', async () => {
277
+ const store = useSelfHealingStore();
278
+ store.configurations.project = [
279
+ { ...configuration, scope: 'selected', workflowIds: ['workflow'] },
280
+ ];
281
+ const save = vi.spyOn(store, 'save');
282
+ const view = await renderView('1');
283
+ await view.findByRole('button', { name: 'Remove workflow' });
284
+ for (let edit = 0; edit < 2; edit++) {
285
+ await fireEvent.click(view.getByRole('button', { name: 'Remove workflow' }));
286
+ await fireEvent.click(view.getByRole('button', { name: 'Remove reviewer' }));
287
+ expect(view.queryByTestId('self-healing-selected-workflow')).not.toBeInTheDocument();
288
+ await fireEvent.click(view.getByRole('radio', { name: 'All' }));
289
+ await fireEvent.click(view.getByRole('button', { name: 'Discard changes' }));
290
+ expect(await view.findByTestId('self-healing-selected-workflow')).toHaveTextContent(
291
+ 'Production workflow',
292
+ );
293
+ expect(view.getByTestId('self-healing-selected-reviewer')).toHaveTextContent('Test Reviewer');
294
+ expect(view.getByRole('button', { name: 'Save changes' })).toBeDisabled();
295
+ expect(view.getByRole('button', { name: 'Discard changes' })).toBeDisabled();
296
+ }
297
+ expect(router.currentRoute.value.params.configurationId).toBe('1');
298
+ expect(save).not.toHaveBeenCalled();
299
+ expect(store.load).toHaveBeenCalledOnce();
300
+ });
301
+
302
+ it('keeps draft workflow picks when switching from selected to all and back', async () => {
303
+ const view = await renderView('new');
304
+ await fireEvent.click(await view.findByRole('radio', { name: 'Select manually' }));
305
+ await fireEvent.click(view.getAllByRole('button', { name: 'Show popup' })[0]);
306
+ await fireEvent.click(await view.findByRole('option', { name: 'Production workflow' }));
307
+ await fireEvent.click(view.getByRole('radio', { name: 'All' }));
308
+ await fireEvent.click(view.getByRole('radio', { name: 'Select manually' }));
309
+
310
+ expect(view.getByTestId('self-healing-selected-workflow')).toHaveTextContent(
311
+ 'Production workflow',
312
+ );
313
+ expect(view.getByRole('button', { name: 'Create configuration' })).toBeEnabled();
314
+ });
315
+
316
+ it('saves all scope with no workflow IDs and uses it as the discard baseline', async () => {
317
+ const store = useSelfHealingStore();
318
+ store.configurations.project = [
319
+ { ...configuration, scope: 'selected', workflowIds: ['workflow'] },
320
+ ];
321
+ const save = vi.spyOn(store, 'save').mockResolvedValue(configuration);
322
+ const view = await renderView('1');
323
+ await fireEvent.click(await view.findByRole('radio', { name: 'All' }));
324
+ await fireEvent.click(view.getByRole('button', { name: 'Save changes' }));
325
+ await waitFor(() => expect(showMessage).toHaveBeenCalledOnce());
326
+
327
+ expect(save).toHaveBeenCalledWith(
328
+ 'project',
329
+ expect.objectContaining({ scope: 'all', workflowIds: [] }),
330
+ 1,
331
+ );
332
+ expect(view.getByRole('button', { name: 'Save changes' })).toBeDisabled();
333
+ expect(view.getByRole('button', { name: 'Discard changes' })).toBeDisabled();
334
+ await fireEvent.click(view.getByRole('radio', { name: 'Select manually' }));
335
+ expect(view.queryByTestId('self-healing-selected-workflow')).not.toBeInTheDocument();
336
+ await fireEvent.click(view.getAllByRole('button', { name: 'Show popup' })[0]);
337
+ await fireEvent.click(await view.findByRole('option', { name: 'Production workflow' }));
338
+ await fireEvent.click(view.getByRole('button', { name: 'Discard changes' }));
339
+ expect(view.getByRole('radio', { name: 'All' })).toBeChecked();
340
+ expect(view.getByRole('button', { name: 'Save changes' })).toBeDisabled();
341
+ await fireEvent.click(view.getByRole('radio', { name: 'Select manually' }));
342
+ expect(view.queryByTestId('self-healing-selected-workflow')).not.toBeInTheDocument();
343
+ expect(store.load).toHaveBeenCalledOnce();
344
+ });
345
+
346
+ it('keeps draft workflow picks and the saved baseline when saving all scope fails', async () => {
347
+ const store = useSelfHealingStore();
348
+ store.configurations.project = [configuration];
349
+ const error = new Error('Could not save the configuration');
350
+ const save = vi.spyOn(store, 'save').mockRejectedValue(error);
351
+ const view = await renderView('1');
352
+ await fireEvent.click(await view.findByRole('radio', { name: 'Select manually' }));
353
+ await fireEvent.click(view.getAllByRole('button', { name: 'Show popup' })[0]);
354
+ await fireEvent.click(await view.findByRole('option', { name: 'Production workflow' }));
355
+ await fireEvent.click(view.getByRole('radio', { name: 'All' }));
356
+ await fireEvent.click(view.getByRole('button', { name: 'Save changes' }));
357
+ await waitFor(() => expect(showError).toHaveBeenCalledWith(error, expect.any(String)));
358
+
359
+ expect(save).toHaveBeenCalledWith(
360
+ 'project',
361
+ expect.objectContaining({ scope: 'all', workflowIds: [] }),
362
+ 1,
363
+ );
364
+ await fireEvent.click(view.getByRole('radio', { name: 'Select manually' }));
365
+ expect(view.getByTestId('self-healing-selected-workflow')).toHaveTextContent(
366
+ 'Production workflow',
367
+ );
368
+ expect(view.getByRole('button', { name: 'Save changes' })).toBeEnabled();
369
+ await fireEvent.click(view.getByRole('button', { name: 'Discard changes' }));
370
+ expect(view.getByRole('radio', { name: 'All' })).toBeChecked();
371
+ expect(view.getByRole('button', { name: 'Discard changes' })).toBeDisabled();
372
+ await fireEvent.click(view.getByRole('radio', { name: 'Select manually' }));
373
+ expect(view.queryByTestId('self-healing-selected-workflow')).not.toBeInTheDocument();
374
+ expect(store.load).toHaveBeenCalledOnce();
375
+ });
376
+
377
+ it('keeps workflows reserved by other configurations unavailable in the picker', async () => {
378
+ const store = useSelfHealingStore();
379
+ store.configurations.project = [
380
+ { ...configuration, scope: 'selected', workflowIds: ['workflow'] },
381
+ ];
382
+ const view = await renderView('new');
383
+ await view.findByRole('button', { name: 'Create configuration' });
384
+ await fireEvent.click(view.getByRole('radio', { name: 'Select manually' }));
385
+ await fireEvent.click(view.getAllByRole('button', { name: 'Show popup' })[0]);
386
+
387
+ expect(await view.findByRole('option', { name: /Production workflow/ })).toHaveAttribute(
388
+ 'data-disabled',
389
+ );
390
+ });
391
+
392
+ it('returns to project settings at the self-healing section', async () => {
393
+ const store = useSelfHealingStore();
394
+ store.configurations.project = [configuration];
395
+ const view = await renderView('1');
396
+ await view.findByRole('button', { name: 'Save changes' });
397
+ await fireEvent.click(view.getByRole('button', { name: 'Project settings' }));
398
+
399
+ await waitFor(() =>
400
+ expect(router.currentRoute.value).toMatchObject({
401
+ name: VIEWS.PROJECT_SETTINGS,
402
+ params: { projectId: 'project' },
403
+ hash: '#self-healing',
404
+ }),
405
+ );
406
+ });
407
+
408
+ it('shows an email-only reviewer once and allows removal', async () => {
409
+ const store = useSelfHealingStore();
410
+ store.options.project.reviewers = [
411
+ { id: 'creator', email: 'creator@example.com', firstName: null, lastName: null },
412
+ ];
413
+ const view = await renderView('new');
414
+ const reviewer = within(await view.findByTestId('self-healing-selected-reviewer'));
415
+
416
+ expect(reviewer.getAllByText('creator@example.com')).toHaveLength(1);
417
+ expect(reviewer.getAllByText(/\(you\)/)).toHaveLength(1);
418
+ await fireEvent.click(reviewer.getByRole('button', { name: 'Remove reviewer' }));
419
+ expect(view.queryByTestId('self-healing-selected-reviewer')).not.toBeInTheDocument();
420
+ });
421
+
422
+ it('keeps a saved unavailable reviewer visible and removable', async () => {
423
+ const store = useSelfHealingStore();
424
+ store.configurations.project = [{ ...configuration, reviewerIds: ['creator', 'unavailable'] }];
425
+ const view = await renderView('1');
426
+ await view.findByRole('button', { name: 'Save changes' });
427
+ const rows = view.getAllByTestId('self-healing-selected-reviewer');
428
+ expect(rows).toHaveLength(2);
429
+ const reviewer = within(rows[1]);
430
+ expect(reviewer.getByText('Reviewer no longer available')).toBeVisible();
431
+
432
+ await fireEvent.click(reviewer.getByRole('button', { name: 'Remove reviewer' }));
433
+ expect(view.getAllByTestId('self-healing-selected-reviewer')).toHaveLength(1);
434
+ expect(view.getByText('Test Reviewer (you)')).toBeVisible();
435
+ expect(view.getByRole('button', { name: 'Save changes' })).toBeEnabled();
436
+ });