@n8n/frontend-module-otel 0.1.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,645 @@
1
+ import { createTestingPinia } from '@pinia/testing';
2
+ import userEvent from '@testing-library/user-event';
3
+ import { waitFor } from '@testing-library/vue';
4
+
5
+ import { createComponentRenderer } from './__tests__/render';
6
+ import type { OtelSettingsResponse } from './otel.api';
7
+ import { useOtelStore } from './otel.store';
8
+ import SettingsOpenTelemetryView from './SettingsOpenTelemetryView.vue';
9
+
10
+ const showMessage = vi.fn();
11
+ const showError = vi.fn();
12
+ vi.mock('@n8n/composables/useToast', () => ({
13
+ useToast: () => ({ showMessage, showError }),
14
+ }));
15
+
16
+ const telemetryTrack = vi.fn();
17
+ vi.mock('@n8n/composables/useTelemetry', () => ({
18
+ useTelemetry: () => ({ track: telemetryTrack }),
19
+ }));
20
+
21
+ vi.mock('@n8n/stores/useRootStore', () => ({
22
+ useRootStore: () => ({ restApiContext: { baseUrl: '', pushRef: '' } }),
23
+ }));
24
+
25
+ let capturedRouteLeaveGuard:
26
+ | Parameters<typeof import('vue-router')['onBeforeRouteLeave']>[0]
27
+ | null = null;
28
+ vi.mock('vue-router', async (importOriginal) => {
29
+ const actual = await importOriginal<typeof import('vue-router')>();
30
+ return {
31
+ ...actual,
32
+ onBeforeRouteLeave: (guard: typeof capturedRouteLeaveGuard) => {
33
+ capturedRouteLeaveGuard = guard;
34
+ },
35
+ };
36
+ });
37
+
38
+ // Typed, so the factory below returns `Promise<unknown>` rather than the `any` a bare
39
+ // `vi.fn()` yields — the module package lints `no-unsafe-return` at error level.
40
+ type ApiMock = (...args: unknown[]) => Promise<unknown>;
41
+
42
+ const getOtelSettingsMock = vi.fn<ApiMock>();
43
+ const updateOtelSettingsMock = vi.fn<ApiMock>();
44
+ const sendOtelTestTraceMock = vi.fn<ApiMock>();
45
+
46
+ vi.mock('./otel.api', () => ({
47
+ getOtelSettings: async (...args: unknown[]) => await getOtelSettingsMock(...args),
48
+ updateOtelSettings: async (...args: unknown[]) => await updateOtelSettingsMock(...args),
49
+ sendOtelTestTrace: async (...args: unknown[]) => await sendOtelTestTraceMock(...args),
50
+ }));
51
+
52
+ const makeSettings = (overrides: Partial<OtelSettingsResponse> = {}): OtelSettingsResponse => ({
53
+ enabled: false,
54
+ exporterEndpoint: 'http://localhost:4318',
55
+ exporterTracingPath: '/v1/traces',
56
+ exporterServiceName: 'n8n',
57
+ exporterHeaders: '',
58
+ tracesSampleRate: 1.0,
59
+ startupConnectivityTimeoutMs: 2000,
60
+ includeNodeSpans: true,
61
+ injectOutbound: true,
62
+ productionExecutionsOnly: true,
63
+ envManagedFields: [],
64
+ ...overrides,
65
+ });
66
+
67
+ const render = (options = {}) => {
68
+ const pinia = createTestingPinia({ stubActions: false });
69
+ return {
70
+ pinia,
71
+ store: useOtelStore(pinia),
72
+ ...createComponentRenderer(SettingsOpenTelemetryView, { pinia })(options),
73
+ };
74
+ };
75
+
76
+ const dirtyEndpoint = async (getByTestId: ReturnType<typeof render>['getByTestId']) => {
77
+ const input = getByTestId('otel-exporter-endpoint');
78
+ await userEvent.type(input, 'x');
79
+ };
80
+
81
+ /** Unit inputs (timeout, sample rate) format on blur, so type then tab away to commit. */
82
+ const typeIntoUnitInput = async (input: HTMLElement, value: string) => {
83
+ await userEvent.clear(input);
84
+ await userEvent.type(input, value);
85
+ await userEvent.tab();
86
+ };
87
+
88
+ // ── tests ─────────────────────────────────────────────────────────────────────
89
+
90
+ describe('SettingsOpenTelemetryView', () => {
91
+ beforeEach(() => {
92
+ vi.clearAllMocks();
93
+ capturedRouteLeaveGuard = null;
94
+ getOtelSettingsMock.mockResolvedValue(makeSettings());
95
+ updateOtelSettingsMock.mockResolvedValue(makeSettings());
96
+ sendOtelTestTraceMock.mockResolvedValue({ success: true });
97
+ });
98
+
99
+ // ── initial render ────────────────────────────────────────────────────────
100
+
101
+ it('shows a loading spinner while fetching', () => {
102
+ getOtelSettingsMock.mockReturnValue(new Promise(() => {}));
103
+
104
+ const { getByTestId, queryByTestId } = render();
105
+ expect(getByTestId('otel-loading')).toBeInTheDocument();
106
+ expect(queryByTestId('otel-exporter-endpoint')).not.toBeInTheDocument();
107
+ });
108
+
109
+ it('renders all settings fields after fetch completes', async () => {
110
+ const { getByTestId } = render();
111
+
112
+ await waitFor(() => {
113
+ expect(getByTestId('otel-exporter-endpoint')).toBeInTheDocument();
114
+ });
115
+
116
+ expect(getByTestId('otel-service-name')).toBeInTheDocument();
117
+ expect(getByTestId('otel-tracing-path')).toBeInTheDocument();
118
+ expect(getByTestId('otel-sample-rate')).toBeInTheDocument();
119
+ expect(getByTestId('otel-include-node-spans')).toBeInTheDocument();
120
+ expect(getByTestId('otel-inject-outbound')).toBeInTheDocument();
121
+ expect(getByTestId('otel-production-only')).toBeInTheDocument();
122
+ });
123
+
124
+ it('hides the save bar when settings are unchanged', async () => {
125
+ const { queryByTestId } = render();
126
+
127
+ await waitFor(() => {
128
+ expect(getOtelSettingsMock).toHaveBeenCalled();
129
+ expect(queryByTestId('settings-save-bar')).not.toBeInTheDocument();
130
+ });
131
+ });
132
+
133
+ // ── dirty state ───────────────────────────────────────────────────────────
134
+
135
+ it('enables save and discard buttons after typing in the endpoint field', async () => {
136
+ const { getByTestId } = render();
137
+ await waitFor(() => expect(getByTestId('otel-exporter-endpoint')).toBeInTheDocument());
138
+
139
+ await dirtyEndpoint(getByTestId);
140
+
141
+ await waitFor(() => {
142
+ expect(getByTestId('settings-save-bar-save')).not.toBeDisabled();
143
+ expect(getByTestId('settings-save-bar-discard')).not.toBeDisabled();
144
+ });
145
+ });
146
+
147
+ // ── status control (instant enable/disable) ───────────────────────────────
148
+
149
+ it('enables OpenTelemetry immediately when clicking Enable, without the save bar', async () => {
150
+ getOtelSettingsMock.mockResolvedValue(makeSettings({ enabled: false }));
151
+ updateOtelSettingsMock.mockResolvedValue(makeSettings({ enabled: true }));
152
+
153
+ const { getByTestId, queryByTestId } = render();
154
+ await waitFor(() => expect(getByTestId('otel-enabled-toggle')).toBeInTheDocument());
155
+
156
+ await userEvent.click(getByTestId('otel-enabled-toggle'));
157
+
158
+ await waitFor(() => {
159
+ expect(updateOtelSettingsMock).toHaveBeenCalledWith(
160
+ expect.anything(),
161
+ expect.objectContaining({ enabled: true }),
162
+ );
163
+ expect(showMessage).toHaveBeenCalledWith(expect.objectContaining({ type: 'success' }));
164
+ });
165
+ expect(queryByTestId('settings-save-bar')).not.toBeInTheDocument();
166
+ });
167
+
168
+ it('commits pending form edits when enabling', async () => {
169
+ getOtelSettingsMock.mockResolvedValue(makeSettings({ enabled: false }));
170
+ updateOtelSettingsMock.mockResolvedValue(
171
+ makeSettings({ enabled: true, exporterEndpoint: 'http://localhost:4318x' }),
172
+ );
173
+
174
+ const { getByTestId, queryByTestId } = render();
175
+ await waitFor(() => expect(getByTestId('otel-exporter-endpoint')).toBeInTheDocument());
176
+
177
+ await dirtyEndpoint(getByTestId);
178
+ await waitFor(() => expect(getByTestId('settings-save-bar')).toBeInTheDocument());
179
+
180
+ await userEvent.click(getByTestId('otel-enabled-toggle'));
181
+
182
+ await waitFor(() => {
183
+ expect(updateOtelSettingsMock).toHaveBeenCalledWith(
184
+ expect.anything(),
185
+ expect.objectContaining({ enabled: true, exporterEndpoint: 'http://localhost:4318x' }),
186
+ );
187
+ expect(queryByTestId('settings-save-bar')).not.toBeInTheDocument();
188
+ });
189
+ });
190
+
191
+ it('rolls the status back when enabling fails', async () => {
192
+ getOtelSettingsMock.mockResolvedValue(makeSettings({ enabled: false }));
193
+ updateOtelSettingsMock.mockRejectedValue(new Error('network error'));
194
+
195
+ const { getByTestId, store } = render();
196
+ await waitFor(() => expect(getByTestId('otel-enabled-toggle')).toBeInTheDocument());
197
+
198
+ await userEvent.click(getByTestId('otel-enabled-toggle'));
199
+
200
+ await waitFor(() => {
201
+ expect(showError).toHaveBeenCalledWith(expect.any(Error), expect.any(String));
202
+ expect(store.settings.enabled).toBe(false);
203
+ });
204
+ });
205
+
206
+ it('shows save button after toggling includeNodeSpans checkbox', async () => {
207
+ const { getByTestId } = render();
208
+ await waitFor(() => expect(getByTestId('otel-include-node-spans')).toBeInTheDocument());
209
+
210
+ await userEvent.click(getByTestId('otel-include-node-spans'));
211
+
212
+ await waitFor(() => expect(getByTestId('settings-save-bar-save')).toBeInTheDocument());
213
+ });
214
+
215
+ it('shows save button after toggling injectOutbound checkbox', async () => {
216
+ const { getByTestId } = render();
217
+ await waitFor(() => expect(getByTestId('otel-inject-outbound')).toBeInTheDocument());
218
+
219
+ await userEvent.click(getByTestId('otel-inject-outbound'));
220
+
221
+ await waitFor(() => expect(getByTestId('settings-save-bar-save')).toBeInTheDocument());
222
+ });
223
+
224
+ it('shows save button after toggling productionExecutionsOnly checkbox', async () => {
225
+ const { getByTestId } = render();
226
+ await waitFor(() => expect(getByTestId('otel-production-only')).toBeInTheDocument());
227
+
228
+ await userEvent.click(getByTestId('otel-production-only'));
229
+
230
+ await waitFor(() => expect(getByTestId('settings-save-bar-save')).toBeInTheDocument());
231
+ });
232
+
233
+ // ── save ──────────────────────────────────────────────────────────────────
234
+
235
+ it('calls updateOtelSettings and shows success toast when saving', async () => {
236
+ updateOtelSettingsMock.mockResolvedValue(makeSettings());
237
+
238
+ const { getByTestId } = render();
239
+ await waitFor(() => expect(getByTestId('otel-exporter-endpoint')).toBeInTheDocument());
240
+
241
+ await dirtyEndpoint(getByTestId);
242
+ await waitFor(() => expect(getByTestId('settings-save-bar-save')).toBeInTheDocument());
243
+ await userEvent.click(getByTestId('settings-save-bar-save'));
244
+
245
+ await waitFor(() => {
246
+ expect(updateOtelSettingsMock).toHaveBeenCalled();
247
+ expect(showMessage).toHaveBeenCalledWith(expect.objectContaining({ type: 'success' }));
248
+ });
249
+ });
250
+
251
+ it('tracks "Activated otel via UI" with props when enabled changes false → true', async () => {
252
+ getOtelSettingsMock.mockResolvedValue(makeSettings({ enabled: false }));
253
+ updateOtelSettingsMock.mockResolvedValue(
254
+ makeSettings({ enabled: true, tracesSampleRate: 0.5 }),
255
+ );
256
+
257
+ const { getByTestId } = render();
258
+ await waitFor(() => expect(getByTestId('otel-enabled-toggle')).toBeInTheDocument());
259
+
260
+ await userEvent.click(getByTestId('otel-enabled-toggle'));
261
+
262
+ await waitFor(() => {
263
+ expect(telemetryTrack).toHaveBeenCalledWith(
264
+ 'Activated otel via UI',
265
+ expect.objectContaining({ tracesSampleRate: 0.5 }),
266
+ );
267
+ });
268
+ });
269
+
270
+ it('tracks "Disabled otel via UI" without props when enabled changes true → false', async () => {
271
+ getOtelSettingsMock.mockResolvedValue(makeSettings({ enabled: true }));
272
+ updateOtelSettingsMock.mockResolvedValue(makeSettings({ enabled: false }));
273
+
274
+ const { getByTestId, getByText } = render();
275
+ await waitFor(() => expect(getByTestId('otel-enabled-toggle')).toBeInTheDocument());
276
+
277
+ await userEvent.click(getByTestId('otel-enabled-toggle'));
278
+ await waitFor(() => expect(getByText('Disable')).toBeInTheDocument());
279
+ await userEvent.click(getByText('Disable'));
280
+
281
+ await waitFor(() => {
282
+ expect(telemetryTrack).toHaveBeenCalledWith('Disabled otel via UI');
283
+ expect(telemetryTrack).not.toHaveBeenCalledWith('Activated otel via UI', expect.anything());
284
+ });
285
+ });
286
+
287
+ it('tracks "Updated otel via UI" with props when enabled state does not change', async () => {
288
+ getOtelSettingsMock.mockResolvedValue(makeSettings({ enabled: true }));
289
+ updateOtelSettingsMock.mockResolvedValue(
290
+ makeSettings({ enabled: true, tracesSampleRate: 0.5 }),
291
+ );
292
+
293
+ const { getByTestId } = render();
294
+ await waitFor(() => expect(getByTestId('otel-exporter-endpoint')).toBeInTheDocument());
295
+
296
+ await dirtyEndpoint(getByTestId);
297
+ await waitFor(() => expect(getByTestId('settings-save-bar-save')).toBeInTheDocument());
298
+ await userEvent.click(getByTestId('settings-save-bar-save'));
299
+
300
+ await waitFor(() => {
301
+ expect(telemetryTrack).toHaveBeenCalledWith(
302
+ 'Updated otel via UI',
303
+ expect.objectContaining({ enabled: true, tracesSampleRate: 0.5 }),
304
+ );
305
+ expect(telemetryTrack).not.toHaveBeenCalledWith('Activated otel via UI', expect.anything());
306
+ expect(telemetryTrack).not.toHaveBeenCalledWith('Disabled otel via UI');
307
+ });
308
+ });
309
+
310
+ it('shows error toast when save fails', async () => {
311
+ updateOtelSettingsMock.mockRejectedValue(new Error('network error'));
312
+
313
+ const { getByTestId } = render();
314
+ await waitFor(() => expect(getByTestId('otel-exporter-endpoint')).toBeInTheDocument());
315
+
316
+ await dirtyEndpoint(getByTestId);
317
+ await waitFor(() => expect(getByTestId('settings-save-bar-save')).toBeInTheDocument());
318
+ await userEvent.click(getByTestId('settings-save-bar-save'));
319
+
320
+ await waitFor(() => {
321
+ expect(showError).toHaveBeenCalledWith(expect.any(Error), expect.any(String));
322
+ });
323
+ });
324
+
325
+ // ── discard ───────────────────────────────────────────────────────────────
326
+
327
+ it('discards changes and disables save/discard buttons when clicking Discard', async () => {
328
+ const { getByTestId, queryByTestId } = render();
329
+ await waitFor(() => expect(getByTestId('otel-exporter-endpoint')).toBeInTheDocument());
330
+
331
+ await dirtyEndpoint(getByTestId);
332
+ await waitFor(() => expect(getByTestId('settings-save-bar-save')).not.toBeDisabled());
333
+ await userEvent.click(getByTestId('settings-save-bar-discard'));
334
+
335
+ await waitFor(() => {
336
+ expect(queryByTestId('settings-save-bar')).not.toBeInTheDocument();
337
+ });
338
+ expect(updateOtelSettingsMock).not.toHaveBeenCalled();
339
+ });
340
+
341
+ // ── headers ───────────────────────────────────────────────────────────────
342
+
343
+ it('adds a header row when clicking Add header', async () => {
344
+ const { getByTestId, getAllByTestId } = render();
345
+ await waitFor(() => expect(getByTestId('otel-header-add')).toBeInTheDocument());
346
+
347
+ await userEvent.click(getByTestId('otel-header-add'));
348
+
349
+ await waitFor(() => {
350
+ expect(getAllByTestId('otel-header-key').length).toBe(1);
351
+ });
352
+ });
353
+
354
+ it('removes a header row when clicking the trash button', async () => {
355
+ const { getByTestId, queryAllByTestId } = render();
356
+ await waitFor(() => expect(getByTestId('otel-header-add')).toBeInTheDocument());
357
+
358
+ await userEvent.click(getByTestId('otel-header-add'));
359
+ await waitFor(() => expect(getByTestId('otel-header-remove')).toBeInTheDocument());
360
+ await userEvent.click(getByTestId('otel-header-remove'));
361
+
362
+ await waitFor(() => {
363
+ expect(queryAllByTestId('otel-header-key').length).toBe(0);
364
+ });
365
+ });
366
+
367
+ it('updates exporterHeaders in the store when typing a header key', async () => {
368
+ const { getByTestId, store } = render();
369
+ await waitFor(() => expect(getByTestId('otel-header-add')).toBeInTheDocument());
370
+
371
+ await userEvent.click(getByTestId('otel-header-add'));
372
+ await waitFor(() => expect(getByTestId('otel-header-key')).toBeInTheDocument());
373
+
374
+ const keyInput =
375
+ getByTestId('otel-header-key').querySelector('input') ?? getByTestId('otel-header-key');
376
+ await userEvent.type(keyInput, 'x-api-key');
377
+
378
+ await waitFor(() => {
379
+ expect(store.settings.exporterHeaders).toContain('x-api-key');
380
+ });
381
+ });
382
+
383
+ it('sends correct payload when user fills in all fields and saves', async () => {
384
+ getOtelSettingsMock.mockResolvedValue(
385
+ makeSettings({
386
+ enabled: false,
387
+ exporterEndpoint: '',
388
+ exporterServiceName: '',
389
+ exporterTracingPath: '',
390
+ exporterHeaders: '',
391
+ startupConnectivityTimeoutMs: 0,
392
+ tracesSampleRate: 0,
393
+ includeNodeSpans: false,
394
+ injectOutbound: false,
395
+ productionExecutionsOnly: false,
396
+ }),
397
+ );
398
+ updateOtelSettingsMock.mockResolvedValue(makeSettings());
399
+
400
+ const { getByTestId } = render();
401
+ await waitFor(() => expect(getByTestId('otel-exporter-endpoint')).toBeInTheDocument());
402
+
403
+ // Endpoint
404
+ await userEvent.type(getByTestId('otel-exporter-endpoint'), 'https://collector.example.com');
405
+
406
+ // Service name
407
+ await userEvent.type(getByTestId('otel-service-name'), 'my-n8n');
408
+
409
+ // Tracing path
410
+ await userEvent.clear(getByTestId('otel-tracing-path'));
411
+ await userEvent.type(getByTestId('otel-tracing-path'), '/custom/traces');
412
+
413
+ // Connectivity timeout
414
+ await typeIntoUnitInput(getByTestId('otel-connectivity-timeout'), '5000');
415
+
416
+ // Sample rate
417
+ await typeIntoUnitInput(getByTestId('otel-sample-rate'), '0.5');
418
+
419
+ // Add a header
420
+ await userEvent.click(getByTestId('otel-header-add'));
421
+ await waitFor(() => expect(getByTestId('otel-header-key')).toBeInTheDocument());
422
+ const keyInput =
423
+ getByTestId('otel-header-key').querySelector('input') ?? getByTestId('otel-header-key');
424
+ const valueInput =
425
+ getByTestId('otel-header-value').querySelector('input') ?? getByTestId('otel-header-value');
426
+ await userEvent.type(keyInput, 'x-api-key');
427
+ await userEvent.type(valueInput, 'secret');
428
+
429
+ // Checkboxes
430
+ await userEvent.click(getByTestId('otel-include-node-spans'));
431
+ await userEvent.click(getByTestId('otel-inject-outbound'));
432
+ await userEvent.click(getByTestId('otel-production-only'));
433
+
434
+ // Save
435
+ await waitFor(() => expect(getByTestId('settings-save-bar-save')).toBeInTheDocument());
436
+ await userEvent.click(getByTestId('settings-save-bar-save'));
437
+
438
+ await waitFor(() => {
439
+ expect(updateOtelSettingsMock).toHaveBeenCalledWith(
440
+ expect.anything(),
441
+ expect.objectContaining({
442
+ exporterEndpoint: 'https://collector.example.com',
443
+ exporterServiceName: 'my-n8n',
444
+ exporterTracingPath: '/custom/traces',
445
+ exporterHeaders: 'x-api-key=secret',
446
+ startupConnectivityTimeoutMs: 5000,
447
+ tracesSampleRate: 0.5,
448
+ includeNodeSpans: true,
449
+ injectOutbound: true,
450
+ productionExecutionsOnly: true,
451
+ }),
452
+ );
453
+ });
454
+ });
455
+
456
+ it('pre-populates header rows from exporterHeaders string on load', async () => {
457
+ getOtelSettingsMock.mockResolvedValue(makeSettings({ exporterHeaders: 'auth=token,x-id=42' }));
458
+
459
+ const { getAllByTestId } = render();
460
+
461
+ await waitFor(() => {
462
+ expect(getAllByTestId('otel-header-key').length).toBe(2);
463
+ });
464
+ });
465
+
466
+ // ── test trace ────────────────────────────────────────────────────────────
467
+
468
+ it('renders the test trace button after fetch', async () => {
469
+ const { getByTestId } = render();
470
+
471
+ await waitFor(() => {
472
+ expect(getByTestId('otel-test-trace-button')).toBeInTheDocument();
473
+ });
474
+ });
475
+
476
+ it('sends a test trace and shows the success result when clicking the button', async () => {
477
+ const { getByTestId, getByText } = render();
478
+ await waitFor(() => expect(getByTestId('otel-test-trace-button')).toBeInTheDocument());
479
+
480
+ await userEvent.click(getByTestId('otel-test-trace-button'));
481
+
482
+ await waitFor(() => {
483
+ expect(sendOtelTestTraceMock).toHaveBeenCalledWith(
484
+ expect.anything(),
485
+ expect.objectContaining({ exporterEndpoint: 'http://localhost:4318' }),
486
+ );
487
+ expect(getByText(/span sent to collector at/)).toBeInTheDocument();
488
+ });
489
+ });
490
+
491
+ it('surfaces the collector error when the test trace fails', async () => {
492
+ sendOtelTestTraceMock.mockResolvedValue({ success: false, error: '401 Unauthorized' });
493
+
494
+ const { getByTestId, getByText } = render();
495
+ await waitFor(() => expect(getByTestId('otel-test-trace-button')).toBeInTheDocument());
496
+
497
+ await userEvent.click(getByTestId('otel-test-trace-button'));
498
+
499
+ await waitFor(() => {
500
+ expect(getByText(/401 Unauthorized/)).toBeInTheDocument();
501
+ expect(getByTestId('otel-settings-row-error')).toHaveTextContent('401 Unauthorized');
502
+ });
503
+ });
504
+
505
+ it('disables the test trace button when the endpoint is empty', async () => {
506
+ getOtelSettingsMock.mockResolvedValue(makeSettings({ exporterEndpoint: '' }));
507
+
508
+ const { getByTestId } = render();
509
+
510
+ await waitFor(() => {
511
+ expect(getByTestId('otel-test-trace-button')).toBeDisabled();
512
+ });
513
+ });
514
+
515
+ it('invalidates a previous test result when a connection field changes', async () => {
516
+ const { getByTestId, getByText, queryByText } = render();
517
+ await waitFor(() => expect(getByTestId('otel-test-trace-button')).toBeInTheDocument());
518
+
519
+ await userEvent.click(getByTestId('otel-test-trace-button'));
520
+ await waitFor(() => expect(getByText(/span sent to collector at/)).toBeInTheDocument());
521
+
522
+ await userEvent.type(getByTestId('otel-service-name'), 'x');
523
+
524
+ await waitFor(() => {
525
+ expect(queryByText(/span sent to collector at/)).not.toBeInTheDocument();
526
+ });
527
+ });
528
+
529
+ it('invalidates a previous test result when the connectivity timeout changes', async () => {
530
+ const { getByTestId, getByText, queryByText } = render();
531
+ await waitFor(() => expect(getByTestId('otel-test-trace-button')).toBeInTheDocument());
532
+
533
+ await userEvent.click(getByTestId('otel-test-trace-button'));
534
+ await waitFor(() => expect(getByText(/span sent to collector at/)).toBeInTheDocument());
535
+
536
+ await typeIntoUnitInput(getByTestId('otel-connectivity-timeout'), '5000');
537
+
538
+ await waitFor(() => {
539
+ expect(queryByText(/span sent to collector at/)).not.toBeInTheDocument();
540
+ });
541
+ });
542
+
543
+ // ── unsaved changes dialog ────────────────────────────────────────────────
544
+
545
+ it('shows unsaved changes dialog when navigating away with dirty form', async () => {
546
+ const { getByTestId } = render();
547
+ await waitFor(() => expect(getByTestId('otel-exporter-endpoint')).toBeInTheDocument());
548
+
549
+ await dirtyEndpoint(getByTestId);
550
+
551
+ const next = vi.fn();
552
+ capturedRouteLeaveGuard!({} as never, {} as never, next);
553
+
554
+ await waitFor(() => {
555
+ expect(getByTestId('otel-unsaved-changes-dialog')).toBeInTheDocument();
556
+ });
557
+ expect(next).not.toHaveBeenCalled();
558
+ });
559
+
560
+ it('does not show the dialog when navigating away with clean form', async () => {
561
+ const { queryByTestId } = render();
562
+ await waitFor(() => expect(getOtelSettingsMock).toHaveBeenCalled());
563
+
564
+ const next = vi.fn();
565
+ capturedRouteLeaveGuard!({} as never, {} as never, next);
566
+
567
+ expect(next).toHaveBeenCalledWith();
568
+ expect(queryByTestId('otel-unsaved-changes-dialog')).not.toBeInTheDocument();
569
+ });
570
+
571
+ it('calls next(false) when clicking Cancel in the dialog', async () => {
572
+ const { getByTestId, getByText } = render();
573
+ await waitFor(() => expect(getByTestId('otel-exporter-endpoint')).toBeInTheDocument());
574
+
575
+ await dirtyEndpoint(getByTestId);
576
+
577
+ const next = vi.fn();
578
+ capturedRouteLeaveGuard!({} as never, {} as never, next);
579
+ await waitFor(() => expect(getByTestId('otel-unsaved-changes-dialog')).toBeInTheDocument());
580
+
581
+ await userEvent.click(getByText('Cancel'));
582
+
583
+ await waitFor(() => {
584
+ expect(next).toHaveBeenCalledWith(false);
585
+ });
586
+ });
587
+
588
+ it('calls next() without saving when clicking Leave without saving', async () => {
589
+ const { getByTestId, getByText } = render();
590
+ await waitFor(() => expect(getByTestId('otel-exporter-endpoint')).toBeInTheDocument());
591
+
592
+ await dirtyEndpoint(getByTestId);
593
+
594
+ const next = vi.fn();
595
+ capturedRouteLeaveGuard!({} as never, {} as never, next);
596
+ await waitFor(() => expect(getByTestId('otel-unsaved-changes-dialog')).toBeInTheDocument());
597
+
598
+ await userEvent.click(getByText('Leave without saving'));
599
+
600
+ await waitFor(() => {
601
+ expect(next).toHaveBeenCalledWith();
602
+ expect(updateOtelSettingsMock).not.toHaveBeenCalled();
603
+ });
604
+ });
605
+
606
+ it('saves and calls next() when clicking Save and leave', async () => {
607
+ updateOtelSettingsMock.mockResolvedValue(makeSettings());
608
+
609
+ const { getByTestId, getByText } = render();
610
+ await waitFor(() => expect(getByTestId('otel-exporter-endpoint')).toBeInTheDocument());
611
+
612
+ await dirtyEndpoint(getByTestId);
613
+
614
+ const next = vi.fn();
615
+ capturedRouteLeaveGuard!({} as never, {} as never, next);
616
+ await waitFor(() => expect(getByTestId('otel-unsaved-changes-dialog')).toBeInTheDocument());
617
+
618
+ await userEvent.click(getByText('Save and leave'));
619
+
620
+ await waitFor(() => {
621
+ expect(updateOtelSettingsMock).toHaveBeenCalled();
622
+ expect(next).toHaveBeenCalledWith();
623
+ });
624
+ });
625
+
626
+ it('does not navigate away when Save and leave fails', async () => {
627
+ updateOtelSettingsMock.mockRejectedValue(new Error('network error'));
628
+
629
+ const { getByTestId, getByText } = render();
630
+ await waitFor(() => expect(getByTestId('otel-exporter-endpoint')).toBeInTheDocument());
631
+
632
+ await dirtyEndpoint(getByTestId);
633
+
634
+ const next = vi.fn();
635
+ capturedRouteLeaveGuard!({} as never, {} as never, next);
636
+ await waitFor(() => expect(getByTestId('otel-unsaved-changes-dialog')).toBeInTheDocument());
637
+
638
+ await userEvent.click(getByText('Save and leave'));
639
+
640
+ await waitFor(() => {
641
+ expect(showError).toHaveBeenCalled();
642
+ expect(next).not.toHaveBeenCalled();
643
+ });
644
+ });
645
+ });