@antelopejs/dms-marketing 0.4.0 → 0.5.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.
Files changed (29) hide show
  1. package/dist/hooks/index.js +1 -5
  2. package/dist/hooks/index.js.map +1 -1
  3. package/dist/index.js +2 -1
  4. package/dist/index.js.map +1 -1
  5. package/dist/pages/funnels/page.js +5 -5
  6. package/dist/pages/funnels/page.js.map +1 -1
  7. package/dist/routes/public-endpoints.d.ts +2 -0
  8. package/dist/routes/public-endpoints.js +14 -0
  9. package/dist/routes/public-endpoints.js.map +1 -0
  10. package/frontend-vue/app/components/Context.vue +37 -61
  11. package/frontend-vue/app/components/FunnelBuilder.vue +22 -19
  12. package/frontend-vue/app/components/FunnelReport.vue +1 -1
  13. package/frontend-vue/app/components/FunnelTemplates.vue +16 -27
  14. package/frontend-vue/app/components/InstallGuide.vue +40 -57
  15. package/frontend-vue/app/components/PagePreview.vue +60 -76
  16. package/frontend-vue/app/components/PagesExplorer.vue +86 -80
  17. package/frontend-vue/app/components/WebsitesGrid.vue +32 -23
  18. package/frontend-vue/app/composables/useMarketingApi.ts +7 -120
  19. package/frontend-vue/app/composables/useMarketingContext.ts +1 -10
  20. package/frontend-vue/app/composables/useMarketingNow.ts +29 -0
  21. package/frontend-vue/app/composables/useMarketingReads.ts +314 -0
  22. package/frontend-vue/app/utils/format.ts +37 -1
  23. package/frontend-vue/package.json +1 -2
  24. package/frontend-vue/pnpm-lock.yaml +51 -19
  25. package/frontend-vue/tests/format.test.ts +21 -0
  26. package/frontend-vue/tests/marketing-reads.test.ts +252 -0
  27. package/frontend-vue/tests/registration.test.ts +0 -4
  28. package/frontend-vue/vitest.config.ts +3 -1
  29. package/package.json +3 -3
@@ -101,10 +101,46 @@ export function formatDateRange(from: Date, to: Date, locale: string): string {
101
101
  }).formatRange(from, to)
102
102
  }
103
103
 
104
- export function formatShortDate(at: number | Date, locale: string): string {
104
+ /**
105
+ * When the statistics' day (a UTC day) starts on the reader's clock today,
106
+ * "02:00"; empty where it starts at their midnight. `timeZone` is the
107
+ * reader's (`undefined`: the zone the code runs in).
108
+ */
109
+ export function utcDayStart(
110
+ now: number,
111
+ locale: string,
112
+ timeZone?: string,
113
+ ): string {
114
+ const today = new Date(now)
115
+ const utcMidnight = new Date(
116
+ Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate()),
117
+ )
118
+ const clock = new Intl.DateTimeFormat('en-US', {
119
+ hour: 'numeric',
120
+ minute: 'numeric',
121
+ hourCycle: 'h23',
122
+ timeZone,
123
+ }).format(utcMidnight)
124
+ if (clock === '0:00' || clock === '00:00') {
125
+ return ''
126
+ }
127
+ return new Intl.DateTimeFormat(locale, {
128
+ hour: '2-digit',
129
+ minute: '2-digit',
130
+ timeZone,
131
+ }).format(utcMidnight)
132
+ }
133
+
134
+ /** "Sep 29", in `timeZone` (`undefined`: the zone the code runs in). */
135
+ export function formatShortDate(
136
+ at: number | Date,
137
+ locale: string,
138
+ timeZone?: string,
139
+ ): string {
105
140
  return new Intl.DateTimeFormat(locale, {
106
141
  month: 'short',
107
142
  day: 'numeric',
143
+ timeZone,
108
144
  }).format(at)
109
145
  }
110
146
 
@@ -7,7 +7,7 @@
7
7
  "license": "Apache-2.0",
8
8
  "packageManager": "pnpm@10.6.5",
9
9
  "engines": {
10
- "@antelopejs/dms-frontend": ">=0.5.2 <0.6.0"
10
+ "@antelopejs/dms-frontend": ">=0.6.0 <0.7.0"
11
11
  },
12
12
  "scripts": {
13
13
  "dev": "pnpm --dir ../playground frontend:dev",
@@ -24,7 +24,6 @@
24
24
  "@vitejs/plugin-vue": "^6.0.0",
25
25
  "eslint": "^9.0.0",
26
26
  "eslint-plugin-vue": "^10.0.0",
27
- "jsdom": "^26",
28
27
  "prettier": "^3.5.3",
29
28
  "typescript": "^5.9.3",
30
29
  "typescript-eslint": "^8.0.0",
@@ -24,9 +24,6 @@ importers:
24
24
  eslint-plugin-vue:
25
25
  specifier: ^10.0.0
26
26
  version: 10.11.0(@typescript-eslint/parser@8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0))(vue-eslint-parser@10.4.1(eslint@9.39.5(jiti@2.7.0)))
27
- jsdom:
28
- specifier: ^26
29
- version: 26.1.0
30
27
  prettier:
31
28
  specifier: ^3.5.3
32
29
  version: 3.9.7
@@ -2653,6 +2650,7 @@ snapshots:
2653
2650
  '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4)
2654
2651
  '@csstools/css-tokenizer': 3.0.4
2655
2652
  lru-cache: 10.4.3
2653
+ optional: true
2656
2654
 
2657
2655
  '@babel/helper-string-parser@7.29.7': {}
2658
2656
 
@@ -2671,12 +2669,14 @@ snapshots:
2671
2669
  dependencies:
2672
2670
  fontkitten: 1.0.3
2673
2671
 
2674
- '@csstools/color-helpers@5.1.0': {}
2672
+ '@csstools/color-helpers@5.1.0':
2673
+ optional: true
2675
2674
 
2676
2675
  '@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)':
2677
2676
  dependencies:
2678
2677
  '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4)
2679
2678
  '@csstools/css-tokenizer': 3.0.4
2679
+ optional: true
2680
2680
 
2681
2681
  '@csstools/css-color-parser@3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)':
2682
2682
  dependencies:
@@ -2684,12 +2684,15 @@ snapshots:
2684
2684
  '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)
2685
2685
  '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4)
2686
2686
  '@csstools/css-tokenizer': 3.0.4
2687
+ optional: true
2687
2688
 
2688
2689
  '@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4)':
2689
2690
  dependencies:
2690
2691
  '@csstools/css-tokenizer': 3.0.4
2692
+ optional: true
2691
2693
 
2692
- '@csstools/css-tokenizer@3.0.4': {}
2694
+ '@csstools/css-tokenizer@3.0.4':
2695
+ optional: true
2693
2696
 
2694
2697
  '@esbuild/aix-ppc64@0.27.7':
2695
2698
  optional: true
@@ -3830,7 +3833,8 @@ snapshots:
3830
3833
 
3831
3834
  acorn@8.18.0: {}
3832
3835
 
3833
- agent-base@7.1.4: {}
3836
+ agent-base@7.1.4:
3837
+ optional: true
3834
3838
 
3835
3839
  ajv@6.15.0:
3836
3840
  dependencies:
@@ -3946,6 +3950,7 @@ snapshots:
3946
3950
  dependencies:
3947
3951
  '@asamuzakjp/css-color': 3.2.0
3948
3952
  rrweb-cssom: 0.8.0
3953
+ optional: true
3949
3954
 
3950
3955
  csstype@3.2.3: {}
3951
3956
 
@@ -3953,12 +3958,14 @@ snapshots:
3953
3958
  dependencies:
3954
3959
  whatwg-mimetype: 4.0.0
3955
3960
  whatwg-url: 14.2.0
3961
+ optional: true
3956
3962
 
3957
3963
  debug@4.4.3:
3958
3964
  dependencies:
3959
3965
  ms: 2.1.3
3960
3966
 
3961
- decimal.js@10.6.0: {}
3967
+ decimal.js@10.6.0:
3968
+ optional: true
3962
3969
 
3963
3970
  deep-is@0.1.4: {}
3964
3971
 
@@ -4012,7 +4019,8 @@ snapshots:
4012
4019
  graceful-fs: 4.2.11
4013
4020
  tapable: 2.3.3
4014
4021
 
4015
- entities@6.0.1: {}
4022
+ entities@6.0.1:
4023
+ optional: true
4016
4024
 
4017
4025
  entities@7.0.1: {}
4018
4026
 
@@ -4293,6 +4301,7 @@ snapshots:
4293
4301
  html-encoding-sniffer@4.0.0:
4294
4302
  dependencies:
4295
4303
  whatwg-encoding: 3.1.1
4304
+ optional: true
4296
4305
 
4297
4306
  http-proxy-agent@7.0.2:
4298
4307
  dependencies:
@@ -4300,6 +4309,7 @@ snapshots:
4300
4309
  debug: 4.4.3
4301
4310
  transitivePeerDependencies:
4302
4311
  - supports-color
4312
+ optional: true
4303
4313
 
4304
4314
  https-proxy-agent@7.0.6:
4305
4315
  dependencies:
@@ -4307,12 +4317,14 @@ snapshots:
4307
4317
  debug: 4.4.3
4308
4318
  transitivePeerDependencies:
4309
4319
  - supports-color
4320
+ optional: true
4310
4321
 
4311
4322
  human-signals@5.0.0: {}
4312
4323
 
4313
4324
  iconv-lite@0.6.3:
4314
4325
  dependencies:
4315
4326
  safer-buffer: 2.1.2
4327
+ optional: true
4316
4328
 
4317
4329
  ignore@5.3.2: {}
4318
4330
 
@@ -4335,7 +4347,8 @@ snapshots:
4335
4347
  dependencies:
4336
4348
  is-extglob: 2.1.1
4337
4349
 
4338
- is-potential-custom-element-name@1.0.1: {}
4350
+ is-potential-custom-element-name@1.0.1:
4351
+ optional: true
4339
4352
 
4340
4353
  is-stream@3.0.0: {}
4341
4354
 
@@ -4377,6 +4390,7 @@ snapshots:
4377
4390
  - bufferutil
4378
4391
  - supports-color
4379
4392
  - utf-8-validate
4393
+ optional: true
4380
4394
 
4381
4395
  json-buffer@3.0.1: {}
4382
4396
 
@@ -4513,7 +4527,8 @@ snapshots:
4513
4527
 
4514
4528
  lodash.merge@4.6.2: {}
4515
4529
 
4516
- lru-cache@10.4.3: {}
4530
+ lru-cache@10.4.3:
4531
+ optional: true
4517
4532
 
4518
4533
  lru-cache@11.5.2: {}
4519
4534
 
@@ -4600,7 +4615,8 @@ snapshots:
4600
4615
  dependencies:
4601
4616
  boolbase: 1.0.0
4602
4617
 
4603
- nwsapi@2.2.27: {}
4618
+ nwsapi@2.2.27:
4619
+ optional: true
4604
4620
 
4605
4621
  obug@2.2.1: {}
4606
4622
 
@@ -4649,6 +4665,7 @@ snapshots:
4649
4665
  parse5@7.3.0:
4650
4666
  dependencies:
4651
4667
  entities: 6.0.1
4668
+ optional: true
4652
4669
 
4653
4670
  path-exists@4.0.0: {}
4654
4671
 
@@ -4823,13 +4840,16 @@ snapshots:
4823
4840
 
4824
4841
  rope-sequence@1.3.4: {}
4825
4842
 
4826
- rrweb-cssom@0.8.0: {}
4843
+ rrweb-cssom@0.8.0:
4844
+ optional: true
4827
4845
 
4828
- safer-buffer@2.1.2: {}
4846
+ safer-buffer@2.1.2:
4847
+ optional: true
4829
4848
 
4830
4849
  saxes@6.0.0:
4831
4850
  dependencies:
4832
4851
  xmlchars: 2.2.0
4852
+ optional: true
4833
4853
 
4834
4854
  scule@1.3.0: {}
4835
4855
 
@@ -4869,7 +4889,8 @@ snapshots:
4869
4889
  dependencies:
4870
4890
  has-flag: 4.0.0
4871
4891
 
4872
- symbol-tree@3.2.4: {}
4892
+ symbol-tree@3.2.4:
4893
+ optional: true
4873
4894
 
4874
4895
  tailwind-merge@3.7.0: {}
4875
4896
 
@@ -4895,21 +4916,25 @@ snapshots:
4895
4916
 
4896
4917
  tinyrainbow@3.1.1: {}
4897
4918
 
4898
- tldts-core@6.1.86: {}
4919
+ tldts-core@6.1.86:
4920
+ optional: true
4899
4921
 
4900
4922
  tldts@6.1.86:
4901
4923
  dependencies:
4902
4924
  tldts-core: 6.1.86
4925
+ optional: true
4903
4926
 
4904
4927
  totalist@3.0.1: {}
4905
4928
 
4906
4929
  tough-cookie@5.1.2:
4907
4930
  dependencies:
4908
4931
  tldts: 6.1.86
4932
+ optional: true
4909
4933
 
4910
4934
  tr46@5.1.1:
4911
4935
  dependencies:
4912
4936
  punycode: 2.3.1
4937
+ optional: true
4913
4938
 
4914
4939
  ts-api-utils@2.5.0(typescript@5.9.3):
4915
4940
  dependencies:
@@ -5179,21 +5204,26 @@ snapshots:
5179
5204
  w3c-xmlserializer@5.0.0:
5180
5205
  dependencies:
5181
5206
  xml-name-validator: 5.0.0
5207
+ optional: true
5182
5208
 
5183
- webidl-conversions@7.0.0: {}
5209
+ webidl-conversions@7.0.0:
5210
+ optional: true
5184
5211
 
5185
5212
  webpack-virtual-modules@0.6.2: {}
5186
5213
 
5187
5214
  whatwg-encoding@3.1.1:
5188
5215
  dependencies:
5189
5216
  iconv-lite: 0.6.3
5217
+ optional: true
5190
5218
 
5191
- whatwg-mimetype@4.0.0: {}
5219
+ whatwg-mimetype@4.0.0:
5220
+ optional: true
5192
5221
 
5193
5222
  whatwg-url@14.2.0:
5194
5223
  dependencies:
5195
5224
  tr46: 5.1.1
5196
5225
  webidl-conversions: 7.0.0
5226
+ optional: true
5197
5227
 
5198
5228
  wheel-gestures@2.3.0: {}
5199
5229
 
@@ -5208,11 +5238,13 @@ snapshots:
5208
5238
 
5209
5239
  word-wrap@1.2.5: {}
5210
5240
 
5211
- ws@8.21.3: {}
5241
+ ws@8.21.3:
5242
+ optional: true
5212
5243
 
5213
5244
  xml-name-validator@5.0.0: {}
5214
5245
 
5215
- xmlchars@2.2.0: {}
5246
+ xmlchars@2.2.0:
5247
+ optional: true
5216
5248
 
5217
5249
  y-protocols@1.0.7(yjs@13.6.32):
5218
5250
  dependencies:
@@ -2,9 +2,11 @@ import { describe, expect, it } from 'vitest'
2
2
  import {
3
3
  displayName,
4
4
  formatDuration,
5
+ formatShortDate,
5
6
  formatSignedDelta,
6
7
  initials,
7
8
  percentChangeTrend,
9
+ utcDayStart,
8
10
  } from '../app/utils/format'
9
11
  import { sampleSizePerArm } from '../app/utils/sample-size'
10
12
  import { normalizeFunnelStepPath } from '../app/utils/step-path'
@@ -71,3 +73,22 @@ describe('helpers', () => {
71
73
  ).toContain('"data-website-id": "site-1"')
72
74
  })
73
75
  })
76
+
77
+ describe('dates in the reader zone', () => {
78
+ // 16 Oct 2026: Paris on summer time (UTC+2), New York on UTC-4.
79
+ const now = Date.UTC(2026, 9, 16, 12)
80
+
81
+ it('says when the statistics day starts on the reader clock, nothing at UTC', () => {
82
+ expect(utcDayStart(now, 'en-GB', 'Europe/Paris')).toBe('02:00')
83
+ expect(utcDayStart(now, 'en-GB', 'America/New_York')).toBe('20:00')
84
+ expect(utcDayStart(now, 'en-GB', 'UTC')).toBe('')
85
+ })
86
+
87
+ it('writes a short date on the reader day, wherever the code runs', () => {
88
+ const lateEvening = Date.UTC(2026, 9, 16, 2)
89
+ expect(formatShortDate(lateEvening, 'en', 'Europe/Paris')).toBe('Oct 16')
90
+ expect(formatShortDate(lateEvening, 'en', 'America/New_York')).toBe(
91
+ 'Oct 15',
92
+ )
93
+ })
94
+ })
@@ -0,0 +1,252 @@
1
+ import { createSSRApp, defineComponent, h, ref, toValue } from 'vue'
2
+ import { renderToString } from 'vue/server-renderer'
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
4
+ import {
5
+ useFunnelTemplates,
6
+ useHeatmapCountRead,
7
+ useMarketingContextRead,
8
+ usePageHeatmap,
9
+ usePageInventory,
10
+ usePageSnapshot,
11
+ useWebsiteConnection,
12
+ useWebsiteFunnels,
13
+ } from '../app/composables/useMarketingReads'
14
+ import { useMarketingNow } from '../app/composables/useMarketingNow'
15
+
16
+ /** What a read asked `useApiData` for. */
17
+ interface ReadCall {
18
+ url: () => string | null
19
+ options: Record<string, unknown>
20
+ }
21
+
22
+ let calls: ReadCall[]
23
+
24
+ const PERIOD = {
25
+ preset: 'last-7-days',
26
+ comparison: 'none',
27
+ range: {
28
+ from: new Date('2026-10-09T00:00:00.000Z'),
29
+ to: new Date('2026-10-15T23:59:59.999Z'),
30
+ },
31
+ compareRange: null,
32
+ key: 'last-7-days',
33
+ }
34
+
35
+ beforeEach(() => {
36
+ calls = []
37
+ vi.stubGlobal(
38
+ 'useApiData',
39
+ (url: unknown, options: Record<string, unknown> = {}) => {
40
+ calls.push({ url: () => toValue(url as () => string | null), options })
41
+ return { data: ref(null) }
42
+ },
43
+ )
44
+ // The DMS's own, as the module's blocks append a period.
45
+ vi.stubGlobal(
46
+ 'appendPeriodToUrl',
47
+ (url: string, state: typeof PERIOD | null) =>
48
+ state?.range
49
+ ? `${url}?from=${state.range.from.toISOString()}&to=${state.range.to.toISOString()}`
50
+ : url,
51
+ )
52
+ })
53
+
54
+ afterEach(() => {
55
+ vi.unstubAllGlobals()
56
+ })
57
+
58
+ const query = (call: ReadCall | undefined) =>
59
+ toValue(call?.options.query as () => Record<string, unknown>)
60
+
61
+ describe('the reads the pages render', () => {
62
+ it('reads nothing bound to the period before there is one, nor without a website', () => {
63
+ const website = ref('')
64
+ const period = ref<typeof PERIOD | null>(null)
65
+ usePageInventory({
66
+ websiteId: website,
67
+ period,
68
+ search: '',
69
+ sort: 'views',
70
+ })
71
+ useFunnelTemplates(website, period)
72
+ const [pages, templates] = calls
73
+ expect(pages?.url()).toBeNull()
74
+ expect(templates?.url()).toBeNull()
75
+ website.value = 'acme-shop'
76
+ expect(pages?.url()).toBeNull()
77
+ period.value = PERIOD
78
+ expect(pages?.url()).toBe(
79
+ '/api/marketing/stats/pages?from=2026-10-09T00:00:00.000Z&to=2026-10-15T23:59:59.999Z',
80
+ )
81
+ expect(query(pages)).toEqual({
82
+ website: 'acme-shop',
83
+ search: '',
84
+ sort: 'views',
85
+ })
86
+ expect(templates?.url()).toContain('/api/marketing/funnels/templates?from=')
87
+ expect(query(templates)).toEqual({ website: 'acme-shop' })
88
+ })
89
+
90
+ it('lists a website funnels oldest first, read again with the page refresh', () => {
91
+ useWebsiteFunnels('acme-shop')
92
+ const [funnels] = calls
93
+ expect(funnels?.url()).toBe('/api/marketing/tables/funnels/list')
94
+ expect(query(funnels)).toMatchObject({
95
+ filter_websiteId: 'is:acme-shop',
96
+ sortKey: 'createdAt',
97
+ sortDirection: 'asc',
98
+ })
99
+ // No trigger of its own: the page's refreshes read it again.
100
+ expect(funnels?.options.watch).toBeUndefined()
101
+ expect(funnels?.options.refreshOnPageRefresh).toBeUndefined()
102
+ const transform = funnels?.options.transform as (value: unknown) => unknown
103
+ expect(transform({ results: [{ _id: 'f1' }], total: 1 })).toEqual([
104
+ { _id: 'f1' },
105
+ ])
106
+ })
107
+
108
+ it('checks a website connection since a server time, once there is a website', () => {
109
+ const website = ref('')
110
+ const since = ref<number | null>(null)
111
+ useWebsiteConnection(website, since)
112
+ const [connection] = calls
113
+ expect(connection?.url()).toBeNull()
114
+ website.value = 'shop/eu'
115
+ expect(connection?.url()).toBe(
116
+ '/api/marketing/websites/shop%2Feu/connection',
117
+ )
118
+ expect(query(connection)).toEqual({ since: undefined })
119
+ since.value = 1_700_000_000_000
120
+ expect(query(connection)).toEqual({ since: 1_700_000_000_000 })
121
+ })
122
+ })
123
+
124
+ describe('useMarketingNow', () => {
125
+ it('starts from the time the server rendered at, handed to the browser', async () => {
126
+ const state = new Map<string, { value: unknown }>()
127
+ const transferred: string[] = []
128
+ vi.stubGlobal(
129
+ 'useDmsState',
130
+ (key: string, init: () => unknown, options?: { transfer?: boolean }) => {
131
+ if (options?.transfer) {
132
+ transferred.push(key)
133
+ }
134
+ if (!state.has(key)) {
135
+ state.set(key, ref(init()))
136
+ }
137
+ return state.get(key)
138
+ },
139
+ )
140
+ state.set('marketing-rendered-at', ref(1_000))
141
+ const app = createSSRApp(
142
+ defineComponent({
143
+ setup: () => {
144
+ const now = useMarketingNow(15_000)
145
+ return () => h('span', String(now.value))
146
+ },
147
+ }),
148
+ )
149
+ expect(await renderToString(app)).toBe('<span>1000</span>')
150
+ expect(transferred).toEqual(['marketing-rendered-at'])
151
+ })
152
+ })
153
+
154
+ describe('the reads with triggers of their own', () => {
155
+ it('reads the context bar on its own clock, not with the page', () => {
156
+ useMarketingContextRead({ refreshInterval: 60_000 })
157
+ const [context] = calls
158
+ expect(context?.options).toMatchObject({
159
+ refreshOnPageRefresh: false,
160
+ refreshInterval: 60_000,
161
+ })
162
+ })
163
+
164
+ it('polls the connection check when asked to', () => {
165
+ useWebsiteConnection('acme-shop', null, { refreshInterval: 4000 })
166
+ const [connection] = calls
167
+ expect(connection?.options.refreshInterval).toBe(4000)
168
+ })
169
+
170
+ it('follows a reactive polling period, which stops the check at 0', () => {
171
+ const interval = ref(4000)
172
+ useWebsiteConnection('acme-shop', null, { refreshInterval: interval })
173
+ // One read: the polling is the check's own interval, not a second caller.
174
+ expect(calls).toHaveLength(1)
175
+ const [connection] = calls
176
+ const polling = () => toValue(connection?.options.refreshInterval as number)
177
+ expect(polling()).toBe(4000)
178
+ interval.value = 0
179
+ expect(polling()).toBe(0)
180
+ })
181
+ })
182
+
183
+ describe('the page preview', () => {
184
+ it('reads the clicks in the browser only, once there is a site and a path', () => {
185
+ const website = ref<string | undefined>(undefined)
186
+ const period = ref<typeof PERIOD | null>(null)
187
+ usePageHeatmap({ websiteId: website, path: '/pricing', period })
188
+ const [heatmap] = calls
189
+ expect(heatmap?.options.server).toBe(false)
190
+ expect(heatmap?.url()).toBeNull()
191
+ website.value = 'acme-shop'
192
+ // Without a period, the route's default window.
193
+ expect(heatmap?.url()).toBe('/api/marketing/stats/heatmap')
194
+ period.value = PERIOD
195
+ expect(heatmap?.url()).toBe(
196
+ '/api/marketing/stats/heatmap?from=2026-10-09T00:00:00.000Z&to=2026-10-15T23:59:59.999Z',
197
+ )
198
+ expect(query(heatmap)).toEqual({ website: 'acme-shop', path: '/pricing' })
199
+ })
200
+
201
+ it('reads no capture while the site keeps none', () => {
202
+ const enabled = ref(false)
203
+ usePageSnapshot({
204
+ websiteId: 'acme-shop',
205
+ path: '/pricing',
206
+ layout: 'phone',
207
+ enabled,
208
+ })
209
+ const [snapshot] = calls
210
+ expect(snapshot?.url()).toBeNull()
211
+ enabled.value = true
212
+ expect(snapshot?.url()).toBe('/api/marketing/stats/snapshot')
213
+ expect(query(snapshot)).toEqual({
214
+ website: 'acme-shop',
215
+ path: '/pricing',
216
+ layout: 'phone',
217
+ })
218
+ })
219
+ })
220
+
221
+ describe('the reads a dialog makes when it asks', () => {
222
+ it('reads nothing before, then answers or rejects with the read', async () => {
223
+ const data = ref<unknown>(null)
224
+ const error = ref<unknown>(null)
225
+ const execute = vi.fn(async () => {
226
+ data.value = { events: 3, snapshots: 1 }
227
+ })
228
+ vi.stubGlobal(
229
+ 'useApiData',
230
+ (url: unknown, options: Record<string, unknown> = {}) => {
231
+ calls.push({ url: () => toValue(url as () => string | null), options })
232
+ return { data, error, execute }
233
+ },
234
+ )
235
+ const countHeatmap = useHeatmapCountRead('acme-shop', '/pricing')
236
+ const [count] = calls
237
+ expect(count?.options).toMatchObject({
238
+ immediate: false,
239
+ refreshOnPageRefresh: false,
240
+ })
241
+ expect(execute).not.toHaveBeenCalled()
242
+ expect(count?.url()).toBe('/api/marketing/stats/heatmap/count')
243
+ expect(query(count)).toEqual({ website: 'acme-shop', path: '/pricing' })
244
+ await expect(countHeatmap()).resolves.toEqual({ events: 3, snapshots: 1 })
245
+
246
+ const failure = new Error('gone')
247
+ execute.mockImplementationOnce(async () => {
248
+ error.value = failure
249
+ })
250
+ await expect(countHeatmap()).rejects.toBe(failure)
251
+ })
252
+ })
@@ -22,10 +22,6 @@ const BACKEND_ADDRESSED = [
22
22
  'ExperimentInput',
23
23
  ]
24
24
 
25
- it('declares the DmsMarketing prefix', () => {
26
- expect(frontendModule.componentPrefix).toBe('DmsMarketing')
27
- })
28
-
29
25
  it('registers every backend-addressed component, once, without the prefix', async () => {
30
26
  const registerComponent = vi.fn()
31
27
  const registerPlugin = vi.fn()
@@ -14,7 +14,9 @@ export default defineConfig({
14
14
  },
15
15
  },
16
16
  test: {
17
- environment: 'jsdom',
17
+ // No test touches the DOM: the suites drive composables, pure helpers
18
+ // and the module's setup. jsdom only added its boot to every file.
19
+ environment: 'node',
18
20
  include: ['tests/**/*.test.ts'],
19
21
  },
20
22
  })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@antelopejs/dms-marketing",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "license": "Apache-2.0",
5
5
  "repository": {
6
6
  "type": "git",
@@ -34,7 +34,7 @@
34
34
  "@antelopejs/interface-data-api": ">=0.2.0 <1.0.0",
35
35
  "@antelopejs/interface-database": ">=0.1.8 <1.0.0",
36
36
  "@antelopejs/interface-database-decorators": ">=0.1.7 <1.0.0",
37
- "@antelopejs/interface-dms": ">=0.5.11 <1.0.0",
37
+ "@antelopejs/interface-dms": ">=0.7.0 <1.0.0",
38
38
  "@antelopejs/interface-dms-marketing": ">=0.4.0 <0.5.0",
39
39
  "defu": "^6.1.4",
40
40
  "maxmind": "^5.0.7",
@@ -76,7 +76,7 @@
76
76
  "scripts": {
77
77
  "build": "pnpm --filter @antelopejs/interface-dms-marketing build && rimraf dist && tsc",
78
78
  "test": "pnpm run test:backend && pnpm run test:layer",
79
- "test:backend": "pnpm run build && ajs module test .",
79
+ "test:backend": "([ -n \"$DMS_PREBUILT\" ] || pnpm run build) && ajs module test .",
80
80
  "test:layer": "cd frontend-vue && pnpm test",
81
81
  "dev": "ajs project run -w -p playground",
82
82
  "frontend:dev": "cd playground && pnpm frontend:dev",