@n8n/frontend-module-inbox 0.2.3 → 0.3.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,160 @@
1
+ <script setup lang="ts">
2
+ import type { SelfHealingResultDetail, WorkflowSuggestionActivity } from '@n8n/api-types';
3
+ import { N8nAssistantAvatar, N8nIcon, N8nText, N8nTimeAgo } from '@n8n/design-system';
4
+ import { useI18n } from '@n8n/i18n';
5
+ import { useRootStore } from '@n8n/stores/useRootStore';
6
+ import { computed } from 'vue';
7
+
8
+ const props = defineProps<{ detail: SelfHealingResultDetail }>();
9
+ const i18n = useI18n();
10
+ const rootStore = useRootStore();
11
+
12
+ type Activity = Omit<WorkflowSuggestionActivity, 'action'> & {
13
+ action: WorkflowSuggestionActivity['action'] | 'completed' | 'dismissed' | 'closed';
14
+ };
15
+
16
+ const entries = computed<Activity[]>(() => {
17
+ const result = props.detail;
18
+ let activity: Activity[];
19
+ if (result.suggestion) {
20
+ activity = [...result.suggestion.activity];
21
+ } else {
22
+ activity = [
23
+ {
24
+ id: `${result.resultId}:completed`,
25
+ action: 'completed',
26
+ author: 'assistant',
27
+ actorId: null,
28
+ createdAt: result.completedAt,
29
+ },
30
+ ];
31
+ }
32
+
33
+ if (result.reviewState === 'closed') {
34
+ activity.push({
35
+ id: `${result.resultId}:closed`,
36
+ action: 'closed',
37
+ author: 'system',
38
+ actorId: null,
39
+ createdAt: result.completedAt,
40
+ });
41
+ }
42
+
43
+ if (result.dismissedAt) {
44
+ activity.push({
45
+ id: `${result.resultId}:dismissed`,
46
+ action: 'dismissed',
47
+ author: 'human',
48
+ actorId: result.dismissedById,
49
+ createdAt: result.dismissedAt,
50
+ });
51
+ }
52
+
53
+ return activity.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
54
+ });
55
+
56
+ function actorLabel(author: Activity['author']) {
57
+ if (author === 'assistant') return i18n.baseText('inbox.selfHealing.metadata.assistant');
58
+ if (author === 'system') return i18n.baseText('inbox.selfHealing.activity.system');
59
+ // The result stores the actor ID without a display name.
60
+ return i18n.baseText('inbox.selfHealing.activity.user');
61
+ }
62
+ </script>
63
+
64
+ <template>
65
+ <div :class="$style.activity">
66
+ <N8nText tag="h3" bold size="medium" color="text-light" :class="$style.heading">{{
67
+ i18n.baseText('inbox.selfHealing.tabs.activity')
68
+ }}</N8nText>
69
+ <ol :class="$style.entries" data-test-id="self-healing-activity">
70
+ <li
71
+ v-for="entry in entries"
72
+ :key="entry.id"
73
+ :class="$style.entry"
74
+ :data-action="entry.action"
75
+ data-test-id="self-healing-activity-entry"
76
+ >
77
+ <N8nAssistantAvatar v-if="entry.author === 'assistant'" size="mini" />
78
+ <N8nIcon
79
+ v-else
80
+ :icon="entry.author === 'human' ? 'user' : 'info'"
81
+ size="large"
82
+ color="text-light"
83
+ aria-hidden="true"
84
+ />
85
+ <div :class="$style.headline">
86
+ <N8nText size="medium" color="text-dark">{{ actorLabel(entry.author) }}</N8nText>
87
+ <span aria-hidden="true" :class="$style.separator">|</span>
88
+ <N8nText size="medium" color="text-light">
89
+ {{ i18n.baseText(`inbox.selfHealing.activity.${entry.action}`) }}
90
+ </N8nText>
91
+ <N8nText size="medium" color="text-light">
92
+ <time :datetime="entry.createdAt">
93
+ <N8nTimeAgo :date="entry.createdAt" :locale="rootStore.defaultLocale" />
94
+ </time>
95
+ </N8nText>
96
+ </div>
97
+ </li>
98
+ </ol>
99
+ </div>
100
+ </template>
101
+
102
+ <style module lang="scss">
103
+ @use '../reviews/components/activity-card' as activity;
104
+
105
+ .activity {
106
+ --review-activity--avatar-size: var(--height--3xs);
107
+ --review-activity--line-height: var(--height--2xs);
108
+ --review-activity--gap: var(--spacing--md);
109
+
110
+ padding-bottom: var(--spacing--md);
111
+ }
112
+
113
+ .heading {
114
+ margin: 0;
115
+ padding: 0 var(--spacing--sm) var(--spacing--sm);
116
+ }
117
+
118
+ .entries {
119
+ display: flex;
120
+ flex-direction: column;
121
+ gap: var(--review-activity--gap);
122
+ list-style: none;
123
+ margin: 0;
124
+ padding: 0 var(--spacing--sm);
125
+ }
126
+
127
+ .entry {
128
+ @include activity.activity-row;
129
+ position: relative;
130
+
131
+ > :first-child {
132
+ @include activity.activity-avatar;
133
+ }
134
+ }
135
+
136
+ /* Join consecutive events through the space between their avatars. */
137
+ .entry:not(:first-child)::before {
138
+ content: '';
139
+ position: absolute;
140
+ bottom: calc(100% + var(--spacing--5xs));
141
+ height: calc(var(--review-activity--gap) - 2 * var(--spacing--5xs));
142
+ left: calc(var(--review-activity--avatar-size) / 2);
143
+ border-left: var(--border);
144
+ }
145
+
146
+ .headline {
147
+ @include activity.activity-headline;
148
+ min-width: 0;
149
+ line-height: var(--review-activity--line-height);
150
+
151
+ > :global(.n8n-text) {
152
+ line-height: inherit;
153
+ }
154
+ }
155
+
156
+ .separator {
157
+ margin-inline: var(--spacing--3xs);
158
+ color: var(--text-color--subtler);
159
+ }
160
+ </style>
@@ -0,0 +1,458 @@
1
+ import type { WorkflowSuggestionActivity } from '@n8n/api-types';
2
+ import { VIEWS } from '@n8n/frontend-constants/views';
3
+ import { componentRegistry, type SlotWorkflowDiffProps } from '@n8n/frontend-module-sdk';
4
+ import { createComponentRenderer } from '@n8n/frontend-test-utils';
5
+ import { useSettingsStore } from '@n8n/stores/settings.store';
6
+ import userEvent from '@testing-library/user-event';
7
+ import { screen, within } from '@testing-library/vue';
8
+ import { defineComponent } from 'vue';
9
+ import { createMemoryHistory, createRouter } from 'vue-router';
10
+
11
+ import SelfHealingResultContent from './SelfHealingResultContent.vue';
12
+ import { result, suggestion } from './selfHealingResults.test.utils';
13
+
14
+ let diffProps: SlotWorkflowDiffProps | undefined;
15
+ const DiffView = defineComponent({
16
+ props: [
17
+ 'sourceSnapshot',
18
+ 'targetSnapshot',
19
+ 'workflowId',
20
+ 'workflowName',
21
+ 'sourceLabel',
22
+ 'targetLabel',
23
+ ],
24
+ setup(props) {
25
+ diffProps = props as SlotWorkflowDiffProps;
26
+ },
27
+ template: '<div data-test-id="saved-workflow-diff" />',
28
+ });
29
+
30
+ const router = createRouter({
31
+ history: createMemoryHistory(),
32
+ routes: [
33
+ { path: '/workflow/:workflowId', name: VIEWS.WORKFLOW, component: { template: '<div />' } },
34
+ {
35
+ path: '/workflow/:workflowId/executions/:executionId',
36
+ name: VIEWS.EXECUTION_PREVIEW,
37
+ component: { template: '<div />' },
38
+ },
39
+ ],
40
+ });
41
+ const renderComponent = createComponentRenderer(SelfHealingResultContent, {
42
+ global: { plugins: [router], stubs: { RouterLink: false } },
43
+ });
44
+
45
+ function renderContent(
46
+ props: Partial<InstanceType<typeof SelfHealingResultContent>['$props']> = {},
47
+ ) {
48
+ return renderComponent({
49
+ props: {
50
+ detail: result(),
51
+ workflowName: 'Daily report',
52
+ tab: 'activity',
53
+ pendingAction: null,
54
+ publishAccess: 'allowed',
55
+ canChat: true,
56
+ ...props,
57
+ },
58
+ });
59
+ }
60
+
61
+ async function openMenu(name: 'Review' | 'Continue in…') {
62
+ await userEvent.click(screen.getByRole('button', { name }));
63
+ return await screen.findByRole('menu');
64
+ }
65
+
66
+ beforeEach(() => {
67
+ diffProps = undefined;
68
+ componentRegistry.register('workflow-diff', DiffView);
69
+ });
70
+ afterEach(() => componentRegistry.clear());
71
+
72
+ describe('SelfHealingResultContent', () => {
73
+ it('renders the saved report as read-only Markdown', () => {
74
+ const { getByRole, getByTestId, queryByRole } = renderContent({
75
+ detail: result({
76
+ report: '**Diagnosis**\n\n- [ ] Check the connection\n\n<script>example()</script>',
77
+ }),
78
+ });
79
+
80
+ expect(getByRole('heading', { name: 'Restore the last step' })).toBeInTheDocument();
81
+ const report = getByTestId('self-healing-report');
82
+ expect(report.querySelector('strong')).toHaveTextContent('Diagnosis');
83
+ expect(within(report).getByRole('checkbox')).toBeDisabled();
84
+ expect(report.querySelector('script')).toBeNull();
85
+ expect(report).toHaveTextContent('<script>example()</script>');
86
+ expect(queryByRole('textbox')).not.toBeInTheDocument();
87
+ expect(queryByRole('tab', { name: 'Trace' })).not.toBeInTheDocument();
88
+ });
89
+
90
+ it('offers publishing and both continuations for a ready fix', async () => {
91
+ const { getByRole, getByTestId, emitted } = renderContent();
92
+
93
+ expect(getByTestId('self-healing-status-card')).toHaveTextContent(/^StatusFix ready$/);
94
+ const menu = await openMenu('Review');
95
+ expect(
96
+ within(menu)
97
+ .getAllByRole('menuitem')
98
+ .map((item) => item.textContent?.trim()),
99
+ ).toEqual(['Apply and publish', 'Continue with Assistant', 'Continue with Editor']);
100
+ expect(menu).toHaveTextContent(
101
+ 'Continuing saves the changes to the workflow draft. It does not publish them.',
102
+ );
103
+ await userEvent.click(within(menu).getByRole('menuitem', { name: 'Apply and publish' }));
104
+ await userEvent.click(getByRole('button', { name: 'Discard and close' }));
105
+
106
+ expect(emitted('action')).toEqual([['approve-and-publish'], ['dismiss']]);
107
+ });
108
+
109
+ it.each([
110
+ ['Continue with Assistant', 'chat'],
111
+ ['Continue with Editor', 'editor'],
112
+ ])('emits %s from the Review menu', async (name, action) => {
113
+ const { emitted } = renderContent();
114
+
115
+ const menu = await openMenu('Review');
116
+ await userEvent.click(within(menu).getByRole('menuitem', { name }));
117
+
118
+ expect(emitted('action')).toEqual([[action]]);
119
+ });
120
+
121
+ it('disables publishing and explains why when publication is not allowed', async () => {
122
+ renderContent({ publishAccess: 'denied' });
123
+
124
+ const menu = await openMenu('Review');
125
+
126
+ expect(within(menu).getByRole('menuitem', { name: 'Apply and publish' })).toHaveAttribute(
127
+ 'data-disabled',
128
+ );
129
+ expect(
130
+ within(menu).getByRole('menuitem', { name: 'Continue with Editor' }),
131
+ ).not.toHaveAttribute('data-disabled');
132
+ expect(menu).toHaveTextContent('Publish permission is required to apply and publish.');
133
+ });
134
+
135
+ it('disables publishing without a permission note while access is unknown', async () => {
136
+ renderContent({ publishAccess: 'unknown' });
137
+
138
+ const menu = await openMenu('Review');
139
+
140
+ expect(within(menu).getByRole('menuitem', { name: 'Apply and publish' })).toHaveAttribute(
141
+ 'data-disabled',
142
+ );
143
+ expect(menu).not.toHaveTextContent('Publish permission is required');
144
+ });
145
+
146
+ it('disables every decision while a decision is pending', () => {
147
+ const { getByRole } = renderContent({ pendingAction: 'editor' });
148
+
149
+ expect(getByRole('button', { name: 'Discard and close' })).toBeDisabled();
150
+ expect(getByRole('button', { name: /Review/ })).toBeDisabled();
151
+ });
152
+
153
+ it.each([
154
+ { proposal: null, notice: false },
155
+ { proposal: suggestion({ resultKind: 'needs_you' }), notice: true },
156
+ ])(
157
+ 'offers both continuations without publishing for Needs attention',
158
+ async ({ proposal, notice }) => {
159
+ const { getByRole, queryByRole, getByTestId, emitted } = renderContent({
160
+ detail: result({ outcome: 'needs_you', suggestion: proposal }),
161
+ });
162
+
163
+ expect(getByTestId('self-healing-outcome-notice')).toHaveTextContent(
164
+ 'Assistant needs your feedback',
165
+ );
166
+ expect(getByTestId('self-healing-status-card')).toHaveTextContent(/^StatusNeeds attention$/);
167
+ expect(queryByRole('button', { name: /Review/ })).not.toBeInTheDocument();
168
+ expect(queryByRole('tab', { name: 'Changes' }) !== null).toBe(proposal !== null);
169
+
170
+ const menu = await openMenu('Continue in…');
171
+ expect(
172
+ within(menu)
173
+ .getAllByRole('menuitem')
174
+ .map((item) => item.textContent?.trim()),
175
+ ).toEqual(['Continue with Assistant', 'Continue with Editor']);
176
+ expect(menu.textContent?.includes('Continuing')).toBe(notice);
177
+ await userEvent.click(within(menu).getByRole('menuitem', { name: 'Continue with Editor' }));
178
+ await userEvent.click(getByRole('button', { name: 'Discard and close' }));
179
+
180
+ expect(emitted('action')).toEqual([['editor'], ['dismiss']]);
181
+ },
182
+ );
183
+
184
+ it('shows a closed Could not fix result without review actions', () => {
185
+ const { getByTestId, queryByRole } = renderContent({
186
+ detail: result({ outcome: 'could_not_fix', suggestion: null, reviewState: 'closed' }),
187
+ });
188
+
189
+ expect(getByTestId('self-healing-status-card')).toHaveTextContent(/^StatusCould not fix$/);
190
+ expect(getByTestId('self-healing-outcome-notice')).toHaveTextContent('closed automatically');
191
+ expect(getByTestId('self-healing-report')).toBeInTheDocument();
192
+ expect(getByTestId('self-healing-activity')).toHaveTextContent(
193
+ /System\s*\|\s*Closed automatically because no fix was possible/,
194
+ );
195
+ expect(queryByRole('button', { name: 'Discard and close' })).not.toBeInTheDocument();
196
+ expect(queryByRole('button', { name: /Continue in|Review/ })).not.toBeInTheDocument();
197
+ expect(queryByRole('tab', { name: 'Changes' })).not.toBeInTheDocument();
198
+ });
199
+
200
+ it('shows a closed result in its status and activity', () => {
201
+ const { getByTestId, getAllByText } = renderContent({
202
+ detail: result({
203
+ outcome: 'could_not_fix',
204
+ suggestion: null,
205
+ reviewState: 'dismissed',
206
+ dismissedAt: '2026-10-09T09:01:00.000Z',
207
+ dismissedById: 'human-1',
208
+ }),
209
+ });
210
+
211
+ expect(getByTestId('self-healing-report')).toBeInTheDocument();
212
+ expect(getByTestId('self-healing-activity')).not.toHaveTextContent('human-1');
213
+ expect(getByTestId('self-healing-status-card')).toHaveTextContent('Closed');
214
+ expect(getAllByText('Closed', { exact: true })).toHaveLength(1);
215
+ expect(getByTestId('self-healing-activity')).toHaveTextContent('Completed the investigation');
216
+ expect(getByTestId('self-healing-activity')).toHaveTextContent(
217
+ /A user\s*\|\s*Closed the result/,
218
+ );
219
+ });
220
+
221
+ it('keeps the editor and discard available when chat is unavailable', async () => {
222
+ const { getByRole } = renderContent({
223
+ detail: result({ outcome: 'needs_you', suggestion: null }),
224
+ canChat: false,
225
+ });
226
+
227
+ expect(getByRole('button', { name: 'Discard and close' })).toBeEnabled();
228
+ const menu = await openMenu('Continue in…');
229
+ expect(within(menu).getByRole('menuitem', { name: 'Continue with Assistant' })).toHaveAttribute(
230
+ 'data-disabled',
231
+ );
232
+ expect(
233
+ within(menu).getByRole('menuitem', { name: 'Continue with Editor' }),
234
+ ).not.toHaveAttribute('data-disabled');
235
+ expect(menu).toHaveTextContent(
236
+ 'Assistant is unavailable. You can still continue in the editor.',
237
+ );
238
+ });
239
+
240
+ it.each([
241
+ { reviewState: 'applied', label: 'Applied' },
242
+ { reviewState: 'dismissed', label: 'Closed' },
243
+ ] as const)('uses the closed status color for $reviewState results', ({ reviewState, label }) => {
244
+ const { getByRole } = renderContent({ detail: result({ reviewState }) });
245
+
246
+ expect(getByRole('img', { name: label }).className).toMatch(/closed/);
247
+ });
248
+
249
+ it('does not claim that applied changes are published', () => {
250
+ const { getByTestId, queryByTestId } = renderContent({
251
+ detail: result({ reviewState: 'applied' }),
252
+ });
253
+
254
+ expect(getByTestId('self-healing-status-card')).toHaveTextContent('Applied');
255
+ expect(getByTestId('self-healing-status-card')).not.toHaveTextContent(/Closed|Published/);
256
+ expect(queryByTestId('self-healing-outdated-notice')).not.toBeInTheDocument();
257
+ });
258
+
259
+ it.each(['applied', 'outdated', 'discarded', 'dismissed'] as const)(
260
+ 'keeps %s results read-only',
261
+ (reviewState) => {
262
+ const { getByTestId, queryByTestId, queryByRole } = renderContent({
263
+ detail: result({ reviewState }),
264
+ publishAccess: 'denied',
265
+ canChat: false,
266
+ });
267
+
268
+ if (reviewState === 'outdated') {
269
+ expect(getByTestId('self-healing-outdated-notice')).toBeInTheDocument();
270
+ } else {
271
+ expect(queryByTestId('self-healing-outdated-notice')).not.toBeInTheDocument();
272
+ }
273
+ expect(getByTestId('self-healing-report')).toBeInTheDocument();
274
+ expect(getByTestId('self-healing-activity')).toBeInTheDocument();
275
+ expect(queryByRole('button', { name: 'Discard and close' })).not.toBeInTheDocument();
276
+ expect(queryByRole('button', { name: /Review/ })).not.toBeInTheDocument();
277
+ },
278
+ );
279
+
280
+ it('renders the exact stored snapshots even after a suggestion is outdated', () => {
281
+ const proposal = suggestion({ state: 'closed', closedReason: 'outdated' });
282
+ proposal.payload.original.settings = { executionOrder: 'v1' };
283
+ const { getByTestId } = renderContent({
284
+ detail: result({ suggestion: proposal, reviewState: 'outdated' }),
285
+ tab: 'changes',
286
+ });
287
+
288
+ expect(getByTestId('saved-workflow-diff')).toBeInTheDocument();
289
+ expect(diffProps).toMatchObject({
290
+ sourceSnapshot: proposal.payload.original,
291
+ targetSnapshot: proposal.payload.proposed,
292
+ workflowId: 'workflow-1',
293
+ workflowName: 'Daily report',
294
+ sourceLabel: 'Original',
295
+ targetLabel: 'Suggested changes',
296
+ });
297
+ expect(diffProps?.sourceSnapshot).not.toHaveProperty('versionId');
298
+ expect(diffProps?.targetSnapshot).not.toHaveProperty('createdAt');
299
+ });
300
+
301
+ it('keeps continuation available while reviewing the suggested changes', async () => {
302
+ const { queryByTestId, emitted } = renderContent({
303
+ detail: result({ outcome: 'needs_you', suggestion: suggestion({ resultKind: 'needs_you' }) }),
304
+ tab: 'changes',
305
+ });
306
+
307
+ expect(queryByTestId('self-healing-activity-panel')).not.toBeInTheDocument();
308
+ const menu = await openMenu('Continue in…');
309
+ await userEvent.click(within(menu).getByRole('menuitem', { name: 'Continue with Assistant' }));
310
+ expect(emitted('action')).toEqual([['chat']]);
311
+ });
312
+
313
+ it('falls back to Activity when a linked Changes tab has no proposal', () => {
314
+ const { getByTestId, queryByTestId } = renderContent({
315
+ detail: result({ outcome: 'could_not_fix', suggestion: null }),
316
+ tab: 'changes',
317
+ });
318
+
319
+ expect(getByTestId('self-healing-activity-panel')).toBeInTheDocument();
320
+ expect(queryByTestId('saved-workflow-diff')).not.toBeInTheDocument();
321
+ });
322
+
323
+ it.each([
324
+ {
325
+ deployment: 'Cloud',
326
+ isCloud: true,
327
+ visible: ['credits'],
328
+ hidden: ['promptTokens', 'totalTokens'],
329
+ },
330
+ {
331
+ deployment: 'self-hosted',
332
+ isCloud: false,
333
+ visible: ['promptTokens', 'totalTokens'],
334
+ hidden: ['credits'],
335
+ },
336
+ ])('shows known $deployment usage, including zeroes', async ({ isCloud, visible, hidden }) => {
337
+ useSettingsStore().settings.deployment = { type: isCloud ? 'cloud' : 'default' };
338
+ const detail = result({
339
+ usage: {
340
+ turns: 3,
341
+ durationSeconds: null,
342
+ ...(isCloud ? { credits: 0 } : { promptTokens: 0, completionTokens: null, totalTokens: 0 }),
343
+ },
344
+ });
345
+ const { getByTestId, queryByTestId, rerender } = renderContent({ detail });
346
+
347
+ for (const field of visible) {
348
+ expect(getByTestId(`self-healing-usage-${field}`)).toHaveTextContent('0');
349
+ }
350
+ for (const field of [...hidden, 'turns', 'duration', 'completionTokens']) {
351
+ expect(queryByTestId(`self-healing-usage-${field}`)).not.toBeInTheDocument();
352
+ }
353
+
354
+ await rerender({
355
+ detail: result({
356
+ usage: {
357
+ turns: 3,
358
+ durationSeconds: null,
359
+ ...(isCloud
360
+ ? { credits: null }
361
+ : { promptTokens: null, completionTokens: null, totalTokens: null }),
362
+ },
363
+ }),
364
+ });
365
+ expect(queryByTestId('self-healing-usage')).not.toBeInTheDocument();
366
+ });
367
+
368
+ it.each(['cloud', 'default'] as const)(
369
+ 'shows saved usage and duration regardless of the %s deployment',
370
+ (deployment) => {
371
+ useSettingsStore().settings.deployment = { type: deployment };
372
+ const { getByTestId, queryByTestId } = renderContent({
373
+ detail: result({
374
+ usage: {
375
+ turns: 3,
376
+ durationSeconds: 65,
377
+ ...(deployment === 'cloud'
378
+ ? { promptTokens: 100, completionTokens: 50, totalTokens: 150 }
379
+ : { credits: 1 }),
380
+ },
381
+ }),
382
+ });
383
+
384
+ expect(getByTestId('self-healing-usage-duration')).toBeInTheDocument();
385
+ if (deployment === 'cloud') {
386
+ expect(getByTestId('self-healing-usage-promptTokens')).toHaveTextContent('100');
387
+ expect(getByTestId('self-healing-usage-completionTokens')).toHaveTextContent('50');
388
+ expect(getByTestId('self-healing-usage-totalTokens')).toHaveTextContent('150');
389
+ expect(queryByTestId('self-healing-usage-credits')).not.toBeInTheDocument();
390
+ } else {
391
+ expect(getByTestId('self-healing-usage-credits')).toHaveTextContent('1');
392
+ for (const field of ['promptTokens', 'completionTokens', 'totalTokens']) {
393
+ expect(queryByTestId(`self-healing-usage-${field}`)).not.toBeInTheDocument();
394
+ }
395
+ }
396
+ },
397
+ );
398
+
399
+ it('links only an available original execution and preserves the report without it', async () => {
400
+ const { getByText, getByTestId, queryByTestId, rerender } = renderContent();
401
+ expect(getByText('View execution')).toBeInTheDocument();
402
+ expect(getByTestId('self-healing-execution-link')).toHaveTextContent('Execution execution-1');
403
+ expect(getByTestId('self-healing-workflow-link')).toHaveAttribute(
404
+ 'href',
405
+ '/workflow/workflow-1',
406
+ );
407
+ expect(getByTestId('self-healing-execution-link')).toHaveAttribute(
408
+ 'href',
409
+ '/workflow/workflow-1/executions/execution-1',
410
+ );
411
+
412
+ await rerender({ detail: result({ execution: { status: 'unavailable' } }) });
413
+
414
+ expect(queryByTestId('self-healing-execution-link')).not.toBeInTheDocument();
415
+ expect(getByTestId('self-healing-execution-unavailable')).toHaveTextContent(
416
+ 'Execution unavailable',
417
+ );
418
+ expect(getByTestId('self-healing-report')).toBeInTheDocument();
419
+ });
420
+
421
+ it('distinguishes Assistant, human, and system activity without exposing actor IDs', () => {
422
+ const activity: WorkflowSuggestionActivity[] = [
423
+ {
424
+ id: 'later',
425
+ action: 'discarded',
426
+ author: 'human',
427
+ actorId: 'human-private-id',
428
+ createdAt: '2026-10-09T10:00:00.000Z',
429
+ },
430
+ ...suggestion().activity,
431
+ {
432
+ id: 'outdated',
433
+ action: 'outdated',
434
+ author: 'system',
435
+ actorId: null,
436
+ createdAt: '2026-10-09T09:30:00.000Z',
437
+ },
438
+ ];
439
+ const { getAllByTestId, getByTestId } = renderContent({
440
+ detail: result({ suggestion: suggestion({ activity }) }),
441
+ });
442
+ const entries = getAllByTestId('self-healing-activity-entry');
443
+
444
+ expect(entries[0]).toHaveTextContent(/n8n Assistant\s*\|\s*Submitted a suggestion/);
445
+ expect(entries[1]).toHaveTextContent(/System\s*\|\s*Marked the suggestion as outdated/);
446
+ expect(entries[2]).toHaveTextContent(/A user\s*\|\s*Discarded the suggested changes/);
447
+ expect(getByTestId('self-healing-activity')).not.toHaveTextContent('human-private-id');
448
+ });
449
+
450
+ it('emits tab changes without selecting a new item', async () => {
451
+ const { getByRole, emitted } = renderContent();
452
+
453
+ await userEvent.click(getByRole('tab', { name: 'Changes' }));
454
+
455
+ expect(emitted('update:tab')).toEqual([['changes']]);
456
+ expect(emitted('action')).toBeUndefined();
457
+ });
458
+ });