@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.
- package/README.md +25 -2
- package/package.json +12 -12
- package/src/components/InboxList.test.ts +53 -1
- package/src/components/InboxList.vue +7 -12
- package/src/components/InboxListItem.vue +54 -22
- package/src/inbox.constants.ts +3 -1
- package/src/inbox.module.ts +6 -1
- package/src/inbox.routes.test.ts +26 -0
- package/src/inbox.routes.ts +15 -2
- package/src/inbox.store.ts +8 -2
- package/src/promotions/PromotionReviewDetail.test.ts +244 -0
- package/src/promotions/PromotionReviewDetail.vue +395 -0
- package/src/promotions/promotionReviews.api.ts +15 -0
- package/src/self-healing/SelfHealingResultActivity.vue +160 -0
- package/src/self-healing/SelfHealingResultContent.test.ts +458 -0
- package/src/self-healing/SelfHealingResultContent.vue +430 -0
- package/src/self-healing/SelfHealingResultDetail.test.ts +804 -0
- package/src/self-healing/SelfHealingResultDetail.vue +340 -8
- package/src/self-healing/SelfHealingResultMetadata.vue +282 -0
- package/src/self-healing/selfHealingResult.store.test.ts +86 -0
- package/src/self-healing/selfHealingResult.store.ts +153 -0
- package/src/self-healing/selfHealingResults.api.test.ts +24 -0
- package/src/self-healing/selfHealingResults.api.ts +38 -0
- package/src/self-healing/selfHealingResults.test.utils.ts +106 -0
- package/src/views/InboxView.test.ts +126 -4
- package/src/views/InboxView.vue +26 -10
|
@@ -0,0 +1,804 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
SelfHealingResultActionResponse,
|
|
3
|
+
SelfHealingResultDetail as ResultDetail,
|
|
4
|
+
} from '@n8n/api-types';
|
|
5
|
+
import { capabilities, capabilityRegistry } from '@n8n/frontend-module-sdk';
|
|
6
|
+
import { createComponentRenderer, waitAllPromises } from '@n8n/frontend-test-utils';
|
|
7
|
+
import { ResponseError } from '@n8n/rest-api-client';
|
|
8
|
+
import { useRootStore } from '@n8n/stores/useRootStore';
|
|
9
|
+
import { createDeferredPromise } from '@n8n/utils/promise/deferred-promise';
|
|
10
|
+
import { computed, ref } from 'vue';
|
|
11
|
+
|
|
12
|
+
import SelfHealingResultDetail from './SelfHealingResultDetail.vue';
|
|
13
|
+
import { useSelfHealingResultStore } from './selfHealingResult.store';
|
|
14
|
+
import * as api from './selfHealingResults.api';
|
|
15
|
+
import { result, resultSelection, suggestion } from './selfHealingResults.test.utils';
|
|
16
|
+
|
|
17
|
+
vi.mock('./selfHealingResults.api');
|
|
18
|
+
const { push, showError, showMessage, resolveChatProject, startChat } = vi.hoisted(() => ({
|
|
19
|
+
push: vi.fn(),
|
|
20
|
+
showError: vi.fn(),
|
|
21
|
+
showMessage: vi.fn(),
|
|
22
|
+
resolveChatProject: vi.fn(),
|
|
23
|
+
startChat: vi.fn(),
|
|
24
|
+
}));
|
|
25
|
+
vi.mock('vue-router', async (importOriginal) => ({
|
|
26
|
+
...(await importOriginal<typeof import('vue-router')>()),
|
|
27
|
+
useRouter: () => ({ push }),
|
|
28
|
+
}));
|
|
29
|
+
vi.mock('@n8n/composables/useToast', () => ({ useToast: () => ({ showError, showMessage }) }));
|
|
30
|
+
|
|
31
|
+
const onItemChange = vi.fn();
|
|
32
|
+
const chatAvailable = ref(true);
|
|
33
|
+
let selectedId: string | null;
|
|
34
|
+
const renderComponent = createComponentRenderer(SelfHealingResultDetail, {
|
|
35
|
+
props: {
|
|
36
|
+
selection: resultSelection,
|
|
37
|
+
tab: 'activity',
|
|
38
|
+
onItemChange,
|
|
39
|
+
isSelected: (id: string) => selectedId === id,
|
|
40
|
+
},
|
|
41
|
+
global: {
|
|
42
|
+
stubs: {
|
|
43
|
+
SelfHealingResultContent: {
|
|
44
|
+
props: ['detail', 'pendingAction', 'publishAccess'],
|
|
45
|
+
template: `<div data-test-id="result-content" :data-state="detail.reviewState" :data-pending="pendingAction" :data-publish-access="publishAccess">
|
|
46
|
+
<span>{{ detail.report }}</span>
|
|
47
|
+
<button v-for="action in ['editor', 'approve-and-publish', 'dismiss', 'chat']" :key="action" :data-test-id="action" @click="$emit('action', action)" />
|
|
48
|
+
<slot name="notice" />
|
|
49
|
+
</div>`,
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
function appliedResult(
|
|
56
|
+
overrides: Partial<SelfHealingResultActionResponse> = {},
|
|
57
|
+
): SelfHealingResultActionResponse {
|
|
58
|
+
return {
|
|
59
|
+
...result({ reviewState: 'applied' }),
|
|
60
|
+
...overrides,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const secondSelection = { ...resultSelection, id: 'result-2' };
|
|
65
|
+
|
|
66
|
+
function mockResultForEachSelection(overrides: Partial<ResultDetail> = {}) {
|
|
67
|
+
vi.mocked(api.fetchSelfHealingResult).mockImplementation(async (_context, selection) =>
|
|
68
|
+
result({ ...overrides, resultId: selection.id }),
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function changeSelection(
|
|
73
|
+
view: ReturnType<typeof renderComponent>,
|
|
74
|
+
selection: typeof resultSelection,
|
|
75
|
+
) {
|
|
76
|
+
selectedId = selection.id;
|
|
77
|
+
await view.rerender({ selection });
|
|
78
|
+
await waitAllPromises();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
beforeEach(() => {
|
|
82
|
+
vi.resetAllMocks();
|
|
83
|
+
selectedId = resultSelection.id;
|
|
84
|
+
resolveChatProject.mockResolvedValue('personal-project');
|
|
85
|
+
startChat.mockResolvedValue('started');
|
|
86
|
+
chatAvailable.value = true;
|
|
87
|
+
capabilityRegistry.provide(capabilities.createSelfHealingChatHandoff, () => ({
|
|
88
|
+
available: computed(() => chatAvailable.value),
|
|
89
|
+
resolveProjectId: resolveChatProject,
|
|
90
|
+
start: startChat,
|
|
91
|
+
}));
|
|
92
|
+
vi.mocked(api.fetchSelfHealingResult).mockResolvedValue(result());
|
|
93
|
+
vi.mocked(api.fetchResultWorkflow).mockResolvedValue({
|
|
94
|
+
name: 'Daily report',
|
|
95
|
+
scopes: ['workflow:read', 'workflow:update', 'workflow:publish'],
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
afterEach(() => capabilityRegistry.clear());
|
|
99
|
+
|
|
100
|
+
it.each([
|
|
101
|
+
{ outcome: 'fix_ready', destination: 'editor', withSuggestion: true },
|
|
102
|
+
{ outcome: 'fix_ready', destination: 'chat', withSuggestion: true },
|
|
103
|
+
{ outcome: 'needs_you', destination: 'chat', withSuggestion: true },
|
|
104
|
+
{ outcome: 'needs_you', destination: 'chat', withSuggestion: false },
|
|
105
|
+
{ outcome: 'needs_you', destination: 'editor', withSuggestion: true },
|
|
106
|
+
{ outcome: 'needs_you', destination: 'editor', withSuggestion: false },
|
|
107
|
+
] as const)(
|
|
108
|
+
'closes $outcome with suggestion=$withSuggestion before opening $destination with one request',
|
|
109
|
+
async ({ outcome, destination, withSuggestion }) => {
|
|
110
|
+
const detail = result({
|
|
111
|
+
outcome,
|
|
112
|
+
suggestion: withSuggestion
|
|
113
|
+
? suggestion({ resultKind: outcome === 'fix_ready' ? 'fix_ready' : 'needs_you' })
|
|
114
|
+
: null,
|
|
115
|
+
});
|
|
116
|
+
const closedResult = result({
|
|
117
|
+
...detail,
|
|
118
|
+
reviewState: withSuggestion ? 'applied' : 'dismissed',
|
|
119
|
+
});
|
|
120
|
+
vi.mocked(api.fetchSelfHealingResult).mockResolvedValue(detail);
|
|
121
|
+
vi.mocked(api.reviewSelfHealingResult).mockResolvedValue(closedResult);
|
|
122
|
+
|
|
123
|
+
const navigate = destination === 'chat' ? startChat : push;
|
|
124
|
+
let itemChangeCountAtNavigation = -1;
|
|
125
|
+
navigate.mockImplementation(async () => {
|
|
126
|
+
itemChangeCountAtNavigation = onItemChange.mock.calls.length;
|
|
127
|
+
return destination === 'chat' ? 'started' : undefined;
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
const view = renderComponent();
|
|
131
|
+
await waitAllPromises();
|
|
132
|
+
|
|
133
|
+
view.getByTestId(destination).click();
|
|
134
|
+
await waitAllPromises();
|
|
135
|
+
|
|
136
|
+
expect(api.reviewSelfHealingResult).toHaveBeenCalledExactlyOnceWith(
|
|
137
|
+
useRootStore().restApiContext,
|
|
138
|
+
resultSelection,
|
|
139
|
+
withSuggestion ? 'apply' : 'dismiss',
|
|
140
|
+
);
|
|
141
|
+
expect(api.fetchSelfHealingResult).toHaveBeenCalledOnce();
|
|
142
|
+
expect(itemChangeCountAtNavigation).toBe(0);
|
|
143
|
+
expect(onItemChange).toHaveBeenCalledExactlyOnceWith(
|
|
144
|
+
expect.objectContaining({
|
|
145
|
+
type: 'self_healing_result',
|
|
146
|
+
id: resultSelection.id,
|
|
147
|
+
state: 'closed',
|
|
148
|
+
}),
|
|
149
|
+
);
|
|
150
|
+
if (destination === 'chat') {
|
|
151
|
+
expect(resolveChatProject.mock.invocationCallOrder[0]).toBeLessThan(
|
|
152
|
+
vi.mocked(api.reviewSelfHealingResult).mock.invocationCallOrder[0],
|
|
153
|
+
);
|
|
154
|
+
expect(startChat).toHaveBeenCalledExactlyOnceWith('personal-project', closedResult);
|
|
155
|
+
expect(push).not.toHaveBeenCalled();
|
|
156
|
+
} else {
|
|
157
|
+
expect(push).toHaveBeenCalledExactlyOnceWith({
|
|
158
|
+
name: 'NodeViewExisting',
|
|
159
|
+
params: { workflowId: resultSelection.workflowId },
|
|
160
|
+
});
|
|
161
|
+
expect(startChat).not.toHaveBeenCalled();
|
|
162
|
+
}
|
|
163
|
+
},
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
it.each(['editor', 'chat'] as const)(
|
|
167
|
+
'waits for %s navigation before updating the inbox tab',
|
|
168
|
+
async (destination) => {
|
|
169
|
+
vi.mocked(api.reviewSelfHealingResult).mockResolvedValue(appliedResult());
|
|
170
|
+
const navigation = createDeferredPromise<'started' | undefined>();
|
|
171
|
+
const navigate = destination === 'chat' ? startChat : push;
|
|
172
|
+
navigate.mockReturnValueOnce(navigation.promise);
|
|
173
|
+
const view = renderComponent();
|
|
174
|
+
await waitAllPromises();
|
|
175
|
+
|
|
176
|
+
view.getByTestId(destination).click();
|
|
177
|
+
await waitAllPromises();
|
|
178
|
+
|
|
179
|
+
expect(navigate).toHaveBeenCalledOnce();
|
|
180
|
+
expect(onItemChange).not.toHaveBeenCalled();
|
|
181
|
+
expect(view.getByTestId('result-content')).toHaveAttribute('data-pending', destination);
|
|
182
|
+
|
|
183
|
+
navigation.resolve(destination === 'chat' ? 'started' : undefined);
|
|
184
|
+
await waitAllPromises();
|
|
185
|
+
|
|
186
|
+
expect(onItemChange).toHaveBeenCalledOnce();
|
|
187
|
+
expect(view.getByTestId('result-content')).not.toHaveAttribute('data-pending');
|
|
188
|
+
},
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
it.each([true, false])(
|
|
192
|
+
'keeps the result open when the chat project cannot load with suggestion=%s',
|
|
193
|
+
async (withSuggestion) => {
|
|
194
|
+
vi.mocked(api.fetchSelfHealingResult).mockResolvedValue(
|
|
195
|
+
result({
|
|
196
|
+
outcome: 'needs_you',
|
|
197
|
+
suggestion: withSuggestion ? suggestion({ resultKind: 'needs_you' }) : null,
|
|
198
|
+
}),
|
|
199
|
+
);
|
|
200
|
+
resolveChatProject.mockResolvedValueOnce(null);
|
|
201
|
+
const view = renderComponent();
|
|
202
|
+
await waitAllPromises();
|
|
203
|
+
view.getByTestId('chat').click();
|
|
204
|
+
await waitAllPromises();
|
|
205
|
+
|
|
206
|
+
expect(api.reviewSelfHealingResult).not.toHaveBeenCalled();
|
|
207
|
+
expect(view.getByTestId('result-content')).toHaveAttribute('data-state', 'open');
|
|
208
|
+
expect(showMessage).toHaveBeenCalledExactlyOnceWith(
|
|
209
|
+
expect.objectContaining({ type: 'error', title: "Couldn't open chat" }),
|
|
210
|
+
);
|
|
211
|
+
expect(onItemChange).not.toHaveBeenCalled();
|
|
212
|
+
expect(startChat).not.toHaveBeenCalled();
|
|
213
|
+
|
|
214
|
+
vi.mocked(api.reviewSelfHealingResult).mockResolvedValue(
|
|
215
|
+
result({ outcome: 'needs_you', reviewState: withSuggestion ? 'applied' : 'dismissed' }),
|
|
216
|
+
);
|
|
217
|
+
view.getByTestId('chat').click();
|
|
218
|
+
await waitAllPromises();
|
|
219
|
+
expect(api.reviewSelfHealingResult).toHaveBeenCalledOnce();
|
|
220
|
+
expect(startChat).toHaveBeenCalledOnce();
|
|
221
|
+
},
|
|
222
|
+
);
|
|
223
|
+
|
|
224
|
+
it('does not close the result when the selection changes while the chat project loads', async () => {
|
|
225
|
+
const projectLookup = createDeferredPromise<string | null>();
|
|
226
|
+
resolveChatProject.mockReturnValue(projectLookup.promise);
|
|
227
|
+
vi.mocked(api.fetchSelfHealingResult).mockResolvedValue(result({ outcome: 'needs_you' }));
|
|
228
|
+
const view = renderComponent();
|
|
229
|
+
await waitAllPromises();
|
|
230
|
+
view.getByTestId('chat').click();
|
|
231
|
+
selectedId = null;
|
|
232
|
+
projectLookup.resolve('personal-project');
|
|
233
|
+
await waitAllPromises();
|
|
234
|
+
|
|
235
|
+
expect(api.reviewSelfHealingResult).not.toHaveBeenCalled();
|
|
236
|
+
expect(startChat).not.toHaveBeenCalled();
|
|
237
|
+
expect(showMessage).not.toHaveBeenCalled();
|
|
238
|
+
expect(onItemChange).not.toHaveBeenCalled();
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it.each([
|
|
242
|
+
{ withSuggestion: true, reviewState: 'applied' },
|
|
243
|
+
{ withSuggestion: false, reviewState: 'dismissed' },
|
|
244
|
+
] as const)(
|
|
245
|
+
'does not start chat when another reviewer already closed the result as $reviewState',
|
|
246
|
+
async ({ withSuggestion, reviewState }) => {
|
|
247
|
+
const detail = result({
|
|
248
|
+
outcome: 'needs_you',
|
|
249
|
+
suggestion: withSuggestion ? suggestion({ resultKind: 'needs_you' }) : null,
|
|
250
|
+
});
|
|
251
|
+
vi.mocked(api.fetchSelfHealingResult).mockResolvedValue(detail);
|
|
252
|
+
vi.mocked(api.reviewSelfHealingResult).mockRejectedValue(
|
|
253
|
+
new ResponseError('This result is already closed.', { httpStatusCode: 409 }),
|
|
254
|
+
);
|
|
255
|
+
const view = renderComponent();
|
|
256
|
+
await waitAllPromises();
|
|
257
|
+
vi.mocked(api.fetchSelfHealingResult).mockResolvedValue(result({ ...detail, reviewState }));
|
|
258
|
+
view.getByTestId('chat').click();
|
|
259
|
+
await waitAllPromises();
|
|
260
|
+
|
|
261
|
+
expect(view.getByTestId('result-content')).toHaveAttribute('data-state', reviewState);
|
|
262
|
+
expect(onItemChange).toHaveBeenCalledOnce();
|
|
263
|
+
expect(showError).toHaveBeenCalledOnce();
|
|
264
|
+
expect(startChat).not.toHaveBeenCalled();
|
|
265
|
+
expect(push).not.toHaveBeenCalled();
|
|
266
|
+
},
|
|
267
|
+
);
|
|
268
|
+
|
|
269
|
+
it.each(['editor', 'chat'] as const)(
|
|
270
|
+
'does not continue to %s when Apply finds an outdated suggestion',
|
|
271
|
+
async (destination) => {
|
|
272
|
+
vi.mocked(api.reviewSelfHealingResult).mockResolvedValue(result({ reviewState: 'outdated' }));
|
|
273
|
+
const view = renderComponent();
|
|
274
|
+
await waitAllPromises();
|
|
275
|
+
view.getByTestId(destination).click();
|
|
276
|
+
await waitAllPromises();
|
|
277
|
+
|
|
278
|
+
expect(api.reviewSelfHealingResult).toHaveBeenCalledOnce();
|
|
279
|
+
expect(onItemChange).toHaveBeenCalledOnce();
|
|
280
|
+
expect(startChat).not.toHaveBeenCalled();
|
|
281
|
+
expect(push).not.toHaveBeenCalled();
|
|
282
|
+
},
|
|
283
|
+
);
|
|
284
|
+
|
|
285
|
+
it.each(['applied', 'outdated', 'discarded', 'dismissed', 'closed'] as const)(
|
|
286
|
+
'ignores every action on a %s result',
|
|
287
|
+
async (reviewState) => {
|
|
288
|
+
vi.mocked(api.fetchSelfHealingResult).mockResolvedValue(result({ reviewState }));
|
|
289
|
+
const view = renderComponent();
|
|
290
|
+
await waitAllPromises();
|
|
291
|
+
for (const action of ['editor', 'chat', 'approve-and-publish', 'dismiss']) {
|
|
292
|
+
view.getByTestId(action).click();
|
|
293
|
+
}
|
|
294
|
+
await waitAllPromises();
|
|
295
|
+
|
|
296
|
+
expect(api.reviewSelfHealingResult).not.toHaveBeenCalled();
|
|
297
|
+
expect(onItemChange).not.toHaveBeenCalled();
|
|
298
|
+
expect(startChat).not.toHaveBeenCalled();
|
|
299
|
+
expect(push).not.toHaveBeenCalled();
|
|
300
|
+
},
|
|
301
|
+
);
|
|
302
|
+
|
|
303
|
+
it.each([new Error('Disconnected'), new ResponseError('Bad gateway', { httpStatusCode: 502 })])(
|
|
304
|
+
'opens the editor when a lost Apply response is followed by applied state (%s)',
|
|
305
|
+
async (cause) => {
|
|
306
|
+
vi.mocked(api.reviewSelfHealingResult).mockRejectedValue(cause);
|
|
307
|
+
const view = renderComponent();
|
|
308
|
+
await waitAllPromises();
|
|
309
|
+
vi.mocked(api.fetchSelfHealingResult).mockResolvedValue(appliedResult());
|
|
310
|
+
view.getByTestId('editor').click();
|
|
311
|
+
await waitAllPromises();
|
|
312
|
+
|
|
313
|
+
expect(api.reviewSelfHealingResult).toHaveBeenCalledOnce();
|
|
314
|
+
expect(onItemChange).toHaveBeenCalledOnce();
|
|
315
|
+
expect(push).toHaveBeenCalledExactlyOnceWith({
|
|
316
|
+
name: 'NodeViewExisting',
|
|
317
|
+
params: { workflowId: resultSelection.workflowId },
|
|
318
|
+
});
|
|
319
|
+
expect(showError).not.toHaveBeenCalled();
|
|
320
|
+
view.getByTestId('editor').click();
|
|
321
|
+
await waitAllPromises();
|
|
322
|
+
expect(api.reviewSelfHealingResult).toHaveBeenCalledOnce();
|
|
323
|
+
expect(push).toHaveBeenCalledOnce();
|
|
324
|
+
},
|
|
325
|
+
);
|
|
326
|
+
|
|
327
|
+
it('offers chat without starting it when a lost Apply response is followed by applied state', async () => {
|
|
328
|
+
vi.mocked(api.fetchSelfHealingResult).mockResolvedValue(result({ outcome: 'needs_you' }));
|
|
329
|
+
vi.mocked(api.reviewSelfHealingResult).mockRejectedValue(new Error('Disconnected'));
|
|
330
|
+
const view = renderComponent();
|
|
331
|
+
await waitAllPromises();
|
|
332
|
+
const applied = appliedResult({ outcome: 'needs_you' });
|
|
333
|
+
vi.mocked(api.fetchSelfHealingResult).mockResolvedValue(applied);
|
|
334
|
+
view.getByTestId('chat').click();
|
|
335
|
+
await waitAllPromises();
|
|
336
|
+
|
|
337
|
+
expect(onItemChange).toHaveBeenCalledOnce();
|
|
338
|
+
expect(startChat).not.toHaveBeenCalled();
|
|
339
|
+
expect(push).not.toHaveBeenCalled();
|
|
340
|
+
expect(view.getByTestId('self-healing-chat-retry')).toHaveTextContent('The response was lost.');
|
|
341
|
+
|
|
342
|
+
view.getByTestId('self-healing-chat-retry-button').click();
|
|
343
|
+
await waitAllPromises();
|
|
344
|
+
expect(startChat).toHaveBeenCalledExactlyOnceWith('personal-project', applied);
|
|
345
|
+
expect(resolveChatProject).toHaveBeenCalledOnce();
|
|
346
|
+
expect(api.reviewSelfHealingResult).toHaveBeenCalledOnce();
|
|
347
|
+
expect(view.queryByTestId('self-healing-chat-retry')).not.toBeInTheDocument();
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
it.each(['open', 'outdated', 'discarded'] as const)(
|
|
351
|
+
'does not continue when a lost Apply response is followed by %s state',
|
|
352
|
+
async (reviewState) => {
|
|
353
|
+
vi.mocked(api.reviewSelfHealingResult).mockRejectedValue(new Error('Disconnected'));
|
|
354
|
+
const view = renderComponent();
|
|
355
|
+
await waitAllPromises();
|
|
356
|
+
vi.mocked(api.fetchSelfHealingResult).mockResolvedValue(result({ reviewState }));
|
|
357
|
+
view.getByTestId('editor').click();
|
|
358
|
+
await waitAllPromises();
|
|
359
|
+
|
|
360
|
+
expect(view.getByTestId('result-content')).toHaveAttribute('data-state', reviewState);
|
|
361
|
+
expect(showError).toHaveBeenCalledOnce();
|
|
362
|
+
expect(push).not.toHaveBeenCalled();
|
|
363
|
+
expect(onItemChange).toHaveBeenCalledTimes(reviewState === 'open' ? 0 : 1);
|
|
364
|
+
},
|
|
365
|
+
);
|
|
366
|
+
|
|
367
|
+
it.each(['editor', 'approve-and-publish'] as const)(
|
|
368
|
+
'does not open the editor when %s is rejected because another reviewer applied the result',
|
|
369
|
+
async (action) => {
|
|
370
|
+
vi.mocked(api.reviewSelfHealingResult).mockRejectedValue(
|
|
371
|
+
new ResponseError('This result is already closed.', { httpStatusCode: 409 }),
|
|
372
|
+
);
|
|
373
|
+
const view = renderComponent();
|
|
374
|
+
await waitAllPromises();
|
|
375
|
+
vi.mocked(api.fetchSelfHealingResult).mockResolvedValue(appliedResult());
|
|
376
|
+
view.getByTestId(action).click();
|
|
377
|
+
await waitAllPromises();
|
|
378
|
+
|
|
379
|
+
expect(view.getByTestId('result-content')).toHaveAttribute('data-state', 'applied');
|
|
380
|
+
expect(onItemChange).toHaveBeenCalledOnce();
|
|
381
|
+
expect(showError).toHaveBeenCalledOnce();
|
|
382
|
+
expect(showMessage).not.toHaveBeenCalled();
|
|
383
|
+
expect(push).not.toHaveBeenCalled();
|
|
384
|
+
},
|
|
385
|
+
);
|
|
386
|
+
|
|
387
|
+
it('does not open the editor for a lost response after the selection changes', async () => {
|
|
388
|
+
vi.mocked(api.reviewSelfHealingResult).mockRejectedValue(new Error('Disconnected'));
|
|
389
|
+
const view = renderComponent();
|
|
390
|
+
await waitAllPromises();
|
|
391
|
+
const refresh = createDeferredPromise<ResultDetail>();
|
|
392
|
+
vi.mocked(api.fetchSelfHealingResult).mockReturnValueOnce(refresh.promise);
|
|
393
|
+
view.getByTestId('editor').click();
|
|
394
|
+
await waitAllPromises();
|
|
395
|
+
selectedId = null;
|
|
396
|
+
refresh.resolve(appliedResult());
|
|
397
|
+
await waitAllPromises();
|
|
398
|
+
|
|
399
|
+
expect(onItemChange).toHaveBeenCalledOnce();
|
|
400
|
+
expect(push).not.toHaveBeenCalled();
|
|
401
|
+
expect(showError).not.toHaveBeenCalled();
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
it('continues to the editor after a later refresh confirms a lost Apply response', async () => {
|
|
405
|
+
vi.mocked(api.reviewSelfHealingResult).mockRejectedValue(new Error('Disconnected'));
|
|
406
|
+
const view = renderComponent();
|
|
407
|
+
await waitAllPromises();
|
|
408
|
+
vi.mocked(api.fetchSelfHealingResult).mockRejectedValueOnce(new Error('Offline'));
|
|
409
|
+
view.getByTestId('editor').click();
|
|
410
|
+
await waitAllPromises();
|
|
411
|
+
expect(view.queryByTestId('result-content')).not.toBeInTheDocument();
|
|
412
|
+
expect(showError).toHaveBeenCalledOnce();
|
|
413
|
+
expect(push).not.toHaveBeenCalled();
|
|
414
|
+
vi.mocked(api.fetchSelfHealingResult).mockResolvedValue(appliedResult());
|
|
415
|
+
view.getByRole('button', { name: 'Retry' }).click();
|
|
416
|
+
await waitAllPromises();
|
|
417
|
+
|
|
418
|
+
expect(onItemChange).toHaveBeenCalledOnce();
|
|
419
|
+
expect(push).toHaveBeenCalledOnce();
|
|
420
|
+
expect(api.reviewSelfHealingResult).toHaveBeenCalledOnce();
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
it('does not continue after a later refresh when the server rejected the action', async () => {
|
|
424
|
+
vi.mocked(api.reviewSelfHealingResult).mockRejectedValue(
|
|
425
|
+
new ResponseError('This result is already closed.', { httpStatusCode: 409 }),
|
|
426
|
+
);
|
|
427
|
+
const view = renderComponent();
|
|
428
|
+
await waitAllPromises();
|
|
429
|
+
vi.mocked(api.fetchSelfHealingResult).mockRejectedValueOnce(new Error('Offline'));
|
|
430
|
+
view.getByTestId('editor').click();
|
|
431
|
+
await waitAllPromises();
|
|
432
|
+
vi.mocked(api.fetchSelfHealingResult).mockResolvedValue(appliedResult());
|
|
433
|
+
view.getByRole('button', { name: 'Retry' }).click();
|
|
434
|
+
await waitAllPromises();
|
|
435
|
+
|
|
436
|
+
expect(onItemChange).toHaveBeenCalledOnce();
|
|
437
|
+
expect(push).not.toHaveBeenCalled();
|
|
438
|
+
expect(api.reviewSelfHealingResult).toHaveBeenCalledOnce();
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
it.each(['open', 'outdated'] as const)(
|
|
442
|
+
'does not navigate when Apply fails and the refresh returns %s',
|
|
443
|
+
async (reviewState) => {
|
|
444
|
+
vi.mocked(api.reviewSelfHealingResult).mockRejectedValue(
|
|
445
|
+
new ResponseError('Cannot apply changes', { httpStatusCode: 409 }),
|
|
446
|
+
);
|
|
447
|
+
const view = renderComponent();
|
|
448
|
+
await waitAllPromises();
|
|
449
|
+
vi.mocked(api.fetchSelfHealingResult).mockResolvedValue(result({ reviewState }));
|
|
450
|
+
view.getByTestId('editor').click();
|
|
451
|
+
await waitAllPromises();
|
|
452
|
+
|
|
453
|
+
expect(view.getByTestId('result-content')).toHaveAttribute('data-state', reviewState);
|
|
454
|
+
expect(showError).toHaveBeenCalledOnce();
|
|
455
|
+
expect(push).not.toHaveBeenCalled();
|
|
456
|
+
expect(onItemChange).toHaveBeenCalledTimes(reviewState === 'open' ? 0 : 1);
|
|
457
|
+
},
|
|
458
|
+
);
|
|
459
|
+
|
|
460
|
+
it.each(['editor', 'chat'] as const)(
|
|
461
|
+
'retains closure when %s navigation fails without repeating the action',
|
|
462
|
+
async (destination) => {
|
|
463
|
+
const outcome = destination === 'editor' ? 'fix_ready' : 'needs_you';
|
|
464
|
+
vi.mocked(api.fetchSelfHealingResult).mockResolvedValue(result({ outcome }));
|
|
465
|
+
vi.mocked(api.reviewSelfHealingResult).mockResolvedValue(
|
|
466
|
+
appliedResult({
|
|
467
|
+
outcome,
|
|
468
|
+
}),
|
|
469
|
+
);
|
|
470
|
+
const navigate = destination === 'chat' ? startChat : push;
|
|
471
|
+
navigate.mockRejectedValueOnce(new Error('Navigation failed'));
|
|
472
|
+
const view = renderComponent();
|
|
473
|
+
await waitAllPromises();
|
|
474
|
+
view.getByTestId(destination).click();
|
|
475
|
+
await waitAllPromises();
|
|
476
|
+
|
|
477
|
+
expect(useSelfHealingResultStore().detail?.reviewState).toBe('applied');
|
|
478
|
+
expect(onItemChange).toHaveBeenCalledOnce();
|
|
479
|
+
expect(api.fetchSelfHealingResult).toHaveBeenCalledOnce();
|
|
480
|
+
expect(showError).toHaveBeenCalledOnce();
|
|
481
|
+
view.getByTestId(destination).click();
|
|
482
|
+
await waitAllPromises();
|
|
483
|
+
expect(navigate).toHaveBeenCalledOnce();
|
|
484
|
+
expect(onItemChange).toHaveBeenCalledOnce();
|
|
485
|
+
expect(api.reviewSelfHealingResult).toHaveBeenCalledOnce();
|
|
486
|
+
},
|
|
487
|
+
);
|
|
488
|
+
|
|
489
|
+
it('reconciles the result after leaving the detail without navigating away from the new page', async () => {
|
|
490
|
+
const action = createDeferredPromise<ResultDetail>();
|
|
491
|
+
vi.mocked(api.reviewSelfHealingResult).mockReturnValue(action.promise);
|
|
492
|
+
const view = renderComponent();
|
|
493
|
+
await waitAllPromises();
|
|
494
|
+
view.getByTestId('editor').click();
|
|
495
|
+
view.getByTestId('approve-and-publish').click();
|
|
496
|
+
selectedId = null;
|
|
497
|
+
view.unmount();
|
|
498
|
+
action.resolve(appliedResult());
|
|
499
|
+
await waitAllPromises();
|
|
500
|
+
|
|
501
|
+
expect(onItemChange).toHaveBeenCalledOnce();
|
|
502
|
+
expect(api.reviewSelfHealingResult).toHaveBeenCalledOnce();
|
|
503
|
+
expect(push).not.toHaveBeenCalled();
|
|
504
|
+
expect(startChat).not.toHaveBeenCalled();
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
it('clears the report without navigating when edit access is lost', async () => {
|
|
508
|
+
const forbidden = new ResponseError('Forbidden', { httpStatusCode: 403 });
|
|
509
|
+
vi.mocked(api.fetchSelfHealingResult).mockResolvedValue(result({ outcome: 'needs_you' }));
|
|
510
|
+
vi.mocked(api.reviewSelfHealingResult).mockRejectedValue(forbidden);
|
|
511
|
+
const view = renderComponent();
|
|
512
|
+
await waitAllPromises();
|
|
513
|
+
vi.mocked(api.fetchSelfHealingResult).mockRejectedValue(forbidden);
|
|
514
|
+
view.getByTestId('chat').click();
|
|
515
|
+
await waitAllPromises();
|
|
516
|
+
|
|
517
|
+
expect(view.queryByTestId('result-content')).not.toBeInTheDocument();
|
|
518
|
+
expect(startChat).not.toHaveBeenCalled();
|
|
519
|
+
expect(onItemChange).not.toHaveBeenCalled();
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
it('does not request chat when the shell is unavailable', async () => {
|
|
523
|
+
capabilityRegistry.clear();
|
|
524
|
+
vi.mocked(api.fetchSelfHealingResult).mockResolvedValue(result({ outcome: 'needs_you' }));
|
|
525
|
+
const view = renderComponent();
|
|
526
|
+
await waitAllPromises();
|
|
527
|
+
view.getByTestId('chat').click();
|
|
528
|
+
await waitAllPromises();
|
|
529
|
+
expect(api.reviewSelfHealingResult).not.toHaveBeenCalled();
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
it('retains applied state and opens the editor when publication returns an error', async () => {
|
|
533
|
+
vi.mocked(api.reviewSelfHealingResult).mockResolvedValue({
|
|
534
|
+
...result({ reviewState: 'applied' }),
|
|
535
|
+
publishError: 'Review is required',
|
|
536
|
+
});
|
|
537
|
+
const view = renderComponent();
|
|
538
|
+
await waitAllPromises();
|
|
539
|
+
view.getByTestId('approve-and-publish').click();
|
|
540
|
+
await waitAllPromises();
|
|
541
|
+
|
|
542
|
+
expect(api.reviewSelfHealingResult).toHaveBeenCalledExactlyOnceWith(
|
|
543
|
+
useRootStore().restApiContext,
|
|
544
|
+
resultSelection,
|
|
545
|
+
'approve-and-publish',
|
|
546
|
+
);
|
|
547
|
+
expect(useSelfHealingResultStore().detail?.reviewState).toBe('applied');
|
|
548
|
+
expect(showMessage).toHaveBeenCalledWith(
|
|
549
|
+
expect.objectContaining({ type: 'warning', message: 'Review is required' }),
|
|
550
|
+
);
|
|
551
|
+
expect(push).toHaveBeenCalledOnce();
|
|
552
|
+
view.getByTestId('approve-and-publish').click();
|
|
553
|
+
await waitAllPromises();
|
|
554
|
+
expect(api.reviewSelfHealingResult).toHaveBeenCalledOnce();
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
it('opens the editor with an unknown publication status after a lost approval response', async () => {
|
|
558
|
+
vi.mocked(api.reviewSelfHealingResult).mockRejectedValue(new Error('Disconnected'));
|
|
559
|
+
const view = renderComponent();
|
|
560
|
+
await waitAllPromises();
|
|
561
|
+
vi.mocked(api.fetchSelfHealingResult).mockResolvedValue(result({ reviewState: 'applied' }));
|
|
562
|
+
view.getByTestId('approve-and-publish').click();
|
|
563
|
+
await waitAllPromises();
|
|
564
|
+
|
|
565
|
+
expect(onItemChange).toHaveBeenCalledOnce();
|
|
566
|
+
expect(push).toHaveBeenCalledOnce();
|
|
567
|
+
expect(showMessage).toHaveBeenCalledExactlyOnceWith(
|
|
568
|
+
expect.objectContaining({ type: 'warning', title: "Couldn't confirm publication" }),
|
|
569
|
+
);
|
|
570
|
+
expect(showError).not.toHaveBeenCalled();
|
|
571
|
+
view.getByTestId('approve-and-publish').click();
|
|
572
|
+
await waitAllPromises();
|
|
573
|
+
expect(api.reviewSelfHealingResult).toHaveBeenCalledOnce();
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
it.each(['discarded', 'outdated'] as const)(
|
|
577
|
+
'does not navigate after a competing %s closure during approval',
|
|
578
|
+
async (reviewState) => {
|
|
579
|
+
vi.mocked(api.reviewSelfHealingResult).mockResolvedValue(result({ reviewState }));
|
|
580
|
+
const view = renderComponent();
|
|
581
|
+
await waitAllPromises();
|
|
582
|
+
view.getByTestId('approve-and-publish').click();
|
|
583
|
+
await waitAllPromises();
|
|
584
|
+
|
|
585
|
+
expect(onItemChange).toHaveBeenCalledOnce();
|
|
586
|
+
expect(push).not.toHaveBeenCalled();
|
|
587
|
+
},
|
|
588
|
+
);
|
|
589
|
+
|
|
590
|
+
it.each([
|
|
591
|
+
{ outcome: 'fix_ready', scopes: ['workflow:read', 'workflow:update'] },
|
|
592
|
+
{ outcome: 'needs_you', scopes: ['workflow:read', 'workflow:update', 'workflow:publish'] },
|
|
593
|
+
{ outcome: 'could_not_fix', scopes: ['workflow:read', 'workflow:update', 'workflow:publish'] },
|
|
594
|
+
] as const)(
|
|
595
|
+
'prevents publication when the result or permissions do not allow it',
|
|
596
|
+
async ({ outcome, scopes }) => {
|
|
597
|
+
vi.mocked(api.fetchResultWorkflow).mockResolvedValue({
|
|
598
|
+
name: 'Daily report',
|
|
599
|
+
scopes: [...scopes],
|
|
600
|
+
});
|
|
601
|
+
vi.mocked(api.fetchSelfHealingResult).mockResolvedValue(result({ outcome }));
|
|
602
|
+
const view = renderComponent();
|
|
603
|
+
await waitAllPromises();
|
|
604
|
+
view.getByTestId('approve-and-publish').click();
|
|
605
|
+
await waitAllPromises();
|
|
606
|
+
expect(api.reviewSelfHealingResult).not.toHaveBeenCalled();
|
|
607
|
+
},
|
|
608
|
+
);
|
|
609
|
+
|
|
610
|
+
it('treats publish access as unknown until workflow permissions load', async () => {
|
|
611
|
+
vi.mocked(api.fetchResultWorkflow).mockRejectedValueOnce(new Error('Offline'));
|
|
612
|
+
const view = renderComponent();
|
|
613
|
+
await waitAllPromises();
|
|
614
|
+
|
|
615
|
+
expect(view.getByTestId('result-content')).toHaveAttribute('data-publish-access', 'unknown');
|
|
616
|
+
view.getByTestId('approve-and-publish').click();
|
|
617
|
+
await waitAllPromises();
|
|
618
|
+
expect(api.reviewSelfHealingResult).not.toHaveBeenCalled();
|
|
619
|
+
|
|
620
|
+
view.getByRole('button', { name: 'Retry' }).click();
|
|
621
|
+
await waitAllPromises();
|
|
622
|
+
expect(view.getByTestId('result-content')).toHaveAttribute('data-publish-access', 'allowed');
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
const dismissals = [
|
|
626
|
+
{ outcome: 'fix_ready', reviewState: 'discarded' },
|
|
627
|
+
{ outcome: 'needs_you', reviewState: 'dismissed' },
|
|
628
|
+
] as const;
|
|
629
|
+
|
|
630
|
+
it.each(dismissals)(
|
|
631
|
+
'refreshes a $outcome result as $reviewState after a lost dismissal response',
|
|
632
|
+
async ({ outcome, reviewState }) => {
|
|
633
|
+
vi.mocked(api.reviewSelfHealingResult).mockRejectedValue(new Error('Disconnected'));
|
|
634
|
+
vi.mocked(api.fetchSelfHealingResult).mockResolvedValue(result({ outcome }));
|
|
635
|
+
const view = renderComponent();
|
|
636
|
+
await waitAllPromises();
|
|
637
|
+
vi.mocked(api.fetchSelfHealingResult).mockResolvedValue(result({ outcome, reviewState }));
|
|
638
|
+
view.getByTestId('dismiss').click();
|
|
639
|
+
await waitAllPromises();
|
|
640
|
+
|
|
641
|
+
expect(view.getByTestId('result-content')).toHaveAttribute('data-state', reviewState);
|
|
642
|
+
expect(api.reviewSelfHealingResult).toHaveBeenCalledOnce();
|
|
643
|
+
expect(onItemChange).toHaveBeenCalledOnce();
|
|
644
|
+
expect(showError).not.toHaveBeenCalled();
|
|
645
|
+
expect(startChat).not.toHaveBeenCalled();
|
|
646
|
+
expect(push).not.toHaveBeenCalled();
|
|
647
|
+
},
|
|
648
|
+
);
|
|
649
|
+
|
|
650
|
+
it.each(dismissals)(
|
|
651
|
+
'dismisses a $outcome result once as $reviewState without opening the editor or chat',
|
|
652
|
+
async ({ outcome, reviewState }) => {
|
|
653
|
+
vi.mocked(api.fetchSelfHealingResult).mockResolvedValue(result({ outcome }));
|
|
654
|
+
vi.mocked(api.reviewSelfHealingResult).mockResolvedValue(result({ outcome, reviewState }));
|
|
655
|
+
const view = renderComponent();
|
|
656
|
+
await waitAllPromises();
|
|
657
|
+
view.getByTestId('dismiss').click();
|
|
658
|
+
await waitAllPromises();
|
|
659
|
+
|
|
660
|
+
expect(api.reviewSelfHealingResult).toHaveBeenCalledExactlyOnceWith(
|
|
661
|
+
useRootStore().restApiContext,
|
|
662
|
+
resultSelection,
|
|
663
|
+
'dismiss',
|
|
664
|
+
);
|
|
665
|
+
expect(view.getByTestId('result-content')).toHaveAttribute('data-state', reviewState);
|
|
666
|
+
expect(onItemChange).toHaveBeenCalledExactlyOnceWith(
|
|
667
|
+
expect.objectContaining({ id: resultSelection.id, state: 'closed' }),
|
|
668
|
+
);
|
|
669
|
+
expect(showError).not.toHaveBeenCalled();
|
|
670
|
+
expect(push).not.toHaveBeenCalled();
|
|
671
|
+
expect(startChat).not.toHaveBeenCalled();
|
|
672
|
+
expect(resolveChatProject).not.toHaveBeenCalled();
|
|
673
|
+
},
|
|
674
|
+
);
|
|
675
|
+
|
|
676
|
+
it.each(['busy', 'failed', 'unavailable'] as const)(
|
|
677
|
+
'keeps the closed result and offers a chat-only retry when the chat start is %s',
|
|
678
|
+
async (outcome) => {
|
|
679
|
+
vi.mocked(api.fetchSelfHealingResult).mockResolvedValue(result({ outcome: 'needs_you' }));
|
|
680
|
+
const applied = appliedResult({ outcome: 'needs_you' });
|
|
681
|
+
vi.mocked(api.reviewSelfHealingResult).mockResolvedValue(applied);
|
|
682
|
+
startChat.mockImplementationOnce(async () => {
|
|
683
|
+
if (outcome === 'unavailable') chatAvailable.value = false;
|
|
684
|
+
return outcome;
|
|
685
|
+
});
|
|
686
|
+
const view = renderComponent();
|
|
687
|
+
await waitAllPromises();
|
|
688
|
+
view.getByTestId('chat').click();
|
|
689
|
+
await waitAllPromises();
|
|
690
|
+
|
|
691
|
+
expect(onItemChange).toHaveBeenCalledOnce();
|
|
692
|
+
expect(view.getByTestId('self-healing-chat-retry')).toBeInTheDocument();
|
|
693
|
+
if (outcome === 'unavailable') {
|
|
694
|
+
expect(view.getByTestId('self-healing-chat-retry-button')).toBeDisabled();
|
|
695
|
+
view.getByTestId('self-healing-chat-retry-button').click();
|
|
696
|
+
await waitAllPromises();
|
|
697
|
+
expect(startChat).toHaveBeenCalledOnce();
|
|
698
|
+
chatAvailable.value = true;
|
|
699
|
+
await waitAllPromises();
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
startChat.mockResolvedValue('started');
|
|
703
|
+
view.getByTestId('self-healing-chat-retry-button').click();
|
|
704
|
+
await waitAllPromises();
|
|
705
|
+
|
|
706
|
+
expect(startChat).toHaveBeenCalledTimes(2);
|
|
707
|
+
expect(startChat).toHaveBeenLastCalledWith('personal-project', applied);
|
|
708
|
+
expect(resolveChatProject).toHaveBeenCalledOnce();
|
|
709
|
+
expect(api.reviewSelfHealingResult).toHaveBeenCalledOnce();
|
|
710
|
+
expect(onItemChange).toHaveBeenCalledOnce();
|
|
711
|
+
expect(view.queryByTestId('self-healing-chat-retry')).not.toBeInTheDocument();
|
|
712
|
+
},
|
|
713
|
+
);
|
|
714
|
+
|
|
715
|
+
it('drops the chat retry when the selection changes', async () => {
|
|
716
|
+
mockResultForEachSelection({ outcome: 'needs_you' });
|
|
717
|
+
vi.mocked(api.reviewSelfHealingResult).mockResolvedValue(appliedResult({ outcome: 'needs_you' }));
|
|
718
|
+
startChat.mockResolvedValueOnce('busy');
|
|
719
|
+
const view = renderComponent();
|
|
720
|
+
await waitAllPromises();
|
|
721
|
+
view.getByTestId('chat').click();
|
|
722
|
+
await waitAllPromises();
|
|
723
|
+
expect(view.getByTestId('self-healing-chat-retry')).toBeInTheDocument();
|
|
724
|
+
|
|
725
|
+
await changeSelection(view, secondSelection);
|
|
726
|
+
|
|
727
|
+
expect(view.queryByTestId('self-healing-chat-retry')).not.toBeInTheDocument();
|
|
728
|
+
expect(startChat).toHaveBeenCalledOnce();
|
|
729
|
+
});
|
|
730
|
+
|
|
731
|
+
it('does not offer a chat retry after the selection changes during the chat start', async () => {
|
|
732
|
+
const chatStart = createDeferredPromise<string>();
|
|
733
|
+
startChat.mockReturnValueOnce(chatStart.promise);
|
|
734
|
+
mockResultForEachSelection({ outcome: 'needs_you' });
|
|
735
|
+
vi.mocked(api.reviewSelfHealingResult).mockResolvedValue(appliedResult({ outcome: 'needs_you' }));
|
|
736
|
+
const view = renderComponent();
|
|
737
|
+
await waitAllPromises();
|
|
738
|
+
view.getByTestId('chat').click();
|
|
739
|
+
await waitAllPromises();
|
|
740
|
+
await changeSelection(view, secondSelection);
|
|
741
|
+
chatStart.resolve('busy');
|
|
742
|
+
await waitAllPromises();
|
|
743
|
+
|
|
744
|
+
expect(view.queryByTestId('self-healing-chat-retry')).not.toBeInTheDocument();
|
|
745
|
+
});
|
|
746
|
+
|
|
747
|
+
it('scopes a pending action to its result', async () => {
|
|
748
|
+
mockResultForEachSelection();
|
|
749
|
+
const firstAction = createDeferredPromise<SelfHealingResultActionResponse>();
|
|
750
|
+
const secondAction = createDeferredPromise<SelfHealingResultActionResponse>();
|
|
751
|
+
vi.mocked(api.reviewSelfHealingResult)
|
|
752
|
+
.mockReturnValueOnce(firstAction.promise)
|
|
753
|
+
.mockReturnValueOnce(secondAction.promise);
|
|
754
|
+
const view = renderComponent();
|
|
755
|
+
await waitAllPromises();
|
|
756
|
+
view.getByTestId('dismiss').click();
|
|
757
|
+
await waitAllPromises();
|
|
758
|
+
expect(view.getByTestId('result-content')).toHaveAttribute('data-pending', 'dismiss');
|
|
759
|
+
|
|
760
|
+
await changeSelection(view, secondSelection);
|
|
761
|
+
expect(view.getByTestId('result-content')).not.toHaveAttribute('data-pending');
|
|
762
|
+
view.getByTestId('dismiss').click();
|
|
763
|
+
await waitAllPromises();
|
|
764
|
+
expect(api.reviewSelfHealingResult).toHaveBeenLastCalledWith(
|
|
765
|
+
useRootStore().restApiContext,
|
|
766
|
+
secondSelection,
|
|
767
|
+
'dismiss',
|
|
768
|
+
);
|
|
769
|
+
|
|
770
|
+
firstAction.resolve(result({ reviewState: 'discarded' }));
|
|
771
|
+
await waitAllPromises();
|
|
772
|
+
expect(view.getByTestId('result-content')).toHaveAttribute('data-pending', 'dismiss');
|
|
773
|
+
|
|
774
|
+
await changeSelection(view, resultSelection);
|
|
775
|
+
expect(view.getByTestId('result-content')).not.toHaveAttribute('data-pending');
|
|
776
|
+
|
|
777
|
+
secondAction.resolve(result({ resultId: secondSelection.id, reviewState: 'discarded' }));
|
|
778
|
+
await waitAllPromises();
|
|
779
|
+
expect(api.reviewSelfHealingResult).toHaveBeenCalledTimes(2);
|
|
780
|
+
});
|
|
781
|
+
|
|
782
|
+
it('does not repeat an action that is still pending when its result is selected again', async () => {
|
|
783
|
+
mockResultForEachSelection();
|
|
784
|
+
const action = createDeferredPromise<SelfHealingResultActionResponse>();
|
|
785
|
+
vi.mocked(api.reviewSelfHealingResult).mockReturnValueOnce(action.promise);
|
|
786
|
+
const view = renderComponent();
|
|
787
|
+
await waitAllPromises();
|
|
788
|
+
view.getByTestId('editor').click();
|
|
789
|
+
await waitAllPromises();
|
|
790
|
+
|
|
791
|
+
await changeSelection(view, secondSelection);
|
|
792
|
+
await changeSelection(view, resultSelection);
|
|
793
|
+
|
|
794
|
+
expect(view.getByTestId('result-content')).toHaveAttribute('data-pending', 'editor');
|
|
795
|
+
view.getByTestId('editor').click();
|
|
796
|
+
view.getByTestId('dismiss').click();
|
|
797
|
+
await waitAllPromises();
|
|
798
|
+
expect(api.reviewSelfHealingResult).toHaveBeenCalledOnce();
|
|
799
|
+
|
|
800
|
+
action.resolve(appliedResult());
|
|
801
|
+
await waitAllPromises();
|
|
802
|
+
expect(push).toHaveBeenCalledOnce();
|
|
803
|
+
expect(view.getByTestId('result-content')).not.toHaveAttribute('data-pending');
|
|
804
|
+
});
|