@designcrowd/library.brand-page 5.17.25-undo-21 → 5.17.25

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 (115) hide show
  1. package/lib/src/{CreatedOnBrandCrowd-de-de-Bze5WUip.js → CreatedOnBrandCrowd-de-de-9YvB5ocm.js} +1 -1
  2. package/lib/src/{CreatedOnBrandCrowd-en-us-Dw5uSnX8.js → CreatedOnBrandCrowd-en-us-B8OpZpMP.js} +1 -1
  3. package/lib/src/{CreatedOnBrandCrowd-es-es-Cz-oPdjt.js → CreatedOnBrandCrowd-es-es-D_d6SwvE.js} +1 -1
  4. package/lib/src/{CreatedOnBrandCrowd-fr-fr-zldLXw3G.js → CreatedOnBrandCrowd-fr-ca-CPqNeevd.js} +1 -1
  5. package/lib/src/{CreatedOnBrandCrowd-fr-ca-zldLXw3G.js → CreatedOnBrandCrowd-fr-fr-CPqNeevd.js} +1 -1
  6. package/lib/src/{CreatedOnBrandCrowd-pt-pt-64QJpEaD.js → CreatedOnBrandCrowd-pt-br-D0DpxtjY.js} +1 -1
  7. package/lib/src/{CreatedOnBrandCrowd-pt-br-64QJpEaD.js → CreatedOnBrandCrowd-pt-pt-D0DpxtjY.js} +1 -1
  8. package/lib/src/{CreatedOnDcom-de-de-8SXOvjEH.js → CreatedOnDcom-de-de-BLKiD1pl.js} +1 -1
  9. package/lib/src/{CreatedOnDcom-en-us-ClZBYY_t.js → CreatedOnDcom-en-us-DGNjFV5B.js} +1 -1
  10. package/lib/src/{CreatedOnDcom-es-es-xg4ZwvEb.js → CreatedOnDcom-es-es-C2IDMZZe.js} +1 -1
  11. package/lib/src/{CreatedOnDcom-fr-ca-4zo2gxc1.js → CreatedOnDcom-fr-ca-ZRxz3QKj.js} +1 -1
  12. package/lib/src/{CreatedOnDcom-fr-fr-Dw9Fuy7U.js → CreatedOnDcom-fr-fr-5bE7oSdF.js} +1 -1
  13. package/lib/src/{CreatedOnDcom-pt-br-Dbl0EWcU.js → CreatedOnDcom-pt-br-WCumEx63.js} +1 -1
  14. package/lib/src/{CreatedOnDcom-pt-pt-Bps73oQE.js → CreatedOnDcom-pt-pt-C8Hinnmg.js} +1 -1
  15. package/lib/src/brand-page-maker/components/action-bar/ActionBar.mixin.d.ts +2 -7
  16. package/lib/src/brand-page-maker/components/brand-page-maker/BrandPageMaker.mixin.d.ts +2 -7
  17. package/lib/src/brand-page-maker/components/brand-page-maker/BrandPageMaker.mixin.js +0 -10
  18. package/lib/src/brand-page-maker/components/brand-page-maker/BrandPageMakerTemplateSettings.mixin.d.ts +2 -7
  19. package/lib/src/brand-page-maker/components/brand-page-maker/__fixtures__/brand-page-examples.js +0 -1
  20. package/lib/src/brand-page-maker/components/brandpage-maker-page/BrandPageMakerPage.mixin.d.ts +2 -23
  21. package/lib/src/brand-page-maker/components/brandpage-settings/ReplaceImageSettingsForBrandPageSettings.mixin.d.ts +2 -7
  22. package/lib/src/brand-page-maker/components/quick-link-bar/QuickLinkBar.mixin.d.ts +0 -3
  23. package/lib/src/brand-page-maker/components/quick-link-bar/QuickLinkBar.mixin.js +0 -11
  24. package/lib/src/brand-page-maker/components/seo-settings/SeoPageSettings.mixin.d.ts +2 -7
  25. package/lib/src/brand-page-maker/components/seo-settings/UrlSettings.mixin.d.ts +2 -7
  26. package/lib/src/brand-page-maker/components/social-share/SocialShare.mixin.d.ts +2 -7
  27. package/lib/src/brand-page-maker/components/template-preview/TemplatePreview.mixin.d.ts +2 -7
  28. package/lib/src/brand-page-maker/composables/ai-chat/useAiWebsiteBuilderChat.js +0 -9
  29. package/lib/src/brand-page-maker/composables/rich-text-editor/useRichTextEditor.js +1 -17
  30. package/lib/src/brand-page-maker/composables/useEventTracker.d.ts +0 -20
  31. package/lib/src/brand-page-maker/composables/useEventTracker.js +0 -17
  32. package/lib/src/brand-page-maker/stores/ai-thread/ai-website-builder-thread-store.d.ts +1 -1
  33. package/lib/src/brand-page-maker/stores/brandpage-maker/brandpage-maker-store.d.ts +2 -41
  34. package/lib/src/brand-page-maker/stores/brandpage-maker/brandpage-maker-store.js +5 -247
  35. package/lib/src/brand-page-maker/stores/logo-store/logo-store.d.ts +0 -1
  36. package/lib/src/brand-page-maker/stores/user/user-store.js +0 -1
  37. package/lib/src/bundled-translations-Bu_5FO26.js +173 -0
  38. package/lib/src/bundles/bundled-translations.json +1 -3
  39. package/lib/src/constants/ui.d.ts +0 -1
  40. package/lib/src/css/library.brand-page-brandCrowd.min.css +17 -22
  41. package/lib/src/css/library.brand-page-brandPage.min.css +18 -22
  42. package/lib/src/css/library.brand-page-designCom.min.css +17 -22
  43. package/lib/src/css/library.brand-page-preview.min.css +17 -22
  44. package/lib/src/index.mjs +1 -1
  45. package/lib/src/{lib-BgvZxeSl.js → lib-BF9ONIgx.js} +23292 -23913
  46. package/lib/src/shared/content-settings/components/GenericTextInput.mixin.d.ts +1 -1
  47. package/lib/src/shared/content-settings/components/content-settings/ContentSettings.mixin.d.ts +2 -23
  48. package/lib/src/shared/content-settings/components/content-settings/ContentSettings.stories.js +0 -1
  49. package/lib/src/shared/content-settings/components/content-settings/ReplaceImageSettingsForContent.mixin.d.ts +2 -23
  50. package/lib/src/shared/content-settings/components/content-settings-item/ContentSettingsItem.mixin.d.ts +0 -16
  51. package/lib/src/shared/content-settings/components/content-settings-item/ContentSettingsItemField.mixin.d.ts +2 -7
  52. package/lib/src/shared/content-settings/components/draggable-item-list/DraggableItemList.mixin.d.ts +0 -3
  53. package/lib/src/shared/content-settings/components/draggable-item-list/DraggableItemList.mixin.js +2 -19
  54. package/lib/src/shared/content-settings/components/email-field/EmailField.mixin.d.ts +1 -1
  55. package/lib/src/shared/content-settings/components/form-fields-field/FormFieldsField.mixin.js +8 -18
  56. package/lib/src/shared/content-settings/components/radio-field/RadioField.mixin.d.ts +1 -1
  57. package/lib/src/shared/content-settings/components/tel-field/TelField.mixin.d.ts +1 -1
  58. package/lib/src/shared/content-settings/components/text-field/TextField.mixin.d.ts +1 -1
  59. package/lib/src/shared/content-settings/components/textarea-field/TextareaField.mixin.d.ts +1 -1
  60. package/lib/src/shared/content-settings/components/url-field/UrlField.mixin.d.ts +1 -1
  61. package/lib/src/shared/content-settings/components/youtube-video-field/YoutubeVideoField.mixin.d.ts +1 -1
  62. package/lib/src/shared/logo-settings/components/EditLogoSettings.mixin.d.ts +0 -18
  63. package/lib/src/shared/logo-settings/components/EditLogoSettings.mixin.js +0 -11
  64. package/lib/src/shared/logo-settings/components/LogoSettings.mixin.d.ts +2 -9
  65. package/lib/src/shared/logo-settings/store/editable-logo-store.d.ts +1 -9
  66. package/lib/src/shared/logo-settings/store/editable-logo-store.js +0 -33
  67. package/lib/src/shared/page-settings/components/page-settings/PageSettings.mixin.d.ts +2 -24
  68. package/lib/src/shared/page-settings/components/page-settings-section-styles/PageSettingsSectionStyles.mixin.d.ts +2 -9
  69. package/lib/src/shared/publish-brand-page-with-state/PublishBrandPageModalWithState.mixin.d.ts +2 -23
  70. package/lib/src/shared/side-bar/components/SideBarTabButton.mixin.d.ts +0 -16
  71. package/lib/src/shared/style-settings/components/StyleSettings.mixin.d.ts +2 -23
  72. package/lib/src/shared/template-settings/components/TemplatesSettings.mixin.d.ts +0 -16
  73. package/package.json +1 -1
  74. package/src/brand-page/components/video/Video.vue +0 -2
  75. package/src/brand-page-maker/components/brand-page-maker/BrandPageMaker.mixin.ts +0 -11
  76. package/src/brand-page-maker/components/brand-page-maker/BrandPageMaker.vue +3 -2
  77. package/src/brand-page-maker/components/brand-page-maker/__fixtures__/brand-page-examples.ts +0 -1
  78. package/src/brand-page-maker/components/cookie-collection/CookieSettings.vue +17 -18
  79. package/src/brand-page-maker/components/quick-link-bar/QuickLinkBar.mixin.ts +0 -13
  80. package/src/brand-page-maker/components/quick-link-bar/QuickLinkBar.vue +4 -14
  81. package/src/brand-page-maker/components/quick-link-bar/i18n/quick-link-bar.json +1 -3
  82. package/src/brand-page-maker/components/save-contact/SaveContact.vue +19 -12
  83. package/src/brand-page-maker/composables/ai-chat/useAiWebsiteBuilderChat.ts +0 -10
  84. package/src/brand-page-maker/composables/rich-text-editor/useRichTextEditor.ts +1 -20
  85. package/src/brand-page-maker/composables/useEventTracker.ts +0 -29
  86. package/src/brand-page-maker/stores/brandpage-maker/brandpage-maker-store.test.ts +0 -166
  87. package/src/brand-page-maker/stores/brandpage-maker/brandpage-maker-store.ts +4 -287
  88. package/src/brand-page-maker/stores/logo-store/logo-store.ts +0 -5
  89. package/src/brand-page-maker/stores/user/user-store.ts +0 -1
  90. package/src/bundles/bundled-translations.json +1 -3
  91. package/src/constants/ui.ts +0 -1
  92. package/src/shared/content-settings/components/content-settings/ContentSettings.stories.ts +0 -1
  93. package/src/shared/content-settings/components/draggable-item-list/DraggableItemList.mixin.ts +2 -19
  94. package/src/shared/content-settings/components/draggable-item-list/DraggableItemList.vue +1 -1
  95. package/src/shared/content-settings/components/form-fields-field/FormFieldsField.mixin.ts +10 -26
  96. package/src/shared/content-settings/components/logo-selection-field/LogoSelection.vue +48 -63
  97. package/src/shared/content-settings/components/text-field/TextField.vue +0 -1
  98. package/src/shared/content-settings/components/text-image-field/TextImageField.vue +4 -33
  99. package/src/shared/logo-settings/components/EditLogoSettings.mixin.ts +0 -11
  100. package/src/shared/logo-settings/store/editable-logo-store.ts +1 -45
  101. package/lib/src/brand-page-maker/composables/useUndoRedoFlag.d.ts +0 -16
  102. package/lib/src/brand-page-maker/composables/useUndoRedoFlag.js +0 -22
  103. package/lib/src/brand-page-maker/stores/history/history-store.d.ts +0 -104
  104. package/lib/src/brand-page-maker/stores/history/history-store.js +0 -836
  105. package/lib/src/bundled-translations-CWHKs3in.js +0 -173
  106. package/lib/src/metadata-builder/builders/reorder-pages-builder.d.ts +0 -13
  107. package/lib/src/metadata-builder/builders/reorder-pages-builder.js +0 -49
  108. package/src/brand-page-maker/components/quick-link-bar/RedoButton.test.ts +0 -166
  109. package/src/brand-page-maker/components/quick-link-bar/RedoButton.vue +0 -66
  110. package/src/brand-page-maker/components/quick-link-bar/UndoButton.test.ts +0 -166
  111. package/src/brand-page-maker/components/quick-link-bar/UndoButton.vue +0 -66
  112. package/src/brand-page-maker/composables/useUndoRedoFlag.ts +0 -26
  113. package/src/brand-page-maker/stores/history/history-store.test.ts +0 -1246
  114. package/src/brand-page-maker/stores/history/history-store.ts +0 -1041
  115. package/src/metadata-builder/builders/reorder-pages-builder.ts +0 -80
@@ -1,1246 +0,0 @@
1
- import { describe, it, expect, beforeEach, vi } from 'vitest';
2
- import { createPinia, setActivePinia } from 'pinia';
3
- import { useHistoryStore } from './history-store';
4
- import { useEventTracker } from '../../composables/useEventTracker';
5
- import type { BrandPageModel } from '@brand-page';
6
-
7
- // Shared mock maker store — methods are vi.fn() so we can assert calls.
8
- const mockMakerStore = {
9
- brandPageData: {
10
- v: 1,
11
- pages: [],
12
- styles: {},
13
- } as unknown as BrandPageModel,
14
- isBrandPageLoaded: false,
15
- applyHistorySnapshot: vi.fn(async () => undefined),
16
- setIsBrandPageLoaded: vi.fn(),
17
- };
18
-
19
- const mockPageNavigationStore = {
20
- activePageId: '',
21
- };
22
-
23
- vi.mock('../brandpage-maker/brandpage-maker-store', () => ({
24
- useBrandPageMakerStore: vi.fn(() => mockMakerStore),
25
- }));
26
-
27
- vi.mock('../ai-thread/ai-website-builder-thread-store', () => ({
28
- useAiWebsiteBuilderThreadStore: vi.fn(() => ({
29
- isProcessing: false,
30
- })),
31
- }));
32
-
33
- vi.mock('@shared/page-navigation/store/page-navigation-store', () => ({
34
- usePageNavigationStore: vi.fn(() => mockPageNavigationStore),
35
- }));
36
-
37
- // Mutate the mock between captures so each capture produces a distinct
38
- // snapshot. The store now drops byte-identical captures (BP-5506: prevents
39
- // undo/redo steps that do nothing visible), so tests that previously relied
40
- // on capture-without-mutation lining up entries need to bump a field first.
41
- let mutationCounter = 0;
42
-
43
- function mutateMock(): void {
44
- mutationCounter += 1;
45
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
46
- (mockMakerStore.brandPageData as any).v = 1000 + mutationCounter;
47
- }
48
-
49
- function captureMutated(
50
- store: ReturnType<typeof useHistoryStore>,
51
- args: Parameters<typeof store.capture>[0] = {
52
- source: 'manual_edit',
53
- actor: 'user',
54
- },
55
- ) {
56
- mutateMock();
57
-
58
- return store.capture(args);
59
- }
60
-
61
- describe('history-store (single stack + JSON-string snapshots)', () => {
62
- beforeEach(() => {
63
- setActivePinia(createPinia());
64
- mockMakerStore.applyHistorySnapshot.mockClear();
65
- mutationCounter = 0;
66
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
67
- (mockMakerStore.brandPageData as any).v = 1;
68
- mockPageNavigationStore.activePageId = '';
69
- });
70
-
71
- it('captures baseline and initialises the pointer to 0', async () => {
72
- const store = useHistoryStore();
73
-
74
- expect(store.entries.length).toBe(0);
75
- expect(store.currentIndex).toBe(-1);
76
- expect(store.baselineCaptured).toBe(false);
77
-
78
- await store.captureBaseline();
79
-
80
- expect(store.baselineCaptured).toBe(true);
81
- expect(store.entries.length).toBe(1);
82
- expect(store.entries[0].label).toBe('baseline');
83
- expect(store.currentIndex).toBe(0);
84
- });
85
-
86
- it('stores snapshots as JSON strings, not objects', async () => {
87
- const store = useHistoryStore();
88
-
89
- await store.captureBaseline();
90
-
91
- const entry = store.entries[0];
92
-
93
- expect(typeof entry.snapshot).toBe('string');
94
- // Round-trips to the same model via JSON.parse.
95
- expect(JSON.parse(entry.snapshot)).toMatchObject(
96
- mockMakerStore.brandPageData,
97
- );
98
- // sizeBytes matches string length
99
- expect(entry.sizeBytes).toBe(entry.snapshot.length);
100
- });
101
-
102
- it('does not capture baseline twice', async () => {
103
- const store = useHistoryStore();
104
-
105
- await store.captureBaseline();
106
- const firstLength = store.entries.length;
107
-
108
- await store.captureBaseline();
109
-
110
- expect(store.entries.length).toBe(firstLength);
111
- });
112
-
113
- it('capture pushes a new entry and advances the pointer', async () => {
114
- const store = useHistoryStore();
115
-
116
- await store.captureBaseline();
117
- captureMutated(store);
118
-
119
- expect(store.entries.length).toBe(2);
120
- expect(store.currentIndex).toBe(1);
121
-
122
- captureMutated(store);
123
-
124
- expect(store.entries.length).toBe(3);
125
- expect(store.currentIndex).toBe(2);
126
- });
127
-
128
- it('capture truncates any redo tail (FR-11)', async () => {
129
- const store = useHistoryStore();
130
-
131
- await store.captureBaseline();
132
- captureMutated(store); // index 1
133
- captureMutated(store); // index 2
134
- await store.undo(); // currentIndex = 1, entries still length 3
135
- await store.undo(); // currentIndex = 0, entries still length 3
136
-
137
- expect(store.entries.length).toBe(3);
138
- expect(store.canRedo).toBe(true);
139
-
140
- // New capture should truncate entries[1..] and replace with the new one.
141
- captureMutated(store);
142
-
143
- expect(store.entries.length).toBe(2); // baseline + new
144
- expect(store.currentIndex).toBe(1);
145
- expect(store.canRedo).toBe(false);
146
- });
147
-
148
- it('drops byte-identical captures so undo/redo never replays a no-op', async () => {
149
- const store = useHistoryStore();
150
-
151
- await store.captureBaseline();
152
-
153
- // First capture mutates and lands.
154
- const r1 = captureMutated(store);
155
-
156
- expect(r1).toBe('committed');
157
- expect(store.entries.length).toBe(2);
158
-
159
- // Second capture without a mutation should be detected as a no-op
160
- // and skipped — the stack stays at length 2.
161
- const r2 = store.capture({ source: 'manual_edit', actor: 'user' });
162
-
163
- expect(r2).toBe('noop');
164
- expect(store.entries.length).toBe(2);
165
- expect(store.currentIndex).toBe(1);
166
- });
167
-
168
- // BP-5506 phantom-undo dedup: opening a content settings panel can
169
- // mutate `brandPageData` in ways the user can't see (RTE empty-paragraph
170
- // init, lazy textStyle hydration, isModifiedAfterTemplateApplied flip).
171
- // The dedup gate normalizes these out so capture returns 'noop'.
172
- describe('phantom-edit dedup (sidebar-open cascades)', () => {
173
- it('treats RTE empty-paragraph init as a no-op', async () => {
174
- const store = useHistoryStore();
175
-
176
- // Baseline has description = "" (cold start).
177
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
178
- (mockMakerStore.brandPageData as any).pages = [
179
- {
180
- contents: [{ type: 'contact-form', description: '' }],
181
- },
182
- ];
183
- await store.captureBaseline();
184
-
185
- // Sidebar open writes back TipTap's empty-paragraph default.
186
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
187
- (mockMakerStore.brandPageData as any).pages[0].contents[0].description =
188
- '<p style="text-align: center;"></p>';
189
-
190
- const result = store.capture({ source: 'manual_edit', actor: 'user' });
191
-
192
- expect(result).toBe('noop');
193
- expect(store.entries.length).toBe(1);
194
- });
195
-
196
- it('treats lazy textStyle hydration of every resolver-written key as a no-op', async () => {
197
- const store = useHistoryStore();
198
-
199
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
200
- (mockMakerStore.brandPageData as any).pages = [
201
- {
202
- contents: [
203
- {
204
- type: 'contact-form',
205
- titleStyle: { textStyleType: 'titles' },
206
- descriptionStyle: { textStyleType: 'paragraphs' },
207
- },
208
- ],
209
- },
210
- ];
211
- await store.captureBaseline();
212
-
213
- // Settings panel — or canvas section selection — resolves
214
- // textStyleType into the full set of resolver-written fields. The
215
- // dedup keeps `textStyleType` (the authoring intent) and drops
216
- // every resolved key, regardless of which exact keys it was.
217
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
218
- const cf = (mockMakerStore.brandPageData as any).pages[0].contents[0];
219
-
220
- cf.titleStyle.fontSize = '2.25em';
221
- cf.titleStyle.fontFamily = 'Gloock';
222
- cf.titleStyle.fontWeight = 'bold';
223
- cf.titleStyle.fontStyle = 'italic';
224
- cf.titleStyle.letterSpacing = '0';
225
- cf.titleStyle.lineHeight = '1.3';
226
- cf.titleStyle.color = 'rgb(255, 253, 233)';
227
- cf.titleStyle.textDecoration = 'underline';
228
- cf.descriptionStyle.fontSize = '1em';
229
- cf.descriptionStyle.fontFamily = 'Lato';
230
- cf.descriptionStyle.fontWeight = 'normal';
231
- cf.descriptionStyle.letterSpacing = '0';
232
- cf.descriptionStyle.lineHeight = '1.5';
233
- cf.descriptionStyle.color = 'rgb(0, 0, 0)';
234
-
235
- const result = store.capture({ source: 'manual_edit', actor: 'user' });
236
-
237
- expect(result).toBe('noop');
238
- expect(store.entries.length).toBe(1);
239
- });
240
-
241
- it('preserves textAlign on a hydrated *Style as user authoring intent', async () => {
242
- // textAlign is the user's per-instance alignment override — the
243
- // resolver carries it through unchanged, so it must NOT be dropped.
244
- const store = useHistoryStore();
245
-
246
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
247
- (mockMakerStore.brandPageData as any).pages = [
248
- {
249
- contents: [
250
- {
251
- type: 'banner',
252
- titleStyle: { textStyleType: 'titles', textAlign: 'left' },
253
- },
254
- ],
255
- },
256
- ];
257
- await store.captureBaseline();
258
-
259
- // User changes alignment from left to center — that's a real edit
260
- // even though every other field is a hydration shape.
261
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
262
- const banner = (mockMakerStore.brandPageData as any).pages[0].contents[0];
263
-
264
- banner.titleStyle.textAlign = 'center';
265
- banner.titleStyle.fontSize = '2.25em';
266
- banner.titleStyle.fontFamily = 'Gloock';
267
- banner.titleStyle.lineHeight = '1.3';
268
-
269
- const result = store.capture({ source: 'manual_edit', actor: 'user' });
270
-
271
- expect(result).toBe('committed');
272
- expect(store.entries.length).toBe(2);
273
- });
274
-
275
- it('still commits per-content style overrides (no textStyleType)', async () => {
276
- // When the user edits a style via the inline RTE toolbar,
277
- // `useRichTextEditor.ts` clears `textStyleType` (sets it to undefined)
278
- // and writes the full set of fields directly to the per-content
279
- // *Style. Those fields ARE the user's authoring intent, so they
280
- // must be preserved by the dedup.
281
- const store = useHistoryStore();
282
-
283
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
284
- (mockMakerStore.brandPageData as any).pages = [
285
- {
286
- contents: [
287
- {
288
- type: 'banner',
289
- titleStyle: { fontSize: '2.25em', fontFamily: 'Gloock' },
290
- },
291
- ],
292
- },
293
- ];
294
- await store.captureBaseline();
295
-
296
- // User bumps the font size — no textStyleType in the snapshot, so
297
- // the field is treated as a real edit.
298
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
299
- (
300
- mockMakerStore.brandPageData as any
301
- ).pages[0].contents[0].titleStyle.fontSize = '5em';
302
-
303
- const result = store.capture({ source: 'manual_edit', actor: 'user' });
304
-
305
- expect(result).toBe('committed');
306
- expect(store.entries.length).toBe(2);
307
- });
308
-
309
- // BP-5506 follow-up: editing logo text via the Logo sidebar mutates
310
- // `editableLogoStore.logoEdits` (not in brandPageData), then re-renders
311
- // the result into `logoImageBase64Url` on every header/footer +
312
- // per-page logo content. If history captured that base64, undo would
313
- // revert the canvas without touching the sidebar input — the user
314
- // sees the canvas snap back but the sidebar still shows their typed
315
- // value. V1 excludes logo property edits from undo, so dedup must
316
- // collapse base64-only diffs.
317
- it('treats isolated logoImageBase64Url changes as a no-op', async () => {
318
- const store = useHistoryStore();
319
-
320
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
321
- (mockMakerStore.brandPageData as any).pageHeaderDesktop = {
322
- logoImageBase64Url: 'data:image/png;base64,AAAA',
323
- };
324
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
325
- (mockMakerStore.brandPageData as any).pageHeaderMobile = {
326
- logoImageBase64Url: 'data:image/png;base64,AAAA',
327
- };
328
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
329
- (mockMakerStore.brandPageData as any).pageFooterDesktop = {
330
- logoImageBase64Url: 'data:image/png;base64,AAAA',
331
- };
332
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
333
- (mockMakerStore.brandPageData as any).pageFooterMobile = {
334
- logoImageBase64Url: 'data:image/png;base64,AAAA',
335
- };
336
- await store.captureBaseline();
337
-
338
- // Logo text/font/color edit re-renders all four base64 fields.
339
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
340
- const data = mockMakerStore.brandPageData as any;
341
-
342
- data.pageHeaderDesktop.logoImageBase64Url = 'data:image/png;base64,BBBB';
343
- data.pageHeaderMobile.logoImageBase64Url = 'data:image/png;base64,BBBB';
344
- data.pageFooterDesktop.logoImageBase64Url = 'data:image/png;base64,BBBB';
345
- data.pageFooterMobile.logoImageBase64Url = 'data:image/png;base64,BBBB';
346
-
347
- const result = store.capture({ source: 'manual_edit', actor: 'user' });
348
-
349
- expect(result).toBe('noop');
350
- expect(store.entries.length).toBe(1);
351
- });
352
-
353
- // BYO-upload undo MUST still work — that diff is in
354
- // `logoImage.logoDraftToken`, not `logoImageBase64Url`. Stripping the
355
- // base64 must not collapse a draft-token change.
356
- it('still commits when logoImage.logoDraftToken changes (BYO upload)', async () => {
357
- const store = useHistoryStore();
358
-
359
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
360
- (mockMakerStore.brandPageData as any).pageHeaderDesktop = {
361
- logoImage: { logoDraftToken: 'token-original', logoVariation: 0 },
362
- logoImageBase64Url: 'data:image/png;base64,AAAA',
363
- };
364
- await store.captureBaseline();
365
-
366
- // BYO upload swaps both the draft token AND the base64.
367
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
368
- const header = (mockMakerStore.brandPageData as any).pageHeaderDesktop;
369
-
370
- header.logoImage.logoDraftToken = 'token-uploaded';
371
- header.logoImageBase64Url = 'data:image/png;base64,BBBB';
372
-
373
- const result = store.capture({ source: 'manual_edit', actor: 'user' });
374
-
375
- expect(result).toBe('committed');
376
- expect(store.entries.length).toBe(2);
377
- });
378
-
379
- it('treats isModifiedAfterTemplateApplied flip as a no-op', async () => {
380
- const store = useHistoryStore();
381
-
382
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
383
- (mockMakerStore.brandPageData as any).isModifiedAfterTemplateApplied =
384
- false;
385
- await store.captureBaseline();
386
-
387
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
388
- (mockMakerStore.brandPageData as any).isModifiedAfterTemplateApplied =
389
- true;
390
-
391
- const result = store.capture({ source: 'manual_edit', actor: 'user' });
392
-
393
- expect(result).toBe('noop');
394
- expect(store.entries.length).toBe(1);
395
- });
396
-
397
- it('treats RTE empty-paragraph init as a no-op when the prior key was absent', async () => {
398
- const store = useHistoryStore();
399
-
400
- // Baseline content has NO `description` key at all (the cold-start
401
- // shape for sections like testimonial-section before any editor
402
- // mounts). JSON.stringify drops `undefined`, so the captured snapshot
403
- // simply lacks the key.
404
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
405
- (mockMakerStore.brandPageData as any).pages = [
406
- {
407
- contents: [{ type: 'testimonial-section', title: 'A' }],
408
- },
409
- ];
410
- await store.captureBaseline();
411
-
412
- // Sidebar open writes back TipTap's empty-paragraph default, creating
413
- // the key that was previously absent.
414
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
415
- (mockMakerStore.brandPageData as any).pages[0].contents[0].description =
416
- '<p style="text-align: center;"></p>';
417
-
418
- const result = store.capture({ source: 'manual_edit', actor: 'user' });
419
-
420
- expect(result).toBe('noop');
421
- expect(store.entries.length).toBe(1);
422
- });
423
-
424
- it('treats RTE default-hydration wrapper around plain text as a no-op', async () => {
425
- const store = useHistoryStore();
426
-
427
- // Cold-start: titles/descriptions are plain strings (no <p> wrapper).
428
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
429
- (mockMakerStore.brandPageData as any).pages = [
430
- {
431
- contents: [
432
- {
433
- type: 'banner',
434
- title: 'Makeup Artist ✦ Eleanor Reeves',
435
- description: 'Creating Flawless Looks',
436
- },
437
- {
438
- type: 'about-section',
439
- title: 'Passionate About Makeup',
440
- paragraph:
441
- 'Makeup is my art form, and I believe that everyone deserves to feel confident.',
442
- },
443
- ],
444
- },
445
- ];
446
- await store.captureBaseline();
447
-
448
- // Opening any content sidebar mounts every RTE editor on the page; each
449
- // one writes back its plain-text input wrapped in TipTap's default
450
- // <p>/<span>/<mark> hydration HTML. Same visible text, no real edit.
451
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
452
- const banner = (mockMakerStore.brandPageData as any).pages[0].contents[0];
453
-
454
- banner.title =
455
- '<p style="text-align: center;"><span style="font-family: Belleza; font-size: 1.5625em; letter-spacing: 0.02em; line-height: 1.5; color: rgb(209, 128, 47);"><mark data-color="#fffde9" style="background-color: rgb(255, 253, 233); color: inherit;">Makeup Artist ✦ Eleanor Reeves</mark></span></p>';
456
- banner.description =
457
- '<p style="text-align: center;"><span style="font-family: Gloock; font-size: 4.6875em; letter-spacing: 0.05em; line-height: 1.4; color: rgb(209, 128, 47);"><mark data-color="#fffde9" style="background-color: rgb(255, 253, 233); color: inherit;">Creating Flawless Looks</mark></span></p>';
458
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
459
- const about = (mockMakerStore.brandPageData as any).pages[0].contents[1];
460
-
461
- about.title =
462
- '<p style="text-align: left;"><span style="font-family: Gloock; font-size: 3.125em; letter-spacing: 0.05em; line-height: 1.3; color: rgb(255, 253, 233);">Passionate About Makeup</span></p>';
463
- about.paragraph =
464
- '<p style="text-align: left;"><span style="font-family: Belleza; font-size: 1.375em; letter-spacing: 0px; line-height: 1.5; color: rgb(255, 253, 233);">Makeup is my art form, and I believe that everyone deserves to feel confident.</span></p>';
465
-
466
- const result = store.capture({ source: 'manual_edit', actor: 'user' });
467
-
468
- expect(result).toBe('noop');
469
- expect(store.entries.length).toBe(1);
470
- });
471
-
472
- it('treats bare <p>plain text</p> hydration as a no-op', async () => {
473
- const store = useHistoryStore();
474
-
475
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
476
- (mockMakerStore.brandPageData as any).pages = [
477
- {
478
- contents: [
479
- {
480
- type: 'contact-form',
481
- successMessage:
482
- "Thank you! We'll get in touch with you as soon as possible.",
483
- },
484
- ],
485
- },
486
- ];
487
- await store.captureBaseline();
488
-
489
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
490
- (
491
- mockMakerStore.brandPageData as any
492
- ).pages[0].contents[0].successMessage =
493
- "<p>Thank you! We'll get in touch with you as soon as possible.</p>";
494
-
495
- const result = store.capture({ source: 'manual_edit', actor: 'user' });
496
-
497
- expect(result).toBe('noop');
498
- expect(store.entries.length).toBe(1);
499
- });
500
-
501
- it('still commits when RTE content has partial-text inline formatting', async () => {
502
- // Partial bold (`Hello <strong>world</strong>`) is a real edit — only
503
- // *part* of the text is bold, which can't be expressed via the global
504
- // textStyle. The dedup must NOT collapse this.
505
- const store = useHistoryStore();
506
-
507
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
508
- (mockMakerStore.brandPageData as any).pages = [
509
- {
510
- contents: [
511
- {
512
- type: 'banner',
513
- title: 'Hello world',
514
- },
515
- ],
516
- },
517
- ];
518
- await store.captureBaseline();
519
-
520
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
521
- (mockMakerStore.brandPageData as any).pages[0].contents[0].title =
522
- '<p>Hello <strong>world</strong></p>';
523
-
524
- const result = store.capture({ source: 'manual_edit', actor: 'user' });
525
-
526
- expect(result).toBe('committed');
527
- expect(store.entries.length).toBe(2);
528
- });
529
-
530
- // BP-5506 follow-up: TipTap re-emits a whole-content <strong>/<em>/<u>/<s>
531
- // wrapper on first hydration to mirror the textStyle's fontWeight /
532
- // fontStyle / textDecoration. The whole-content case is hydration noise
533
- // (textStyle already encodes it), so it must dedup. The earlier RTE
534
- // canonicalizer rejected anything containing `<` or `>` inside the inner
535
- // text, so this case slipped through and produced phantom undo entries
536
- // when the user clicked any content section that had bolded text.
537
- it('treats whole-content <strong>/<em>/<u>/<s> hydration wrappers as a no-op', async () => {
538
- const store = useHistoryStore();
539
-
540
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
541
- (mockMakerStore.brandPageData as any).pages = [
542
- {
543
- contents: [
544
- {
545
- type: 'shapes-section',
546
- textItems: [
547
- { text: 'This test is for text' },
548
- { text: 'Italic line' },
549
- { text: 'Underline line' },
550
- { text: 'Strikethrough line' },
551
- { text: 'Bold italic combo' },
552
- ],
553
- },
554
- ],
555
- },
556
- ];
557
- await store.captureBaseline();
558
-
559
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
560
- const items = (mockMakerStore.brandPageData as any).pages[0].contents[0]
561
- .textItems;
562
-
563
- // Each item gets hydrated to its TipTap default-shape with a
564
- // whole-content format tag mirroring the textStyle.
565
- items[0].text =
566
- '<p style="text-align: left;"><span style="font-family: Lemonada; font-size: 1.875em;"><strong>This test is for text</strong></span></p>';
567
- items[1].text =
568
- '<p><span style="font-family: Lato;"><em>Italic line</em></span></p>';
569
- items[2].text = '<p><u>Underline line</u></p>';
570
- items[3].text = '<p><s>Strikethrough line</s></p>';
571
- items[4].text = '<p><em><strong>Bold italic combo</strong></em></p>';
572
-
573
- const result = store.capture({ source: 'manual_edit', actor: 'user' });
574
-
575
- expect(result).toBe('noop');
576
- expect(store.entries.length).toBe(1);
577
- });
578
-
579
- // Anything beyond the hydration whitelist must NOT be collapsed.
580
- // <a> is the canonical case — a TipTap-rendered link is real authoring
581
- // intent and must survive dedup.
582
- it('still commits when RTE content adds a link (real authoring intent)', async () => {
583
- const store = useHistoryStore();
584
-
585
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
586
- (mockMakerStore.brandPageData as any).pages = [
587
- {
588
- contents: [{ type: 'banner', title: 'click here' }],
589
- },
590
- ];
591
- await store.captureBaseline();
592
-
593
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
594
- (mockMakerStore.brandPageData as any).pages[0].contents[0].title =
595
- '<p>click <a href="https://example.com">here</a></p>';
596
-
597
- const result = store.capture({ source: 'manual_edit', actor: 'user' });
598
-
599
- expect(result).toBe('committed');
600
- expect(store.entries.length).toBe(2);
601
- });
602
-
603
- // A <span> carrying a style property outside the hydration whitelist
604
- // (e.g. `text-shadow`, `text-transform`) is a real edit. TipTap's
605
- // hydration span only emits `font-family / font-size / letter-spacing /
606
- // line-height / color / background-color`.
607
- it('still commits when RTE content adds a non-hydration style property', async () => {
608
- const store = useHistoryStore();
609
-
610
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
611
- (mockMakerStore.brandPageData as any).pages = [
612
- {
613
- contents: [{ type: 'banner', title: 'shadowed' }],
614
- },
615
- ];
616
- await store.captureBaseline();
617
-
618
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
619
- (mockMakerStore.brandPageData as any).pages[0].contents[0].title =
620
- '<p><span style="text-shadow: 1px 1px 2px black;">shadowed</span></p>';
621
-
622
- const result = store.capture({ source: 'manual_edit', actor: 'user' });
623
-
624
- expect(result).toBe('committed');
625
- expect(store.entries.length).toBe(2);
626
- });
627
-
628
- it('treats canvas section-click cascade as a no-op (BP-5506 repro)', async () => {
629
- // Captured live from UAT: clicking any section on the canvas mounts
630
- // every RTE editor on the page at once + resolves textStyleType for
631
- // the section's style objects + flips isModifiedAfterTemplateApplied.
632
- // None of that is a user-visible edit, so dedup must collapse it.
633
- const store = useHistoryStore();
634
-
635
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
636
- (mockMakerStore.brandPageData as any).isModifiedAfterTemplateApplied =
637
- false;
638
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
639
- (mockMakerStore.brandPageData as any).pages = [
640
- {
641
- contents: [
642
- {
643
- type: 'banner',
644
- title: 'Makeup Artist ✦ Eleanor Reeves',
645
- description: 'Creating Flawless Looks',
646
- },
647
- {
648
- type: 'text-media-section',
649
- title: 'My Specialties',
650
- description: '',
651
- descriptionStyle: { textStyleType: 'paragraphs' },
652
- },
653
- ],
654
- },
655
- ];
656
- await store.captureBaseline();
657
-
658
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
659
- const data = mockMakerStore.brandPageData as any;
660
- const banner = data.pages[0].contents[0];
661
- const tms = data.pages[0].contents[1];
662
-
663
- banner.title =
664
- '<p style="text-align: center;"><span style="font-family: Belleza;"><mark data-color="#fffde9">Makeup Artist ✦ Eleanor Reeves</mark></span></p>';
665
- banner.description =
666
- '<p style="text-align: center;"><span style="font-family: Gloock;"><mark>Creating Flawless Looks</mark></span></p>';
667
- tms.title =
668
- '<p style="text-align: center;"><span style="font-family: Gloock;">My Specialties</span></p>';
669
- tms.description = '<p style="text-align: center;"></p>';
670
- tms.descriptionStyle.fontSize = '1em';
671
- tms.descriptionStyle.fontFamily = 'Lato';
672
- tms.descriptionStyle.lineHeight = '1.5';
673
- tms.descriptionStyle.letterSpacing = '0';
674
- data.isModifiedAfterTemplateApplied = true;
675
-
676
- const result = store.capture({ source: 'manual_edit', actor: 'user' });
677
-
678
- expect(result).toBe('noop');
679
- expect(store.entries.length).toBe(1);
680
- });
681
-
682
- it('still commits when a real edit accompanies a phantom field', async () => {
683
- const store = useHistoryStore();
684
-
685
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
686
- (mockMakerStore.brandPageData as any).pages = [
687
- {
688
- contents: [
689
- {
690
- type: 'contact-form',
691
- buttonText: 'Send',
692
- description: '',
693
- },
694
- ],
695
- },
696
- ];
697
- await store.captureBaseline();
698
-
699
- // Real edit + phantom together — must commit.
700
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
701
- const cf = (mockMakerStore.brandPageData as any).pages[0].contents[0];
702
-
703
- cf.buttonText = 'Submit';
704
- cf.description = '<p style="text-align: center;"></p>';
705
-
706
- const result = store.capture({ source: 'manual_edit', actor: 'user' });
707
-
708
- expect(result).toBe('committed');
709
- expect(store.entries.length).toBe(2);
710
- });
711
- });
712
-
713
- it('reentrancy guard: capture no-ops when isApplyingSnapshot is true', async () => {
714
- const store = useHistoryStore();
715
-
716
- await store.captureBaseline();
717
- const initialLength = store.entries.length;
718
-
719
- store.isApplyingSnapshot = true;
720
- store.capture({ source: 'manual_edit', actor: 'user' });
721
-
722
- expect(store.entries.length).toBe(initialLength);
723
- store.isApplyingSnapshot = false;
724
- });
725
-
726
- it('canUndo requires currentIndex > 0', async () => {
727
- const store = useHistoryStore();
728
-
729
- expect(store.canUndo).toBe(false);
730
-
731
- await store.captureBaseline();
732
- expect(store.canUndo).toBe(false); // only baseline
733
-
734
- captureMutated(store);
735
- expect(store.canUndo).toBe(true);
736
- });
737
-
738
- it('canRedo requires currentIndex < entries.length - 1', async () => {
739
- const store = useHistoryStore();
740
-
741
- expect(store.canRedo).toBe(false);
742
-
743
- await store.captureBaseline();
744
- captureMutated(store);
745
- expect(store.canRedo).toBe(false);
746
-
747
- await store.undo();
748
- expect(store.canRedo).toBe(true);
749
- });
750
-
751
- it('undo decrements pointer, keeps stack intact, calls applyHistorySnapshot', async () => {
752
- const store = useHistoryStore();
753
-
754
- await store.captureBaseline();
755
- const baselineBrandPageData = JSON.parse(store.entries[0].snapshot);
756
-
757
- captureMutated(store);
758
-
759
- expect(store.currentIndex).toBe(1);
760
- expect(store.entries.length).toBe(2);
761
-
762
- await store.undo();
763
-
764
- expect(store.currentIndex).toBe(0);
765
- expect(store.entries.length).toBe(2); // stack is unchanged, pointer moved
766
- expect(mockMakerStore.applyHistorySnapshot).toHaveBeenCalledTimes(1);
767
- // Undo restores the baseline snapshot — the parsed BrandPageModel must
768
- // round-trip back through applyHistorySnapshot. Second arg is the
769
- // resolved activePageId (undefined here because no entry has one).
770
- expect(mockMakerStore.applyHistorySnapshot).toHaveBeenCalledWith(
771
- expect.objectContaining(baselineBrandPageData),
772
- undefined,
773
- );
774
- });
775
-
776
- it('redo increments pointer, keeps stack intact, calls applyHistorySnapshot', async () => {
777
- const store = useHistoryStore();
778
-
779
- await store.captureBaseline();
780
- captureMutated(store);
781
- await store.undo();
782
- mockMakerStore.applyHistorySnapshot.mockClear();
783
-
784
- expect(store.currentIndex).toBe(0);
785
- expect(store.canRedo).toBe(true);
786
-
787
- await store.redo();
788
-
789
- expect(store.currentIndex).toBe(1);
790
- expect(store.entries.length).toBe(2);
791
- expect(mockMakerStore.applyHistorySnapshot).toHaveBeenCalledTimes(1);
792
- });
793
-
794
- it('baseline invariant: undo refuses to go below index 0', async () => {
795
- const store = useHistoryStore();
796
-
797
- await store.captureBaseline();
798
-
799
- expect(store.canUndo).toBe(false);
800
- await store.undo();
801
-
802
- expect(store.currentIndex).toBe(0);
803
- expect(mockMakerStore.applyHistorySnapshot).not.toHaveBeenCalled();
804
- });
805
-
806
- it('getCurrentSizeBytes sums byte sizes across all entries', async () => {
807
- const store = useHistoryStore();
808
-
809
- expect(store.getCurrentSizeBytes()).toBe(0);
810
-
811
- await store.captureBaseline();
812
- const baselineSize = store.getCurrentSizeBytes();
813
-
814
- expect(baselineSize).toBe(store.entries[0].sizeBytes);
815
- expect(baselineSize).toBeGreaterThan(0);
816
-
817
- captureMutated(store);
818
- expect(store.getCurrentSizeBytes()).toBe(
819
- store.entries[0].sizeBytes + store.entries[1].sizeBytes,
820
- );
821
- });
822
-
823
- it('getCurrentEntryCount reflects the stack length', async () => {
824
- const store = useHistoryStore();
825
-
826
- expect(store.getCurrentEntryCount()).toBe(0);
827
-
828
- await store.captureBaseline();
829
- expect(store.getCurrentEntryCount()).toBe(1);
830
-
831
- captureMutated(store);
832
- expect(store.getCurrentEntryCount()).toBe(2);
833
- });
834
-
835
- // --- U1 finalize (FR-12 / FR-13 / FR-13c) ---
836
-
837
- it('FR-12: caps the stack at 40 entries, dropping oldest non-baseline', async () => {
838
- const store = useHistoryStore();
839
-
840
- await store.captureBaseline();
841
- const baselineSnapshot = store.entries[0].snapshot;
842
-
843
- // Baseline + 50 edits — stack should cap at 40.
844
- for (let i = 0; i < 50; i++) {
845
- // Mutate the mock so each snapshot is distinguishable.
846
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
847
- (mockMakerStore.brandPageData as any).v = i + 100;
848
- store.capture({ source: 'manual_edit', actor: 'user' });
849
- }
850
-
851
- expect(store.entries.length).toBe(40);
852
- // Baseline is preserved at index 0.
853
- expect(store.entries[0].snapshot).toBe(baselineSnapshot);
854
- // Pointer is at the new end.
855
- expect(store.currentIndex).toBe(39);
856
- });
857
-
858
- it('FR-13c: skips oversize captures (> 25 MB) and leaves the stack alone', async () => {
859
- const store = useHistoryStore();
860
-
861
- await store.captureBaseline();
862
- const lengthBefore = store.entries.length;
863
-
864
- // Swap in a mock that produces a > 25 MB snapshot on serialize.
865
- const originalData = mockMakerStore.brandPageData;
866
-
867
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
868
- (mockMakerStore as any).brandPageData = {
869
- v: 1,
870
- pages: [],
871
- styles: {},
872
- _padding: 'x'.repeat(26 * 1024 * 1024),
873
- };
874
-
875
- const result = store.capture({ source: 'manual_edit', actor: 'user' });
876
-
877
- expect(result).toBe('oversized');
878
- expect(store.entries.length).toBe(lengthBefore);
879
-
880
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
881
- (mockMakerStore as any).brandPageData = originalData;
882
- });
883
-
884
- it('FR-13: evicts oldest non-baseline when total memory exceeds 100 MB', async () => {
885
- const store = useHistoryStore();
886
-
887
- await store.captureBaseline();
888
- const baselineSnapshot = store.entries[0].snapshot;
889
-
890
- // Each capture is ~4 MB, under the 5 MB per-entry cap. Push 30
891
- // of them — total would be ~120 MB, so memory eviction should kick in.
892
- const originalData = mockMakerStore.brandPageData;
893
-
894
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
895
- (mockMakerStore as any).brandPageData = {
896
- v: 1,
897
- pages: [],
898
- styles: {},
899
- _padding: 'x'.repeat(4 * 1024 * 1024),
900
- };
901
-
902
- for (let i = 0; i < 30; i++) {
903
- store.capture({ source: 'manual_edit', actor: 'user' });
904
- }
905
-
906
- expect(store.getCurrentSizeBytes()).toBeLessThanOrEqual(100 * 1024 * 1024);
907
- // Baseline must survive memory eviction.
908
- expect(store.entries[0].snapshot).toBe(baselineSnapshot);
909
-
910
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
911
- (mockMakerStore as any).brandPageData = originalData;
912
- });
913
-
914
- // --- U5 analytics ---
915
-
916
- it('emits undo / redo events with entries_count + actor of the affected entry', async () => {
917
- const received: { name: string; props: unknown }[] = [];
918
-
919
- useEventTracker().subscribe((event) =>
920
- received.push({ name: event.eventName, props: event.eventProps }),
921
- );
922
-
923
- const store = useHistoryStore();
924
-
925
- await store.captureBaseline();
926
- captureMutated(store, { source: 'ai_changeset', actor: 'ai_builder' });
927
- await store.undo();
928
- await store.redo();
929
-
930
- const undoEvent = received.find((e) => e.name === 'website_maker_undo');
931
- const redoEvent = received.find((e) => e.name === 'website_maker_redo');
932
-
933
- expect(undoEvent).toBeDefined();
934
- expect(redoEvent).toBeDefined();
935
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
936
- expect((undoEvent!.props as any).entries_count).toBe(2);
937
- // Undo reports the actor of the entry being undone — the ai_builder change.
938
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
939
- expect((undoEvent!.props as any).actor).toBe('ai_builder');
940
- // Redo reports the actor of the entry being re-applied — same ai_builder change.
941
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
942
- expect((redoEvent!.props as any).actor).toBe('ai_builder');
943
- });
944
-
945
- it('emits oversized event when a capture exceeds 25 MB', async () => {
946
- const received: { name: string; props: unknown }[] = [];
947
-
948
- useEventTracker().subscribe((event) =>
949
- received.push({ name: event.eventName, props: event.eventProps }),
950
- );
951
-
952
- const store = useHistoryStore();
953
-
954
- await store.captureBaseline();
955
-
956
- const originalData = mockMakerStore.brandPageData;
957
-
958
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
959
- (mockMakerStore as any).brandPageData = {
960
- v: 1,
961
- pages: [],
962
- styles: {},
963
- _padding: 'x'.repeat(26 * 1024 * 1024),
964
- };
965
-
966
- store.capture({ source: 'manual_edit', actor: 'user' });
967
-
968
- const oversized = received.find(
969
- (e) => e.name === 'website_maker_history_oversized',
970
- );
971
-
972
- expect(oversized).toBeDefined();
973
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
974
- expect((oversized!.props as any).attempted_bytes).toBeGreaterThan(
975
- 25 * 1024 * 1024,
976
- );
977
-
978
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
979
- (mockMakerStore as any).brandPageData = originalData;
980
- });
981
-
982
- // --- BP-5506 (page-tab highlight + active-page tracking) ---
983
-
984
- describe('activePageId capture and restore', () => {
985
- it('captures the current activePageId on baseline', async () => {
986
- mockPageNavigationStore.activePageId = 'page-home';
987
-
988
- const store = useHistoryStore();
989
-
990
- await store.captureBaseline();
991
-
992
- expect(store.entries[0].activePageId).toBe('page-home');
993
- });
994
-
995
- it('captures the current activePageId on each force-commit', async () => {
996
- mockPageNavigationStore.activePageId = 'page-home';
997
- const store = useHistoryStore();
998
-
999
- await store.captureBaseline();
1000
-
1001
- mockPageNavigationStore.activePageId = 'page-2';
1002
- captureMutated(store);
1003
-
1004
- expect(store.entries[1].activePageId).toBe('page-2');
1005
- });
1006
-
1007
- it('passes the captured activePageId to applyHistorySnapshot when the page still exists', async () => {
1008
- mockPageNavigationStore.activePageId = 'page-home';
1009
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
1010
- (mockMakerStore.brandPageData as any).pages = [
1011
- { id: 'page-home', contents: [] },
1012
- ];
1013
-
1014
- const store = useHistoryStore();
1015
-
1016
- await store.captureBaseline();
1017
-
1018
- mockPageNavigationStore.activePageId = 'page-2';
1019
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
1020
- (mockMakerStore.brandPageData as any).pages = [
1021
- { id: 'page-home', contents: [] },
1022
- { id: 'page-2', contents: [] },
1023
- ];
1024
- captureMutated(store);
1025
-
1026
- mockMakerStore.applyHistorySnapshot.mockClear();
1027
- await store.undo();
1028
-
1029
- // Baseline restored — its captured page was 'page-home', and that
1030
- // page exists in the restored snapshot, so it round-trips to apply.
1031
- expect(mockMakerStore.applyHistorySnapshot).toHaveBeenCalledWith(
1032
- expect.anything(),
1033
- 'page-home',
1034
- );
1035
- });
1036
-
1037
- it('walks back when the captured activePageId no longer exists in the restored snapshot (page-add undo)', async () => {
1038
- // Baseline: only page-home, user is on page-home.
1039
- mockPageNavigationStore.activePageId = 'page-home';
1040
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
1041
- (mockMakerStore.brandPageData as any).pages = [
1042
- { id: 'page-home', contents: [] },
1043
- ];
1044
-
1045
- const store = useHistoryStore();
1046
-
1047
- await store.captureBaseline();
1048
-
1049
- // User adds a new page. Snapshot now has both pages, user is on the
1050
- // new page.
1051
- mockPageNavigationStore.activePageId = 'page-new';
1052
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
1053
- (mockMakerStore.brandPageData as any).pages = [
1054
- { id: 'page-home', contents: [] },
1055
- { id: 'page-new', contents: [] },
1056
- ];
1057
- captureMutated(store);
1058
-
1059
- mockMakerStore.applyHistorySnapshot.mockClear();
1060
- await store.undo();
1061
-
1062
- // Baseline only has 'page-home'. The entry being applied has
1063
- // activePageId='page-home', so we resolve to that.
1064
- expect(mockMakerStore.applyHistorySnapshot).toHaveBeenCalledWith(
1065
- expect.anything(),
1066
- 'page-home',
1067
- );
1068
- });
1069
-
1070
- it('navigates to the restored page when undoing a delete (page present in target but not source)', async () => {
1071
- // Baseline: home + about, user is on about (the page that will be deleted).
1072
- mockPageNavigationStore.activePageId = 'page-about';
1073
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
1074
- (mockMakerStore.brandPageData as any).pages = [
1075
- { id: 'page-home', contents: [] },
1076
- { id: 'page-about', contents: [] },
1077
- ];
1078
-
1079
- const store = useHistoryStore();
1080
-
1081
- await store.captureBaseline();
1082
-
1083
- // User deletes about. The delete handler navigates them to home,
1084
- // then captures. Snapshot has only home; activePageId on the entry
1085
- // is page-home (not page-about — the user was already off it by
1086
- // the time the watcher fired).
1087
- mockPageNavigationStore.activePageId = 'page-home';
1088
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
1089
- (mockMakerStore.brandPageData as any).pages = [
1090
- { id: 'page-home', contents: [] },
1091
- ];
1092
- captureMutated(store);
1093
-
1094
- mockMakerStore.applyHistorySnapshot.mockClear();
1095
- await store.undo();
1096
-
1097
- // Diff resolution: target snapshot (baseline) has page-about,
1098
- // source snapshot (post-delete entry 1) does not. So page-about
1099
- // is being restored — navigate there. Without diff resolution
1100
- // the walk-back would land on page-home (the activePageId on
1101
- // entry 1 and entry 0 both happen to be page-home in this
1102
- // scenario), leaving the user staring at home after un-deleting
1103
- // about.
1104
- expect(mockMakerStore.applyHistorySnapshot).toHaveBeenCalledWith(
1105
- expect.anything(),
1106
- 'page-about',
1107
- );
1108
- });
1109
-
1110
- it('navigates to the newly-added page when redoing an add (page present in target but not source)', async () => {
1111
- // Baseline: only home.
1112
- mockPageNavigationStore.activePageId = 'page-home';
1113
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
1114
- (mockMakerStore.brandPageData as any).pages = [
1115
- { id: 'page-home', contents: [] },
1116
- ];
1117
-
1118
- const store = useHistoryStore();
1119
-
1120
- await store.captureBaseline();
1121
-
1122
- // User adds page-new and is navigated there.
1123
- mockPageNavigationStore.activePageId = 'page-new';
1124
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
1125
- (mockMakerStore.brandPageData as any).pages = [
1126
- { id: 'page-home', contents: [] },
1127
- { id: 'page-new', contents: [] },
1128
- ];
1129
- captureMutated(store);
1130
-
1131
- // Undo the add — back to baseline.
1132
- await store.undo();
1133
-
1134
- // Redo the add. Source (baseline) has only home; target (entry 1)
1135
- // has both home and new. The diff identifies page-new as the
1136
- // page being restored by this transition — navigate there.
1137
- mockMakerStore.applyHistorySnapshot.mockClear();
1138
- await store.redo();
1139
-
1140
- expect(mockMakerStore.applyHistorySnapshot).toHaveBeenCalledWith(
1141
- expect.anything(),
1142
- 'page-new',
1143
- );
1144
- });
1145
-
1146
- it('falls through to undefined when no entry walking back has a viable id', async () => {
1147
- // Baseline page exists, captured id was 'page-home'.
1148
- mockPageNavigationStore.activePageId = 'page-home';
1149
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
1150
- (mockMakerStore.brandPageData as any).pages = [
1151
- { id: 'page-home', contents: [] },
1152
- ];
1153
-
1154
- const store = useHistoryStore();
1155
-
1156
- await store.captureBaseline();
1157
-
1158
- // Snapshot contains a totally different page (e.g. template switch
1159
- // replaced everything) and the user is on its new page.
1160
- mockPageNavigationStore.activePageId = 'page-x';
1161
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
1162
- (mockMakerStore.brandPageData as any).pages = [
1163
- { id: 'page-x', contents: [] },
1164
- ];
1165
- captureMutated(store);
1166
-
1167
- mockMakerStore.applyHistorySnapshot.mockClear();
1168
- await store.undo();
1169
-
1170
- // Baseline restored — its activePageId 'page-home' exists in its own
1171
- // snapshot, so resolution finds it on the first walk-back hop.
1172
- expect(mockMakerStore.applyHistorySnapshot).toHaveBeenCalledWith(
1173
- expect.anything(),
1174
- 'page-home',
1175
- );
1176
-
1177
- // Now redo. Pointer moves forward to entry 1 (page-x captured),
1178
- // page-x exists in entry-1's snapshot, so resolves to 'page-x'.
1179
- mockMakerStore.applyHistorySnapshot.mockClear();
1180
- await store.redo();
1181
- expect(mockMakerStore.applyHistorySnapshot).toHaveBeenCalledWith(
1182
- expect.anything(),
1183
- 'page-x',
1184
- );
1185
- });
1186
- });
1187
-
1188
- describe('reset()', () => {
1189
- it('clears entries and re-baselines against the current state', async () => {
1190
- mockPageNavigationStore.activePageId = 'page-home';
1191
-
1192
- const store = useHistoryStore();
1193
-
1194
- await store.captureBaseline();
1195
- captureMutated(store);
1196
- captureMutated(store);
1197
-
1198
- expect(store.entries.length).toBe(3);
1199
-
1200
- // Mutate the mock so the new baseline reflects the just-saved state.
1201
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
1202
- (mockMakerStore.brandPageData as any).v = 9999;
1203
-
1204
- store.reset();
1205
-
1206
- expect(store.entries.length).toBe(1);
1207
- expect(store.currentIndex).toBe(0);
1208
- expect(store.baselineCaptured).toBe(true);
1209
- expect(JSON.parse(store.entries[0].snapshot).v).toBe(9999);
1210
- expect(store.canUndo).toBe(false);
1211
- expect(store.canRedo).toBe(false);
1212
- });
1213
-
1214
- it('captures activePageId on the new baseline after reset', async () => {
1215
- mockPageNavigationStore.activePageId = 'page-home';
1216
-
1217
- const store = useHistoryStore();
1218
-
1219
- await store.captureBaseline();
1220
- captureMutated(store);
1221
-
1222
- mockPageNavigationStore.activePageId = 'page-2';
1223
- store.reset();
1224
-
1225
- expect(store.entries[0].activePageId).toBe('page-2');
1226
- });
1227
-
1228
- it('no-ops when no baseline has ever been captured (unflagged session)', () => {
1229
- // Simulates a session where the undo/redo feature flag is off, so
1230
- // captureBaseline is never called. saveBrandPage calls reset()
1231
- // unconditionally — it must not install the watcher or capture a
1232
- // baseline as a side effect, otherwise unflagged sessions start
1233
- // accumulating snapshots in memory.
1234
- const store = useHistoryStore();
1235
-
1236
- expect(store.baselineCaptured).toBe(false);
1237
- expect(store.entries.length).toBe(0);
1238
-
1239
- store.reset();
1240
-
1241
- expect(store.baselineCaptured).toBe(false);
1242
- expect(store.entries.length).toBe(0);
1243
- expect(store.currentIndex).toBe(-1);
1244
- });
1245
- });
1246
- });