fg-devkit 1.17.3 → 1.19.2

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,400 @@
1
+ import assert from 'node:assert/strict'
2
+ import fs from 'node:fs'
3
+ import path from 'node:path'
4
+ import test from 'node:test'
5
+ import { fileURLToPath } from 'node:url'
6
+
7
+ import {
8
+ accountFromPiwikUrl,
9
+ defaultEnvironmentPair,
10
+ defaultPiwikEnvironment,
11
+ discoverPiwikSites,
12
+ normalizeDimension,
13
+ uniquePiwikEnvironments,
14
+ } from './piwik-common.mjs'
15
+ import {
16
+ diffDimensions,
17
+ diffEnvironments,
18
+ formatDimensionReport,
19
+ formatEnvironmentReport,
20
+ parseDimensionSource,
21
+ planCreates,
22
+ planEnvironmentMigration,
23
+ planLocalPull,
24
+ rewriteDimensionNumbers,
25
+ } from './piwik-dimensions.mjs'
26
+
27
+ const scriptDir = path.dirname(fileURLToPath(import.meta.url))
28
+
29
+ const bookingSource = `
30
+ export const customDimensions = {
31
+ clientNumber: 1,
32
+ rowsBooked: 7,
33
+ rowsFailed: 13,
34
+ } as const
35
+ `
36
+
37
+ const portalSource = `
38
+ export const customDimensions = {
39
+ action: 1,
40
+ serviceName: 24,
41
+ deprecated: 25 // Unused - accidentally added
42
+ } as const
43
+
44
+ export const sessionDimensions = {
45
+ permission: 18,
46
+ organizationName: 26
47
+ } as const
48
+ `
49
+
50
+ const appSource = `
51
+ /* eslint-disable perfectionist/sort-enums */
52
+ export enum SessionCustomDimensions {
53
+ OS = 1,
54
+ hasFriskvard = 2,
55
+ }
56
+
57
+ export enum CustomDimensions {
58
+ deviceLocale = 18,
59
+ eventTimestamp = 78,
60
+ }
61
+ `
62
+
63
+ test('parses const maps and enums into event and session scopes', () => {
64
+ const booking = parseDimensionSource(bookingSource, 'src/analytics/dimensions.ts')
65
+ assert.deepEqual(
66
+ booking.map((item) => [item.scope, item.slot, item.key]),
67
+ [
68
+ ['event', 1, 'clientNumber'],
69
+ ['event', 7, 'rowsBooked'],
70
+ ['event', 13, 'rowsFailed'],
71
+ ],
72
+ )
73
+
74
+ const portal = parseDimensionSource(portalSource, 'src/analytics/dimensions.ts')
75
+ assert.equal(portal.find((item) => item.key === 'deprecated')?.slot, 25)
76
+ assert.equal(portal.find((item) => item.key === 'permission')?.scope, 'session')
77
+ assert.equal(portal.find((item) => item.key === 'serviceName')?.scope, 'event')
78
+
79
+ const app = parseDimensionSource(appSource, 'src/util/piwikDimensions.ts')
80
+ assert.equal(app.find((item) => item.key === 'OS')?.scope, 'session')
81
+ assert.equal(app.find((item) => item.key === 'deviceLocale')?.scope, 'event')
82
+ assert.equal(app.find((item) => item.key === 'eventTimestamp')?.slot, 78)
83
+ })
84
+
85
+ test('rejects a duplicated slot in one scope', () => {
86
+ assert.throws(
87
+ () => parseDimensionSource('export const customDimensions = { a: 1, b: 1 } as const'),
88
+ /Duplicate event slot 1/,
89
+ )
90
+ })
91
+
92
+ test('matches the app id to tracking_id even when the Piwik slot differs', () => {
93
+ const locals = parseDimensionSource(`
94
+ export const customDimensions = { active: 46 } as const
95
+ `)
96
+ const remotes = [
97
+ {
98
+ id: '1',
99
+ name: 'active',
100
+ slot: 29,
101
+ trackingId: 46,
102
+ scope: 'event',
103
+ active: true,
104
+ caseSensitive: false,
105
+ description: 'Active',
106
+ },
107
+ ]
108
+ const diff = diffDimensions(locals, remotes)
109
+ assert.equal(diff.inSync.length, 1)
110
+ assert.equal(diff.missing.length, 0)
111
+ assert.equal(diff.inSync[0].remote.slot, 29)
112
+ const report = formatDimensionReport(diff, planCreates(diff.missing, remotes))
113
+ assert.match(report, /│ 29\s+│ 46\s+│ active\s+│ active\s+│ Active\s+│ in sync\s+│ Active\s+│/)
114
+ assert.equal(planCreates(diff.missing, remotes).creates.length, 0)
115
+ })
116
+
117
+ test('diff matches on tracking_id and plans creates in order', () => {
118
+ const locals = parseDimensionSource(portalSource)
119
+ const remotes = [
120
+ { id: '1', name: 'action', slot: 1, trackingId: 1, scope: 'event', active: true, caseSensitive: false, description: '' },
121
+ { id: '2', name: 'Old name', slot: 8, trackingId: 18, scope: 'session', active: true, caseSensitive: false, description: '' },
122
+ { id: '3', name: 'extra', slot: 2, trackingId: 2, scope: 'event', active: false, caseSensitive: false, description: '' },
123
+ ]
124
+ const diff = diffDimensions(locals, remotes)
125
+ assert.equal(diff.inSync.length, 1)
126
+ assert.equal(diff.nameMismatch[0].local.key, 'permission')
127
+ assert.equal(diff.remoteOnly[0].name, 'extra')
128
+ assert.equal(diff.trackingMismatch[0].remote.trackingId, 18)
129
+
130
+ const plan = planCreates(diff.missing, remotes)
131
+ assert.deepEqual(
132
+ plan.creates.map((item) => `${item.scope}:${item.slot}`),
133
+ [],
134
+ )
135
+ assert.match(plan.blocked[0].reason, /Next tracking_id is 3/)
136
+ const report = formatDimensionReport(diff, plan)
137
+ assert.match(report, /different name/)
138
+ assert.match(report, /cannot add/)
139
+ })
140
+
141
+ test('status stays in one column when a name is long', () => {
142
+ const longName = 'bookingConfirmationStep'.repeat(2)
143
+ const locals = parseDimensionSource(`
144
+ export const customDimensions = { ${longName}: 1, short: 2 } as const
145
+ `)
146
+ const remotes = [longName, 'short'].map((name, index) => ({
147
+ id: String(index + 1),
148
+ name,
149
+ slot: index + 1,
150
+ trackingId: index + 1,
151
+ scope: 'event',
152
+ active: true,
153
+ caseSensitive: false,
154
+ description: '',
155
+ }))
156
+ const report = formatDimensionReport(diffDimensions(locals, remotes), { creates: [], blocked: [] })
157
+ const statusAt = report
158
+ .split('\n')
159
+ .filter((line) => line.startsWith('│') && line.includes('in sync'))
160
+ .map((line) => line.indexOf('in sync'))
161
+ assert.equal(statusAt.length, 2)
162
+ assert.equal(statusAt[0], statusAt[1])
163
+ assert.ok(statusAt[0] > longName.length)
164
+ })
165
+
166
+ test('report rows are ordered by scope and numeric slot', () => {
167
+ const locals = parseDimensionSource(`
168
+ export const customDimensions = { a: 1, b: 2, c: 10, d: 13 } as const
169
+ export const sessionDimensions = { permission: 2 } as const
170
+ `)
171
+ const remotes = [1, 2, 10].map((slot) => ({
172
+ id: String(slot),
173
+ name: locals.find((item) => item.scope === 'event' && item.slot === slot)?.key ?? '',
174
+ slot,
175
+ trackingId: slot,
176
+ scope: 'event',
177
+ active: true,
178
+ caseSensitive: false,
179
+ description: '',
180
+ }))
181
+ const diff = diffDimensions(locals, remotes)
182
+ const plan = planCreates(diff.missing, remotes)
183
+ const report = formatDimensionReport(diff, plan)
184
+ const eventAt = report.indexOf('\nevent\n')
185
+ const sessionAt = report.indexOf('\nsession\n')
186
+ assert.ok(eventAt >= 0 && sessionAt > eventAt)
187
+ const ids = report
188
+ .split('\n')
189
+ .filter((line) => line.startsWith('│') && !line.includes('tracking_id'))
190
+ .map((line) => line.split('│')[2].trim())
191
+ assert.deepEqual(ids, ['1', '2', '10', '13', '2'])
192
+ })
193
+
194
+ test('creates the next free slots and blocks a later gap', () => {
195
+ const locals = parseDimensionSource(`
196
+ export const customDimensions = { a: 1, b: 2, c: 4 } as const
197
+ `)
198
+ const plan = planCreates(locals, [])
199
+ assert.deepEqual(
200
+ plan.creates.map((item) => item.slot),
201
+ [1, 2],
202
+ )
203
+ assert.equal(plan.blocked.length, 1)
204
+ assert.match(plan.blocked[0].reason, /Next tracking_id is 3/)
205
+ })
206
+
207
+ test('product slots can be chosen when they are free', () => {
208
+ const locals = parseDimensionSource(`
209
+ export const productDimensions = { color: 2, size: 4 } as const
210
+ `)
211
+ const plan = planCreates(locals, [
212
+ { id: 'p', name: 'taken', slot: 2, trackingId: 2, scope: 'product', active: true, caseSensitive: false, description: '' },
213
+ ])
214
+ assert.deepEqual(
215
+ plan.creates.map((item) => item.key),
216
+ ['size'],
217
+ )
218
+ assert.match(plan.blocked[0].reason, /already used/)
219
+ })
220
+
221
+ test('event and session share one tracking_id sequence', () => {
222
+ const locals = parseDimensionSource(`
223
+ export const sessionDimensions = { permission: 1 } as const
224
+ export const customDimensions = { action: 2 } as const
225
+ `)
226
+ const plan = planCreates(locals, [])
227
+ assert.deepEqual(
228
+ plan.creates.map((item) => `${item.scope}:${item.slot}`),
229
+ ['session:1', 'event:2'],
230
+ )
231
+ })
232
+
233
+ test('discovers account and website id from env modules', () => {
234
+ const sites = discoverPiwikSites(`
235
+ const production: EnvironmentConstants = {
236
+ environment: 'production',
237
+ piwikUrl: 'https://terveystalo.piwik.pro',
238
+ piwikId: '475754f6-f457-4203-9d8f-c58b510fadba',
239
+ }
240
+ const developmentEnvironment: EnvironmentConstants = {
241
+ environment: 'development',
242
+ piwikProBaseUrl: 'https://terveystalo.piwik.pro',
243
+ piwikProId: '6931c50c-b8ea-4960-8153-6916586ecd5b',
244
+ }
245
+ `)
246
+ assert.equal(accountFromPiwikUrl(sites[0].url), 'terveystalo')
247
+ assert.equal(sites[0].websiteId, '475754f6-f457-4203-9d8f-c58b510fadba')
248
+ assert.equal(sites[1].environment, 'development')
249
+ assert.equal(sites[1].websiteId, '6931c50c-b8ea-4960-8153-6916586ecd5b')
250
+ })
251
+
252
+ test('environments that share a piwik id collapse to one choice', () => {
253
+ const unique = uniquePiwikEnvironments([
254
+ { environment: 'localhost', account: 'terveystalo', websiteId: '049864b3-ba27-42ca-b89f-ad2b130aa64b' },
255
+ { environment: 'development', account: 'terveystalo', websiteId: '049864b3-ba27-42ca-b89f-ad2b130aa64b' },
256
+ { environment: 'preprod', account: 'terveystalo', websiteId: '049864b3-ba27-42ca-b89f-ad2b130aa64b' },
257
+ { environment: 'production', account: 'terveystalo', websiteId: '475754f6-f457-4203-9d8f-c58b510fadba' },
258
+ ])
259
+ assert.deepEqual(
260
+ unique.map((site) => site.environment),
261
+ ['preprod', 'production'],
262
+ )
263
+ assert.equal(defaultPiwikEnvironment(unique), 'preprod')
264
+ assert.equal(defaultPiwikEnvironment([{ environment: 'production' }]), 'production')
265
+ })
266
+
267
+ test('normalizes a JSON:API custom dimension', () => {
268
+ const remote = normalizeDimension(
269
+ {
270
+ id: 'abc',
271
+ attributes: {
272
+ name: 'Rows booked',
273
+ slot: 7,
274
+ tracking_id: 7,
275
+ scope: 'event',
276
+ active: true,
277
+ case_sensitive: false,
278
+ website_id: 'site',
279
+ },
280
+ },
281
+ 'event',
282
+ )
283
+ assert.equal(remote.slot, 7)
284
+ assert.equal(remote.scope, 'event')
285
+ assert.equal(remote.websiteId, 'site')
286
+ })
287
+
288
+ test('environment pair defaults to a non-production source and production target', () => {
289
+ assert.deepEqual(
290
+ defaultEnvironmentPair([{ environment: 'preprod' }, { environment: 'production' }]),
291
+ { from: 'preprod', to: 'production' },
292
+ )
293
+ assert.deepEqual(
294
+ defaultEnvironmentPair([{ environment: 'staging' }, { environment: 'production' }]),
295
+ { from: 'staging', to: 'production' },
296
+ )
297
+ })
298
+
299
+ /**
300
+ * @param {Record<string, unknown>} overrides
301
+ */
302
+ function remoteDimension(overrides) {
303
+ return {
304
+ id: '1',
305
+ name: 'active',
306
+ slot: 1,
307
+ trackingId: 1,
308
+ scope: 'event',
309
+ active: true,
310
+ caseSensitive: false,
311
+ description: '',
312
+ ...overrides,
313
+ }
314
+ }
315
+
316
+ test('compares environments on tracking_id and keeps in-sync rows', () => {
317
+ const source = [
318
+ remoteDimension({ id: 'a', name: 'active', slot: 29, trackingId: 46, description: 'Active' }),
319
+ remoteDimension({ id: 'b', name: 'step', slot: 30, trackingId: 47 }),
320
+ ]
321
+ const target = [
322
+ remoteDimension({ id: 'c', name: 'active', slot: 12, trackingId: 46, description: 'Active' }),
323
+ ]
324
+ const diff = diffEnvironments(source, target)
325
+ assert.equal(diff.inSync.length, 1)
326
+ assert.equal(diff.inSync[0].target.slot, 12)
327
+ assert.equal(diff.missing[0].trackingId, 47)
328
+ const plan = planEnvironmentMigration(diff, target)
329
+ assert.equal(plan.creates.length, 0)
330
+ assert.match(plan.blocked[0].reason, /Next tracking_id is 1/)
331
+ const report = formatEnvironmentReport(diff, plan, 'preprod', 'production')
332
+ assert.match(report, /│ 29\s+│ 46\s+│ active\s+│ 12\s+│ 46\s+│ active\s+│ Active\s+│ in sync\s+│ Active\s+│/)
333
+ assert.match(report, /tracking_id preprod/)
334
+ assert.match(report, /tracking_id production/)
335
+ assert.match(report, /in sync/)
336
+ })
337
+
338
+ test('plans environment migration in tracking_id order and blocks a gap', () => {
339
+ const source = [
340
+ remoteDimension({ name: 'one', trackingId: 1, slot: 1 }),
341
+ remoteDimension({ id: '2', name: 'two', trackingId: 2, slot: 2, description: 'Second' }),
342
+ remoteDimension({ id: '4', name: 'four', trackingId: 4, slot: 4 }),
343
+ ]
344
+ const target = [
345
+ remoteDimension({ name: 'old', trackingId: 1, slot: 8, description: 'Old' }),
346
+ remoteDimension({ id: 'x', name: 'extra', trackingId: 9, slot: 9 }),
347
+ ]
348
+ const diff = diffEnvironments(source, target)
349
+ assert.deepEqual(diff.changed[0].fields, ['name', 'description'])
350
+ assert.equal(diff.targetOnly[0].name, 'extra')
351
+ const plan = planEnvironmentMigration(diff, target)
352
+ assert.deepEqual(
353
+ plan.creates.map((item) => item.slot),
354
+ [2],
355
+ )
356
+ assert.match(plan.blocked[0].reason, /Next tracking_id is 3/)
357
+ const report = formatEnvironmentReport(diff, plan, 'staging', 'production')
358
+ assert.match(report, /different name, different description/)
359
+ assert.match(report, /cannot add/)
360
+ assert.match(report, /only in production/)
361
+ })
362
+
363
+ test('pull plans tracking id updates from Piwik into the local map', () => {
364
+ const locals = parseDimensionSource(`
365
+ export const customDimensions = { active: 29, step: 30 } as const
366
+ `)
367
+ const remotes = [
368
+ remoteDimension({ name: 'active', slot: 12, trackingId: 46 }),
369
+ remoteDimension({ id: '2', name: 'step', slot: 13, trackingId: 30 }),
370
+ remoteDimension({ id: '3', name: 'extra', slot: 14, trackingId: 50 }),
371
+ ]
372
+ const plan = planLocalPull(locals, remotes)
373
+ assert.equal(plan.updates.length, 1)
374
+ assert.equal(plan.updates[0].local.key, 'active')
375
+ assert.equal(plan.updates[0].next, 46)
376
+ assert.equal(plan.inSync.length, 1)
377
+ assert.equal(plan.remoteOnly[0].name, 'extra')
378
+ const source = 'export const customDimensions = { active: 29, step: 30 } as const\n'
379
+ assert.equal(
380
+ rewriteDimensionNumbers(source, [{ key: 'active', from: 29, to: 46 }]),
381
+ 'export const customDimensions = { active: 46, step: 30 } as const\n',
382
+ )
383
+ })
384
+
385
+ test('parses the real consumer dimension files when they are checked out beside fg-devkit', () => {
386
+ const files = [
387
+ ['../../bokningsverktyget-ui/src/analytics/dimensions.ts', 'event', 'rowsBooked', 7],
388
+ ['../../webbportalen-ui/src/analytics/dimensions.ts', 'session', 'organizationName', 26],
389
+ ['../../feelgood-app/src/util/piwikDimensions.ts', 'event', 'eventTimestamp', 78],
390
+ ]
391
+ for (const [relative, scope, key, slot] of files) {
392
+ const file = path.join(scriptDir, relative)
393
+ if (!fs.existsSync(file)) continue
394
+ const dimensions = parseDimensionSource(fs.readFileSync(file, 'utf8'), relative)
395
+ const found = dimensions.find((item) => item.key === key)
396
+ assert.ok(found, `${key} missing in ${relative}`)
397
+ assert.equal(found.scope, scope)
398
+ assert.equal(found.slot, slot)
399
+ }
400
+ })