@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,391 @@
1
+ import { createPinia, setActivePinia } from 'pinia';
2
+
3
+ import * as otelApi from './otel.api';
4
+ import type { OtelSettingsResponse } from './otel.api';
5
+ import { useOtelStore, headersStringToPairs, headersPairsToString } from './otel.store';
6
+
7
+ vi.mock('./otel.api', () => ({
8
+ getOtelSettings: vi.fn(),
9
+ updateOtelSettings: vi.fn(),
10
+ sendOtelTestTrace: vi.fn(),
11
+ }));
12
+
13
+ vi.mock('@n8n/stores/useRootStore', () => ({
14
+ useRootStore: vi.fn(() => ({
15
+ restApiContext: { baseUrl: 'http://localhost', pushRef: '' },
16
+ })),
17
+ }));
18
+
19
+ const fetchMock = vi.mocked(otelApi.getOtelSettings);
20
+ const saveMock = vi.mocked(otelApi.updateOtelSettings);
21
+ const testTraceMock = vi.mocked(otelApi.sendOtelTestTrace);
22
+
23
+ const makeSettings = (overrides: Partial<OtelSettingsResponse> = {}): OtelSettingsResponse => ({
24
+ enabled: false,
25
+ exporterEndpoint: 'http://localhost:4318',
26
+ exporterTracingPath: '/v1/traces',
27
+ exporterServiceName: 'n8n',
28
+ exporterHeaders: '',
29
+ tracesSampleRate: 1.0,
30
+ startupConnectivityTimeoutMs: 2000,
31
+ includeNodeSpans: true,
32
+ injectOutbound: true,
33
+ productionExecutionsOnly: true,
34
+ envManagedFields: [],
35
+ ...overrides,
36
+ });
37
+
38
+ function extractSettings(r: OtelSettingsResponse) {
39
+ const { envManagedFields: _, ...settings } = r;
40
+ return settings;
41
+ }
42
+
43
+ describe('headersStringToPairs', () => {
44
+ it('returns empty array for empty string', () => {
45
+ expect(headersStringToPairs('')).toEqual([]);
46
+ });
47
+
48
+ it('returns empty array for whitespace-only string', () => {
49
+ expect(headersStringToPairs(' ')).toEqual([]);
50
+ });
51
+
52
+ it('parses a single key=value pair', () => {
53
+ expect(headersStringToPairs('auth=token')).toEqual([{ key: 'auth', value: 'token' }]);
54
+ });
55
+
56
+ it('parses multiple comma-separated pairs', () => {
57
+ expect(headersStringToPairs('a=1,b=2')).toEqual([
58
+ { key: 'a', value: '1' },
59
+ { key: 'b', value: '2' },
60
+ ]);
61
+ });
62
+
63
+ it('trims whitespace around keys and values', () => {
64
+ expect(headersStringToPairs(' key = val ')).toEqual([{ key: 'key', value: 'val' }]);
65
+ });
66
+
67
+ it('treats missing = as key with empty value', () => {
68
+ expect(headersStringToPairs('noequals')).toEqual([{ key: 'noequals', value: '' }]);
69
+ });
70
+
71
+ it('allows = in value (splits on first = only)', () => {
72
+ expect(headersStringToPairs('sig=a=b')).toEqual([{ key: 'sig', value: 'a=b' }]);
73
+ });
74
+
75
+ it('filters out pairs with empty key', () => {
76
+ expect(headersStringToPairs(',a=1')).toEqual([{ key: 'a', value: '1' }]);
77
+ });
78
+ });
79
+
80
+ describe('headersPairsToString', () => {
81
+ it('returns empty string for empty array', () => {
82
+ expect(headersPairsToString([])).toBe('');
83
+ });
84
+
85
+ it('serialises a single pair', () => {
86
+ expect(headersPairsToString([{ key: 'auth', value: 'token' }])).toBe('auth=token');
87
+ });
88
+
89
+ it('joins multiple pairs with comma', () => {
90
+ expect(
91
+ headersPairsToString([
92
+ { key: 'a', value: '1' },
93
+ { key: 'b', value: '2' },
94
+ ]),
95
+ ).toBe('a=1,b=2');
96
+ });
97
+
98
+ it('filters out pairs where key is blank', () => {
99
+ expect(
100
+ headersPairsToString([
101
+ { key: '', value: 'x' },
102
+ { key: 'k', value: 'v' },
103
+ ]),
104
+ ).toBe('k=v');
105
+ });
106
+
107
+ it('filters out whitespace-only keys', () => {
108
+ expect(headersPairsToString([{ key: ' ', value: 'x' }])).toBe('');
109
+ });
110
+ });
111
+
112
+ describe('useOtelStore', () => {
113
+ beforeEach(() => {
114
+ setActivePinia(createPinia());
115
+ fetchMock.mockReset();
116
+ saveMock.mockReset();
117
+ testTraceMock.mockReset();
118
+ });
119
+
120
+ describe('fetchSettings', () => {
121
+ it('populates settings and savedSettings from API response', async () => {
122
+ const remote = makeSettings({ enabled: true, exporterEndpoint: 'https://collector.io' });
123
+ fetchMock.mockResolvedValueOnce(remote);
124
+
125
+ const store = useOtelStore();
126
+ await store.fetchSettings();
127
+
128
+ expect(store.settings).toEqual(extractSettings(remote));
129
+ expect(store.savedSettings).toEqual(extractSettings(remote));
130
+ });
131
+
132
+ it('stores independent copies so mutations do not affect savedSettings', async () => {
133
+ const remote = makeSettings({ exporterEndpoint: 'https://original.io' });
134
+ fetchMock.mockResolvedValueOnce(remote);
135
+
136
+ const store = useOtelStore();
137
+ await store.fetchSettings();
138
+
139
+ store.settings.exporterEndpoint = 'https://changed.io';
140
+
141
+ expect(store.savedSettings.exporterEndpoint).toBe('https://original.io');
142
+ });
143
+
144
+ it('sets loading to true during the call and resets it after', async () => {
145
+ let resolve!: (v: OtelSettingsResponse) => void;
146
+ fetchMock.mockImplementationOnce(async () => await new Promise((r) => (resolve = r)));
147
+
148
+ const store = useOtelStore();
149
+ const pending = store.fetchSettings();
150
+
151
+ expect(store.loading).toBe(true);
152
+
153
+ resolve(makeSettings());
154
+ await pending;
155
+
156
+ expect(store.loading).toBe(false);
157
+ });
158
+
159
+ it('resets loading even when the API throws', async () => {
160
+ fetchMock.mockRejectedValueOnce(new Error('network error'));
161
+
162
+ const store = useOtelStore();
163
+ await expect(store.fetchSettings()).rejects.toThrow('network error');
164
+
165
+ expect(store.loading).toBe(false);
166
+ });
167
+ });
168
+
169
+ describe('saveSettings', () => {
170
+ it('calls updateOtelSettings with current settings and updates store from response', async () => {
171
+ const current = makeSettings({ enabled: true });
172
+ const updated = makeSettings({ enabled: true, exporterEndpoint: 'https://updated.io' });
173
+ fetchMock.mockResolvedValueOnce(current);
174
+ saveMock.mockResolvedValueOnce(updated);
175
+
176
+ const store = useOtelStore();
177
+ await store.fetchSettings();
178
+ await store.saveSettings();
179
+
180
+ expect(saveMock).toHaveBeenCalledWith(expect.anything(), extractSettings(current));
181
+ expect(store.settings).toEqual(extractSettings(updated));
182
+ expect(store.savedSettings).toEqual(extractSettings(updated));
183
+ });
184
+
185
+ it('sets saving to true during the call and resets it after', async () => {
186
+ const settings = makeSettings();
187
+ fetchMock.mockResolvedValueOnce(settings);
188
+ let resolve!: (v: OtelSettingsResponse) => void;
189
+ saveMock.mockImplementationOnce(async () => await new Promise((r) => (resolve = r)));
190
+
191
+ const store = useOtelStore();
192
+ await store.fetchSettings();
193
+ const pending = store.saveSettings();
194
+
195
+ expect(store.saving).toBe(true);
196
+
197
+ resolve(settings);
198
+ await pending;
199
+
200
+ expect(store.saving).toBe(false);
201
+ });
202
+
203
+ it('resets saving even when the API throws', async () => {
204
+ fetchMock.mockResolvedValueOnce(makeSettings());
205
+ saveMock.mockRejectedValueOnce(new Error('save failed'));
206
+
207
+ const store = useOtelStore();
208
+ await store.fetchSettings();
209
+
210
+ await expect(store.saveSettings()).rejects.toThrow('save failed');
211
+ expect(store.saving).toBe(false);
212
+ });
213
+ });
214
+
215
+ describe('discardChanges', () => {
216
+ it('resets settings to savedSettings', async () => {
217
+ const original = makeSettings({ exporterEndpoint: 'https://original.io' });
218
+ fetchMock.mockResolvedValueOnce(original);
219
+
220
+ const store = useOtelStore();
221
+ await store.fetchSettings();
222
+
223
+ store.settings.exporterEndpoint = 'https://changed.io';
224
+ store.discardChanges();
225
+
226
+ expect(store.settings.exporterEndpoint).toBe('https://original.io');
227
+ });
228
+ });
229
+
230
+ describe('isDirty', () => {
231
+ it('is false before fetch (settings equal default savedSettings)', () => {
232
+ const store = useOtelStore();
233
+ expect(store.isDirty).toBe(false);
234
+ });
235
+
236
+ it('is false immediately after fetch (settings equal savedSettings)', async () => {
237
+ fetchMock.mockResolvedValueOnce(makeSettings());
238
+ const store = useOtelStore();
239
+ await store.fetchSettings();
240
+
241
+ expect(store.isDirty).toBe(false);
242
+ });
243
+
244
+ it('is true after a field is changed', async () => {
245
+ fetchMock.mockResolvedValueOnce(makeSettings({ enabled: false }));
246
+ const store = useOtelStore();
247
+ await store.fetchSettings();
248
+
249
+ store.settings.enabled = true;
250
+
251
+ expect(store.isDirty).toBe(true);
252
+ });
253
+
254
+ it('returns to false after discardChanges', async () => {
255
+ fetchMock.mockResolvedValueOnce(makeSettings({ enabled: false }));
256
+ const store = useOtelStore();
257
+ await store.fetchSettings();
258
+
259
+ store.settings.enabled = true;
260
+ expect(store.isDirty).toBe(true);
261
+
262
+ store.discardChanges();
263
+ expect(store.isDirty).toBe(false);
264
+ });
265
+
266
+ it('returns to false after a successful save', async () => {
267
+ const settings = makeSettings({ enabled: false });
268
+ fetchMock.mockResolvedValueOnce(settings);
269
+ saveMock.mockResolvedValueOnce(makeSettings({ enabled: true }));
270
+
271
+ const store = useOtelStore();
272
+ await store.fetchSettings();
273
+ store.settings.enabled = true;
274
+ expect(store.isDirty).toBe(true);
275
+
276
+ await store.saveSettings();
277
+ expect(store.isDirty).toBe(false);
278
+ });
279
+ });
280
+
281
+ describe('sendTestTrace', () => {
282
+ it('starts in the idle state', () => {
283
+ const store = useOtelStore();
284
+ expect(store.testState).toBe('idle');
285
+ });
286
+
287
+ it('sends only the connection fields from the current settings', async () => {
288
+ fetchMock.mockResolvedValueOnce(
289
+ makeSettings({
290
+ exporterEndpoint: 'https://collector.io',
291
+ exporterTracingPath: '/custom',
292
+ exporterServiceName: 'n8n-prod',
293
+ exporterHeaders: 'auth=token',
294
+ startupConnectivityTimeoutMs: 3000,
295
+ }),
296
+ );
297
+ testTraceMock.mockResolvedValueOnce({ success: true });
298
+
299
+ const store = useOtelStore();
300
+ await store.fetchSettings();
301
+ await store.sendTestTrace();
302
+
303
+ expect(testTraceMock).toHaveBeenCalledWith(expect.anything(), {
304
+ exporterEndpoint: 'https://collector.io',
305
+ exporterTracingPath: '/custom',
306
+ exporterServiceName: 'n8n-prod',
307
+ exporterHeaders: 'auth=token',
308
+ startupConnectivityTimeoutMs: 3000,
309
+ });
310
+ });
311
+
312
+ it('transitions to sent and records a timestamp on success', async () => {
313
+ testTraceMock.mockResolvedValueOnce({ success: true });
314
+
315
+ const store = useOtelStore();
316
+ await store.sendTestTrace();
317
+
318
+ expect(store.testState).toBe('sent');
319
+ expect(store.testTimestamp).not.toBe('');
320
+ expect(store.testError).toBe('');
321
+ });
322
+
323
+ it('transitions to error with the collector message on failure', async () => {
324
+ testTraceMock.mockResolvedValueOnce({ success: false, error: '401 Unauthorized' });
325
+
326
+ const store = useOtelStore();
327
+ await store.sendTestTrace();
328
+
329
+ expect(store.testState).toBe('error');
330
+ expect(store.testError).toBe('401 Unauthorized');
331
+ });
332
+
333
+ it('transitions to error when the request itself throws', async () => {
334
+ testTraceMock.mockRejectedValueOnce(new Error('network error'));
335
+
336
+ const store = useOtelStore();
337
+ await store.sendTestTrace();
338
+
339
+ expect(store.testState).toBe('error');
340
+ expect(store.testError).toBe('network error');
341
+ });
342
+
343
+ it('is in the sending state while the request is in flight', async () => {
344
+ let resolve!: (v: { success: true }) => void;
345
+ testTraceMock.mockImplementationOnce(async () => await new Promise((r) => (resolve = r)));
346
+
347
+ const store = useOtelStore();
348
+ const pending = store.sendTestTrace();
349
+
350
+ expect(store.testState).toBe('sending');
351
+
352
+ resolve({ success: true });
353
+ await pending;
354
+
355
+ expect(store.testState).toBe('sent');
356
+ });
357
+
358
+ it('discards an in-flight result when reset before it resolves', async () => {
359
+ let resolve!: (v: { success: true }) => void;
360
+ testTraceMock.mockImplementationOnce(async () => await new Promise((r) => (resolve = r)));
361
+
362
+ const store = useOtelStore();
363
+ const pending = store.sendTestTrace();
364
+ expect(store.testState).toBe('sending');
365
+
366
+ // User edits a connection field mid-flight.
367
+ store.resetTestState();
368
+ expect(store.testState).toBe('idle');
369
+
370
+ resolve({ success: true });
371
+ await pending;
372
+
373
+ // The stale success must not flip the badge back to 'sent'.
374
+ expect(store.testState).toBe('idle');
375
+ });
376
+
377
+ it('resetTestState clears the result', async () => {
378
+ testTraceMock.mockResolvedValueOnce({ success: false, error: 'boom' });
379
+
380
+ const store = useOtelStore();
381
+ await store.sendTestTrace();
382
+ expect(store.testState).toBe('error');
383
+
384
+ store.resetTestState();
385
+
386
+ expect(store.testState).toBe('idle');
387
+ expect(store.testError).toBe('');
388
+ expect(store.testTimestamp).toBe('');
389
+ });
390
+ });
391
+ });
@@ -0,0 +1,145 @@
1
+ import { useRootStore } from '@n8n/stores/useRootStore';
2
+ import { defineStore } from 'pinia';
3
+ import { ref, computed } from 'vue';
4
+
5
+ import { getOtelSettings, updateOtelSettings, sendOtelTestTrace } from './otel.api';
6
+ import type { OtelSettings, OtelSettingsResponse } from './otel.api';
7
+ import { OTEL_STORE } from './otel.constants';
8
+
9
+ export type OtelTestState = 'idle' | 'sending' | 'sent' | 'error';
10
+
11
+ export function headersStringToPairs(str: string): Array<{ key: string; value: string }> {
12
+ if (!str.trim()) return [];
13
+ return str
14
+ .split(',')
15
+ .map((pair) => {
16
+ const idx = pair.indexOf('=');
17
+ if (idx === -1) return { key: pair.trim(), value: '' };
18
+ return { key: pair.slice(0, idx).trim(), value: pair.slice(idx + 1).trim() };
19
+ })
20
+ .filter((p) => p.key);
21
+ }
22
+
23
+ export function headersPairsToString(pairs: Array<{ key: string; value: string }>): string {
24
+ return pairs
25
+ .filter((p) => p.key.trim())
26
+ .map((p) => `${p.key}=${p.value}`)
27
+ .join(',');
28
+ }
29
+
30
+ const defaultSettings: OtelSettings = {
31
+ enabled: false,
32
+ exporterEndpoint: 'http://localhost:4318',
33
+ exporterTracingPath: '/v1/traces',
34
+ exporterServiceName: 'n8n',
35
+ exporterHeaders: '',
36
+ tracesSampleRate: 1.0,
37
+ startupConnectivityTimeoutMs: 2_000,
38
+ includeNodeSpans: true,
39
+ injectOutbound: true,
40
+ productionExecutionsOnly: true,
41
+ };
42
+
43
+ function extractSettings(response: OtelSettingsResponse): OtelSettings {
44
+ const { envManagedFields: _, ...settings } = response;
45
+ return settings;
46
+ }
47
+
48
+ export const useOtelStore = defineStore(OTEL_STORE, () => {
49
+ const rootStore = useRootStore();
50
+
51
+ const settings = ref<OtelSettings>({ ...defaultSettings });
52
+ const savedSettings = ref<OtelSettings>({ ...defaultSettings });
53
+ const envManagedFields = ref<Array<keyof OtelSettings>>([]);
54
+ const loading = ref(true);
55
+ const saving = ref(false);
56
+
57
+ const testState = ref<OtelTestState>('idle');
58
+ const testError = ref('');
59
+ const testTimestamp = ref('');
60
+
61
+ const isDirty = computed(
62
+ () => JSON.stringify(settings.value) !== JSON.stringify(savedSettings.value),
63
+ );
64
+
65
+ async function fetchSettings(): Promise<void> {
66
+ loading.value = true;
67
+ try {
68
+ const fetched = await getOtelSettings(rootStore.restApiContext);
69
+ settings.value = extractSettings(fetched);
70
+ savedSettings.value = extractSettings(fetched);
71
+ envManagedFields.value = fetched.envManagedFields;
72
+ } finally {
73
+ loading.value = false;
74
+ }
75
+ }
76
+
77
+ async function saveSettings(): Promise<void> {
78
+ saving.value = true;
79
+ try {
80
+ const updated = await updateOtelSettings(rootStore.restApiContext, settings.value);
81
+ settings.value = extractSettings(updated);
82
+ savedSettings.value = extractSettings(updated);
83
+ envManagedFields.value = updated.envManagedFields;
84
+ } finally {
85
+ saving.value = false;
86
+ }
87
+ }
88
+
89
+ function discardChanges(): void {
90
+ settings.value = { ...savedSettings.value };
91
+ }
92
+
93
+ let currentTestRun = 0;
94
+
95
+ function resetTestState(): void {
96
+ currentTestRun++;
97
+ testState.value = 'idle';
98
+ testError.value = '';
99
+ testTimestamp.value = '';
100
+ }
101
+
102
+ async function sendTestTrace(): Promise<void> {
103
+ const runId = ++currentTestRun;
104
+ testState.value = 'sending';
105
+ testError.value = '';
106
+ try {
107
+ const result = await sendOtelTestTrace(rootStore.restApiContext, {
108
+ exporterEndpoint: settings.value.exporterEndpoint,
109
+ exporterTracingPath: settings.value.exporterTracingPath,
110
+ exporterServiceName: settings.value.exporterServiceName,
111
+ exporterHeaders: settings.value.exporterHeaders,
112
+ startupConnectivityTimeoutMs: settings.value.startupConnectivityTimeoutMs,
113
+ });
114
+ if (runId !== currentTestRun) return;
115
+ if (result.success) {
116
+ testTimestamp.value = new Date().toLocaleTimeString();
117
+ testState.value = 'sent';
118
+ } else {
119
+ testError.value = result.error;
120
+ testState.value = 'error';
121
+ }
122
+ } catch (error) {
123
+ if (runId !== currentTestRun) return;
124
+ testError.value = error instanceof Error ? error.message : String(error);
125
+ testState.value = 'error';
126
+ }
127
+ }
128
+
129
+ return {
130
+ settings,
131
+ savedSettings,
132
+ envManagedFields,
133
+ loading,
134
+ saving,
135
+ isDirty,
136
+ testState,
137
+ testError,
138
+ testTimestamp,
139
+ fetchSettings,
140
+ saveSettings,
141
+ discardChanges,
142
+ sendTestTrace,
143
+ resetTestState,
144
+ };
145
+ });
@@ -0,0 +1,44 @@
1
+ import { createSampleRateFormat } from './otel.utils';
2
+
3
+ describe('createSampleRateFormat', () => {
4
+ describe.each(['en-US', 'de-DE', 'fr-FR', 'ar-EG', 'fa-IR'])('locale %s', (locale) => {
5
+ const { format, parse } = createSampleRateFormat(locale);
6
+
7
+ test.each([0, 0.25, 0.5, 0.1234, 1])('round-trips %s through format and parse', (value) => {
8
+ expect(parse(format(value))).toBe(value);
9
+ });
10
+ });
11
+
12
+ describe('parse', () => {
13
+ const { parse } = createSampleRateFormat('en-US');
14
+
15
+ it('accepts both plain decimal separators', () => {
16
+ expect(parse('0.5')).toBe(0.5);
17
+ expect(parse('0,5')).toBe(0.5);
18
+ });
19
+
20
+ it('clamps to [0, 1]', () => {
21
+ expect(parse('5')).toBe(1);
22
+ expect(parse('-1')).toBe(0);
23
+ });
24
+
25
+ it('returns null for empty or non-numeric input', () => {
26
+ expect(parse('')).toBeNull();
27
+ expect(parse(' ')).toBeNull();
28
+ expect(parse('abc')).toBeNull();
29
+ });
30
+
31
+ it('accepts ASCII digits under a localized-digit locale', () => {
32
+ expect(createSampleRateFormat('ar-EG').parse('0.5')).toBe(0.5);
33
+ });
34
+ });
35
+
36
+ describe('format', () => {
37
+ it('renders at least two and at most four fraction digits', () => {
38
+ const { format } = createSampleRateFormat('en-US');
39
+ expect(format(1)).toBe('1.00');
40
+ expect(format(0.5)).toBe('0.50');
41
+ expect(format(0.1234)).toBe('0.1234');
42
+ });
43
+ });
44
+ });
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Locale-aware formatting and parsing for the traces sample rate (0..1).
3
+ *
4
+ * The parser inverts the formatter: the locale's digits are mapped back to
5
+ * ASCII and its decimal separator is accepted alongside '.' and ',', so any
6
+ * value the formatter renders (or a user types) parses back to the same number.
7
+ */
8
+ export function createSampleRateFormat(locale?: string) {
9
+ const formatter = new Intl.NumberFormat(locale, {
10
+ minimumFractionDigits: 2,
11
+ maximumFractionDigits: 4,
12
+ });
13
+ const decimalSeparator =
14
+ formatter.formatToParts(1.1).find((part) => part.type === 'decimal')?.value ?? '.';
15
+ const digitFormatter = new Intl.NumberFormat(locale);
16
+ const asciiDigits = new Map(
17
+ Array.from({ length: 10 }, (_, digit) => [digitFormatter.format(digit), String(digit)]),
18
+ );
19
+
20
+ /** Parse a rate in this locale; null when not a number (incl. empty). Clamps to [0, 1]. */
21
+ function parse(text: string): number | null {
22
+ // strip bidi control marks (LRM, RLM, ALM) some locales emit around numbers
23
+ const trimmed = text.replace(/[\u200e\u200f\u061c]/gu, '').trim();
24
+ if (!trimmed) return null;
25
+ const normalized = [...trimmed]
26
+ .map((char) => asciiDigits.get(char) ?? char)
27
+ .join('')
28
+ .replace(decimalSeparator, '.')
29
+ .replace(',', '.');
30
+ const parsed = Number(normalized);
31
+ return Number.isFinite(parsed) ? Math.min(1, Math.max(0, parsed)) : null;
32
+ }
33
+
34
+ return {
35
+ format: (value: number) => formatter.format(value),
36
+ parse,
37
+ };
38
+ }
@@ -0,0 +1,9 @@
1
+ import { baseConfig } from '@n8n/stylelint-config/base';
2
+
3
+ export default {
4
+ ...baseConfig,
5
+ rules: {
6
+ ...baseConfig.rules,
7
+ '@n8n/css-var-naming': [true, { severity: 'error' }],
8
+ },
9
+ };
package/vite.config.ts ADDED
@@ -0,0 +1,51 @@
1
+ import vue from '@vitejs/plugin-vue';
2
+ import { fileURLToPath } from 'node:url';
3
+ import { resolve } from 'node:path';
4
+ import icons from 'unplugin-icons/vite';
5
+ import svgLoader from 'vite-svg-loader';
6
+ import { defineConfig, mergeConfig } from 'vite';
7
+ import { frontendAliases } from '@n8n/frontend-vite-config';
8
+ import { vitestConfig } from '@n8n/vitest-config/frontend';
9
+
10
+ const packageDir = fileURLToPath(new URL('.', import.meta.url));
11
+ const packagesDir = resolve(packageDir, '..', '..', '..');
12
+
13
+ export default mergeConfig(
14
+ defineConfig({
15
+ // `@n8n/design-system`'s icon set reaches for two things Vite does not handle on its
16
+ // own: `~icons/lucide/*` virtual modules and `./custom/*.svg` single-file components.
17
+ // Consuming design-system from source makes both the consumer's problem — without
18
+ // `svgLoader` an `.svg` import returns a data-URI string, which Vue then renders as a
19
+ // tag name and jsdom rejects. Every module that renders a design-system component
20
+ // needs these two plugins.
21
+ plugins: [
22
+ vue(),
23
+ // Off, so a test never reaches the network for a missing collection.
24
+ icons({ compiler: 'vue3', autoInstall: false }),
25
+ svgLoader({
26
+ svgoConfig: {
27
+ plugins: [
28
+ {
29
+ name: 'preset-default',
30
+ params: {
31
+ overrides: {
32
+ // The icons rely on their ids, and on a viewBox to stay scalable.
33
+ cleanupIds: false,
34
+ removeViewBox: false,
35
+ },
36
+ },
37
+ },
38
+ ],
39
+ },
40
+ }),
41
+ ],
42
+ resolve: {
43
+ // The same platform mapping the editor-ui dev server uses, so a test resolves
44
+ // `@n8n/stores/...` from source rather than from a stale `dist` — the two disagreeing is
45
+ // what put 1,111 specifiers on the wrong side of the src/dist line. Sibling modules are
46
+ // deliberately absent: nothing here should make a cross-module import resolve.
47
+ alias: frontendAliases(packagesDir),
48
+ },
49
+ }),
50
+ vitestConfig,
51
+ );