@designcrowd/library.brand-page 5.18.3-undo-38 → 5.18.3

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 (112) hide show
  1. package/lib/src/{CreatedOnBrandCrowd-de-de-ydEGYl5O.js → CreatedOnBrandCrowd-de-de-CkuAAwKQ.js} +1 -1
  2. package/lib/src/{CreatedOnBrandCrowd-en-us-BiJ8RmwM.js → CreatedOnBrandCrowd-en-us-Coln5kSU.js} +1 -1
  3. package/lib/src/{CreatedOnBrandCrowd-es-es-C8CyL1ny.js → CreatedOnBrandCrowd-es-es-raGyXChm.js} +1 -1
  4. package/lib/src/{CreatedOnBrandCrowd-fr-fr-BMh0yIJJ.js → CreatedOnBrandCrowd-fr-ca-BT_WYYxW.js} +1 -1
  5. package/lib/src/{CreatedOnBrandCrowd-fr-ca-BMh0yIJJ.js → CreatedOnBrandCrowd-fr-fr-BT_WYYxW.js} +1 -1
  6. package/lib/src/{CreatedOnBrandCrowd-pt-pt-C0DghlQk.js → CreatedOnBrandCrowd-pt-br-BRbD_kiI.js} +1 -1
  7. package/lib/src/{CreatedOnBrandCrowd-pt-br-C0DghlQk.js → CreatedOnBrandCrowd-pt-pt-BRbD_kiI.js} +1 -1
  8. package/lib/src/{CreatedOnDcom-de-de-CZEnW0qm.js → CreatedOnDcom-de-de-DetCIFxa.js} +1 -1
  9. package/lib/src/{CreatedOnDcom-en-us-DPpRkfrD.js → CreatedOnDcom-en-us-D0LeF_V2.js} +1 -1
  10. package/lib/src/{CreatedOnDcom-es-es-8yTtB23v.js → CreatedOnDcom-es-es-CHa4stWS.js} +1 -1
  11. package/lib/src/{CreatedOnDcom-fr-ca-DdGwSBLy.js → CreatedOnDcom-fr-ca-ntW7A335.js} +1 -1
  12. package/lib/src/{CreatedOnDcom-fr-fr-DRGbffC4.js → CreatedOnDcom-fr-fr-C6rQcclc.js} +1 -1
  13. package/lib/src/{CreatedOnDcom-pt-br-CWm_1vbQ.js → CreatedOnDcom-pt-br-Cn07ZK_m.js} +1 -1
  14. package/lib/src/{CreatedOnDcom-pt-pt-DZBR79tn.js → CreatedOnDcom-pt-pt-CewmqYVY.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 -87
  21. package/lib/src/brand-page-maker/components/brandpage-maker-page/BrandPageMakerPage.mixin.js +0 -5
  22. package/lib/src/brand-page-maker/components/brandpage-settings/ReplaceImageSettingsForBrandPageSettings.mixin.d.ts +2 -7
  23. package/lib/src/brand-page-maker/components/quick-link-bar/QuickLinkBar.mixin.d.ts +0 -3
  24. package/lib/src/brand-page-maker/components/quick-link-bar/QuickLinkBar.mixin.js +0 -11
  25. package/lib/src/brand-page-maker/components/seo-settings/SeoPageSettings.mixin.d.ts +2 -7
  26. package/lib/src/brand-page-maker/components/seo-settings/UrlSettings.mixin.d.ts +2 -7
  27. package/lib/src/brand-page-maker/components/social-share/SocialShare.mixin.d.ts +2 -7
  28. package/lib/src/brand-page-maker/components/template-preview/TemplatePreview.mixin.d.ts +2 -7
  29. package/lib/src/brand-page-maker/composables/ai-chat/useAiWebsiteBuilderChat.js +0 -9
  30. package/lib/src/brand-page-maker/composables/rich-text-editor/useRichTextEditor.js +1 -20
  31. package/lib/src/brand-page-maker/composables/useEventTracker.d.ts +0 -20
  32. package/lib/src/brand-page-maker/composables/useEventTracker.js +0 -17
  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 -307
  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-B5Fkt3qd.js +175 -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 +15 -20
  41. package/lib/src/css/library.brand-page-brandPage.min.css +16 -20
  42. package/lib/src/css/library.brand-page-designCom.min.css +15 -20
  43. package/lib/src/css/library.brand-page-preview.min.css +15 -20
  44. package/lib/src/index.mjs +1 -1
  45. package/lib/src/{lib-BZlXQjdd.js → lib-CGfmcIBb.js} +29373 -29970
  46. package/lib/src/shared/content-settings/components/content-settings/ContentSettings.mixin.d.ts +2 -87
  47. package/lib/src/shared/content-settings/components/content-settings/ContentSettings.mixin.js +0 -5
  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/form-fields-field/FormFieldsField.mixin.js +8 -18
  55. package/lib/src/shared/content-settings/components/payment-config-field/PaymentConfigField.mixin.d.ts +0 -64
  56. package/lib/src/shared/content-settings/components/payment-config-field/PaymentConfigField.mixin.js +0 -6
  57. package/lib/src/shared/logo-settings/components/EditLogoSettings.mixin.d.ts +0 -19
  58. package/lib/src/shared/logo-settings/components/EditLogoSettings.mixin.js +0 -11
  59. package/lib/src/shared/logo-settings/components/LogoSettings.mixin.d.ts +2 -10
  60. package/lib/src/shared/logo-settings/store/editable-logo-store.d.ts +1 -5
  61. package/lib/src/shared/logo-settings/store/editable-logo-store.js +0 -31
  62. package/lib/src/shared/page-settings/components/page-settings/PageSettings.mixin.d.ts +2 -24
  63. package/lib/src/shared/page-settings/components/page-settings-section-styles/PageSettingsSectionStyles.mixin.d.ts +2 -10
  64. package/lib/src/shared/publish-brand-page-with-state/PublishBrandPageModalWithState.mixin.d.ts +2 -23
  65. package/lib/src/shared/side-bar/components/SideBarTabButton.mixin.d.ts +0 -16
  66. package/lib/src/shared/style-settings/components/StyleSettings.mixin.d.ts +2 -23
  67. package/lib/src/shared/template-settings/components/TemplatesSettings.mixin.d.ts +0 -16
  68. package/package.json +1 -1
  69. package/src/brand-page-maker/components/brand-page-maker/BrandPageMaker.mixin.ts +0 -11
  70. package/src/brand-page-maker/components/brand-page-maker/BrandPageMaker.vue +3 -2
  71. package/src/brand-page-maker/components/brand-page-maker/__fixtures__/brand-page-examples.ts +0 -1
  72. package/src/brand-page-maker/components/brandpage-maker-page/BrandPageMakerPage.mixin.ts +0 -5
  73. package/src/brand-page-maker/components/cookie-collection/CookieSettings.vue +17 -18
  74. package/src/brand-page-maker/components/quick-link-bar/QuickLinkBar.mixin.ts +0 -13
  75. package/src/brand-page-maker/components/quick-link-bar/QuickLinkBar.vue +3 -26
  76. package/src/brand-page-maker/components/quick-link-bar/i18n/quick-link-bar.json +1 -3
  77. package/src/brand-page-maker/components/save-contact/SaveContact.vue +19 -12
  78. package/src/brand-page-maker/composables/ai-chat/useAiWebsiteBuilderChat.ts +0 -10
  79. package/src/brand-page-maker/composables/rich-text-editor/useRichTextEditor.ts +1 -24
  80. package/src/brand-page-maker/composables/useEventTracker.ts +0 -29
  81. package/src/brand-page-maker/stores/brandpage-maker/brandpage-maker-store.test.ts +0 -335
  82. package/src/brand-page-maker/stores/brandpage-maker/brandpage-maker-store.ts +4 -371
  83. package/src/brand-page-maker/stores/logo-store/logo-store.ts +0 -5
  84. package/src/brand-page-maker/stores/user/user-store.ts +0 -1
  85. package/src/bundles/bundled-translations.json +1 -3
  86. package/src/constants/ui.ts +0 -1
  87. package/src/shared/content-settings/components/content-settings/ContentSettings.mixin.ts +0 -5
  88. package/src/shared/content-settings/components/content-settings/ContentSettings.stories.ts +0 -1
  89. package/src/shared/content-settings/components/draggable-item-list/DraggableItemList.mixin.ts +2 -19
  90. package/src/shared/content-settings/components/draggable-item-list/DraggableItemList.vue +1 -1
  91. package/src/shared/content-settings/components/form-fields-field/FormFieldsField.mixin.ts +10 -26
  92. package/src/shared/content-settings/components/logo-selection-field/LogoSelection.vue +48 -63
  93. package/src/shared/content-settings/components/page-dropdown-field/PageDropdownField.vue +0 -4
  94. package/src/shared/content-settings/components/payment-config-field/PaymentConfigField.mixin.ts +0 -7
  95. package/src/shared/content-settings/components/text-field/TextField.vue +0 -1
  96. package/src/shared/content-settings/components/text-image-field/TextImageField.vue +4 -33
  97. package/src/shared/logo-settings/components/EditLogoSettings.mixin.ts +0 -11
  98. package/src/shared/logo-settings/store/editable-logo-store.ts +1 -42
  99. package/lib/src/brand-page-maker/composables/useUndoRedoFlag.d.ts +0 -16
  100. package/lib/src/brand-page-maker/composables/useUndoRedoFlag.js +0 -22
  101. package/lib/src/brand-page-maker/stores/history/history-store.d.ts +0 -83
  102. package/lib/src/brand-page-maker/stores/history/history-store.js +0 -495
  103. package/lib/src/bundled-translations-D1q3rSEz.js +0 -175
  104. package/lib/src/metadata-builder/builders/reorder-pages-builder.d.ts +0 -13
  105. package/lib/src/metadata-builder/builders/reorder-pages-builder.js +0 -49
  106. package/src/brand-page-maker/components/quick-link-bar/RedoButton.vue +0 -66
  107. package/src/brand-page-maker/components/quick-link-bar/UndoButton.vue +0 -66
  108. package/src/brand-page-maker/components/quick-link-bar/undo-redo-buttons.test.ts +0 -116
  109. package/src/brand-page-maker/composables/useUndoRedoFlag.ts +0 -26
  110. package/src/brand-page-maker/stores/history/history-store.test.ts +0 -1120
  111. package/src/brand-page-maker/stores/history/history-store.ts +0 -660
  112. package/src/metadata-builder/builders/reorder-pages-builder.ts +0 -80
@@ -1,1120 +0,0 @@
1
- import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
- import { reactive } from 'vue';
3
- import { createPinia, setActivePinia } from 'pinia';
4
- import { useHistoryStore } from './history-store';
5
- import { useEventTracker } from '../../composables/useEventTracker';
6
- import { useUiEditableLogoStore } from '@shared/logo-settings/store/editable-logo-store';
7
- import type { BrandPageModel } from '@brand-page';
8
-
9
- // Shared mock maker store — methods are vi.fn() so we can assert calls.
10
- const mockMakerStore = {
11
- brandPageData: {
12
- v: 1,
13
- pages: [],
14
- styles: {},
15
- } as unknown as BrandPageModel,
16
- isBrandPageLoaded: false,
17
- applyHistorySnapshot: vi.fn(async () => undefined),
18
- setIsBrandPageLoaded: vi.fn(),
19
- };
20
-
21
- const mockPageNavigationStore = {
22
- activePageId: '',
23
- };
24
-
25
- vi.mock('../brandpage-maker/brandpage-maker-store', () => ({
26
- useBrandPageMakerStore: vi.fn(() => mockMakerStore),
27
- }));
28
-
29
- vi.mock('../ai-thread/ai-website-builder-thread-store', () => ({
30
- useAiWebsiteBuilderThreadStore: vi.fn(() => ({
31
- isProcessing: false,
32
- })),
33
- }));
34
-
35
- vi.mock('@shared/page-navigation/store/page-navigation-store', () => ({
36
- usePageNavigationStore: vi.fn(() => mockPageNavigationStore),
37
- }));
38
-
39
- // Mutate the mock between captures so each capture produces a distinct
40
- // snapshot. The store now drops byte-identical captures (BP-5506: prevents
41
- // undo/redo steps that do nothing visible), so tests that previously relied
42
- // on capture-without-mutation lining up entries need to bump a field first.
43
- let mutationCounter = 0;
44
-
45
- function mutateMock(): void {
46
- mutationCounter += 1;
47
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
48
- (mockMakerStore.brandPageData as any).v = 1000 + mutationCounter;
49
- }
50
-
51
- function captureMutated(
52
- store: ReturnType<typeof useHistoryStore>,
53
- args: Parameters<typeof store.capture>[0] = {
54
- source: 'manual_edit',
55
- actor: 'user',
56
- },
57
- ) {
58
- mutateMock();
59
-
60
- return store.capture(args);
61
- }
62
-
63
- describe('history-store (single stack + JSON-string snapshots)', () => {
64
- beforeEach(() => {
65
- setActivePinia(createPinia());
66
- mockMakerStore.applyHistorySnapshot.mockClear();
67
- mutationCounter = 0;
68
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
69
- (mockMakerStore.brandPageData as any).v = 1;
70
- mockPageNavigationStore.activePageId = '';
71
- });
72
-
73
- it('captures baseline and initialises the pointer to 0', async () => {
74
- const store = useHistoryStore();
75
-
76
- expect(store.entries.length).toBe(0);
77
- expect(store.currentIndex).toBe(-1);
78
- expect(store.baselineCaptured).toBe(false);
79
-
80
- await store.captureBaseline();
81
-
82
- expect(store.baselineCaptured).toBe(true);
83
- expect(store.entries.length).toBe(1);
84
- expect(store.entries[0].label).toBe('baseline');
85
- expect(store.currentIndex).toBe(0);
86
- });
87
-
88
- it('stores snapshots as JSON strings, not objects', async () => {
89
- const store = useHistoryStore();
90
-
91
- await store.captureBaseline();
92
-
93
- const entry = store.entries[0];
94
-
95
- expect(typeof entry.snapshot).toBe('string');
96
- // Round-trips to the same model via JSON.parse.
97
- expect(JSON.parse(entry.snapshot)).toMatchObject(
98
- mockMakerStore.brandPageData,
99
- );
100
- // sizeBytes matches string length
101
- expect(entry.sizeBytes).toBe(entry.snapshot.length);
102
- });
103
-
104
- it('does not capture baseline twice', async () => {
105
- const store = useHistoryStore();
106
-
107
- await store.captureBaseline();
108
- const firstLength = store.entries.length;
109
-
110
- await store.captureBaseline();
111
-
112
- expect(store.entries.length).toBe(firstLength);
113
- });
114
-
115
- it('capture pushes a new entry and advances the pointer', async () => {
116
- const store = useHistoryStore();
117
-
118
- await store.captureBaseline();
119
- captureMutated(store);
120
-
121
- expect(store.entries.length).toBe(2);
122
- expect(store.currentIndex).toBe(1);
123
-
124
- captureMutated(store);
125
-
126
- expect(store.entries.length).toBe(3);
127
- expect(store.currentIndex).toBe(2);
128
- });
129
-
130
- it('capture truncates any redo tail (FR-11)', async () => {
131
- const store = useHistoryStore();
132
-
133
- await store.captureBaseline();
134
- captureMutated(store); // index 1
135
- captureMutated(store); // index 2
136
- await store.undo(); // currentIndex = 1, entries still length 3
137
- await store.undo(); // currentIndex = 0, entries still length 3
138
-
139
- expect(store.entries.length).toBe(3);
140
- expect(store.canRedo).toBe(true);
141
-
142
- // New capture should truncate entries[1..] and replace with the new one.
143
- captureMutated(store);
144
-
145
- expect(store.entries.length).toBe(2); // baseline + new
146
- expect(store.currentIndex).toBe(1);
147
- expect(store.canRedo).toBe(false);
148
- });
149
-
150
- it('drops byte-identical captures so undo/redo never replays a no-op', async () => {
151
- const store = useHistoryStore();
152
-
153
- await store.captureBaseline();
154
-
155
- // First capture mutates and lands.
156
- const r1 = captureMutated(store);
157
-
158
- expect(r1).toBe('committed');
159
- expect(store.entries.length).toBe(2);
160
-
161
- // Second capture without a mutation should be detected as a no-op
162
- // and skipped — the stack stays at length 2.
163
- const r2 = store.capture({ source: 'manual_edit', actor: 'user' });
164
-
165
- expect(r2).toBe('noop');
166
- expect(store.entries.length).toBe(2);
167
- expect(store.currentIndex).toBe(1);
168
- });
169
-
170
- it('captures pre-purchase logo edits even when brandPageData is byte-identical', async () => {
171
- // Pre-purchase logo-tab edits (text/color/font) live on
172
- // `editableLogoStore.logoEdits` (sidecar) and the rendered base64 is
173
- // stripped from snapshots to stay under the 25 MB cap. Two
174
- // consecutive logo edits therefore produce identical snapshot bytes
175
- // — without comparing the sidecar, every logo edit would dedup as
176
- // a no-op and history would silently stop growing.
177
- const editableLogoStore = useUiEditableLogoStore();
178
-
179
- editableLogoStore.logoEdits.textObjects = [
180
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
181
- { id: 't1', role: 'primary-text', text: 'Acme' } as any,
182
- ];
183
-
184
- const store = useHistoryStore();
185
-
186
- await store.captureBaseline();
187
- expect(store.entries.length).toBe(1);
188
-
189
- // User changes the logo text — only the sidecar mutates. Snapshot
190
- // bytes are identical (no brandPageData diff). Must still commit.
191
- editableLogoStore.logoEdits.textObjects[0].text = 'AcmeCo';
192
-
193
- const r = store.capture({ source: 'manual_edit', actor: 'user' });
194
-
195
- expect(r).toBe('committed');
196
- expect(store.entries.length).toBe(2);
197
- });
198
-
199
- // BP-5506 phantom-undo prevention via the `mergeNextMutation()` flag.
200
- // Hydration write sites (TipTap mount, lazy paymentConfig fill,
201
- // stale-page-ref clear) call this immediately before the imperative
202
- // write they know is a hydration. The watcher's flush consumes the flag
203
- // and folds the resulting snapshot into the current head entry instead
204
- // of pushing a new one — so opening a sidebar or clicking a section
205
- // doesn't grow the undo stack.
206
- describe('mergeNextMutation flag (hydration writes fold into head)', () => {
207
- // Internal constants from the watcher: BASELINE_SETTLE_MS and
208
- // CAPTURE_DEBOUNCE_MS. Tests can't import them directly without
209
- // exporting from the store; we use vi.useFakeTimers + advance past
210
- // the debounce to drive the watcher's flush.
211
- const CAPTURE_DEBOUNCE_MS = 1700;
212
- const BASELINE_SETTLE_MS = 2000;
213
-
214
- let liveData: Record<string, unknown>;
215
-
216
- // Vue's deep watcher only fires on a reactive proxy. The shared mock
217
- // is a plain object — for this group we swap in a `reactive()` proxy
218
- // and mutate it directly so the watcher (installed on store baseline
219
- // capture) sees the change.
220
- beforeEach(() => {
221
- vi.useFakeTimers();
222
- liveData = reactive({ v: 1, pages: [], styles: {} });
223
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
224
- (mockMakerStore as any).brandPageData = liveData;
225
- });
226
-
227
- afterEach(() => {
228
- vi.useRealTimers();
229
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
230
- (mockMakerStore as any).brandPageData = {
231
- v: 1,
232
- pages: [],
233
- styles: {},
234
- };
235
- });
236
-
237
- // Drive the watcher: flush Vue's reactive queue (so the watch
238
- // callback runs and arms the timer), then advance past
239
- // CAPTURE_DEBOUNCE_MS so the queued flush fires.
240
- async function flushWatcher(): Promise<void> {
241
- await Promise.resolve();
242
- await vi.advanceTimersByTimeAsync(CAPTURE_DEBOUNCE_MS + 50);
243
- }
244
-
245
- async function elapseSettleWindow(): Promise<void> {
246
- await vi.advanceTimersByTimeAsync(BASELINE_SETTLE_MS + 50);
247
- }
248
-
249
- it('mergeNextMutation folds the next watcher flush into the current head (no new entry)', async () => {
250
- const store = useHistoryStore();
251
-
252
- await store.captureBaseline();
253
- await elapseSettleWindow();
254
-
255
- // First real edit lands as a new entry.
256
- liveData.v = 100;
257
- await flushWatcher();
258
-
259
- expect(store.entries.length).toBe(2);
260
- expect(store.currentIndex).toBe(1);
261
- const headBefore = store.entries[1].snapshot;
262
-
263
- // Now a hydration site flags the next mutation as merge-into-head.
264
- store.mergeNextMutation();
265
- liveData.hydratedField = '<p>hydrated</p>';
266
- await flushWatcher();
267
-
268
- // No new entry — the head was replaced in place.
269
- expect(store.entries.length).toBe(2);
270
- expect(store.currentIndex).toBe(1);
271
- expect(store.entries[1].snapshot).not.toBe(headBefore);
272
- expect(JSON.parse(store.entries[1].snapshot).hydratedField).toBe(
273
- '<p>hydrated</p>',
274
- );
275
- });
276
-
277
- it('mergeNextMutation is one-shot — the flag clears after a single flush', async () => {
278
- const store = useHistoryStore();
279
-
280
- await store.captureBaseline();
281
- await elapseSettleWindow();
282
-
283
- // Land a real entry first so the head isn't the baseline (where
284
- // the post-settle merge-into-baseline rule no longer applies but
285
- // the merge branch is still doing its job).
286
- liveData.v = 100;
287
- await flushWatcher();
288
-
289
- expect(store.entries.length).toBe(2);
290
-
291
- // First post-baseline mutation: flagged as merge — folds into head.
292
- store.mergeNextMutation();
293
- liveData.v = 150;
294
- await flushWatcher();
295
-
296
- expect(store.entries.length).toBe(2);
297
-
298
- // Second mutation: no flag — must push a new entry.
299
- liveData.v = 200;
300
- await flushWatcher();
301
-
302
- expect(store.entries.length).toBe(3);
303
- expect(store.currentIndex).toBe(2);
304
- });
305
-
306
- it('multiple mergeNextMutation calls in the same burst still produce only one merge', async () => {
307
- // Idempotent flag — every editor on a page mounting at once calls
308
- // it, but the watcher debounces all the mutations into a single
309
- // flush, which consumes the flag once.
310
- const store = useHistoryStore();
311
-
312
- await store.captureBaseline();
313
- await elapseSettleWindow();
314
-
315
- // Three hydration writes from three editor mounts in the same tick.
316
- store.mergeNextMutation();
317
- liveData.a = '1';
318
- store.mergeNextMutation();
319
- liveData.b = '2';
320
- store.mergeNextMutation();
321
- liveData.c = '3';
322
- await flushWatcher();
323
-
324
- // All three writes folded into the same merge — still one entry.
325
- expect(store.entries.length).toBe(1);
326
- const merged = JSON.parse(store.entries[0].snapshot);
327
-
328
- expect(merged.a).toBe('1');
329
- expect(merged.b).toBe('2');
330
- expect(merged.c).toBe('3');
331
- });
332
-
333
- it('a follow-up real edit after a merged hydration still pushes a new entry', async () => {
334
- const store = useHistoryStore();
335
-
336
- await store.captureBaseline();
337
- await elapseSettleWindow();
338
-
339
- // Land a real entry first.
340
- liveData.v = 100;
341
- await flushWatcher();
342
-
343
- expect(store.entries.length).toBe(2);
344
-
345
- // Hydration burst.
346
- store.mergeNextMutation();
347
- liveData.hydrated = 'yes';
348
- await flushWatcher();
349
-
350
- expect(store.entries.length).toBe(2);
351
-
352
- // User makes a real edit — must push.
353
- liveData.realEdit = 'yes';
354
- await flushWatcher();
355
-
356
- expect(store.entries.length).toBe(3);
357
- expect(store.currentIndex).toBe(2);
358
- });
359
-
360
- it('drops a stale merge arm if no mutation follows in the same tick', async () => {
361
- // Repro for the "first pre-purchase logo edit not captured" bug:
362
- // a hydration site (e.g. ContentSettings.mounted on tab switch)
363
- // armed the flag, but the user navigated away before the flagged
364
- // write happened. A LATER unrelated mutation must NOT inherit
365
- // the stale arm and merge — it must commit normally.
366
- const store = useHistoryStore();
367
-
368
- await store.captureBaseline();
369
- await elapseSettleWindow();
370
-
371
- // Land a real entry first.
372
- liveData.v = 100;
373
- await flushWatcher();
374
-
375
- expect(store.entries.length).toBe(2);
376
-
377
- // Hydration site arms the flag, but no mutation follows in the
378
- // same tick — caller navigated away.
379
- store.mergeNextMutation();
380
- // Advance past the macrotask cleanup, no mutation in between.
381
- await vi.advanceTimersByTimeAsync(10);
382
-
383
- // Some time later, a real edit happens.
384
- liveData.unrelated = 'edit';
385
- await flushWatcher();
386
-
387
- // Must push, not merge.
388
- expect(store.entries.length).toBe(3);
389
- expect(store.currentIndex).toBe(2);
390
- });
391
-
392
- it('clears any armed merge flag during snapshot apply (undo/redo)', async () => {
393
- const store = useHistoryStore();
394
-
395
- await store.captureBaseline();
396
- await elapseSettleWindow();
397
-
398
- // Land two real entries.
399
- liveData.v = 100;
400
- await flushWatcher();
401
- liveData.v = 200;
402
- await flushWatcher();
403
-
404
- expect(store.entries.length).toBe(3);
405
-
406
- // Hydration site arms the flag right before the user clicks Undo.
407
- // The flag must NOT survive the apply — otherwise the post-apply
408
- // watcher tick would silently fold the just-restored snapshot into
409
- // entries[currentIndex.value], erasing the user's redo.
410
- store.mergeNextMutation();
411
- await store.undo();
412
-
413
- // Drive any pending watcher tick.
414
- liveData.postApply = 'edit';
415
- await flushWatcher();
416
-
417
- // The post-apply edit pushed a new entry — proves the merge flag
418
- // was cleared, not consumed by the apply tick.
419
- // (Pointer was at 1 after undo, post-apply edit pushes to 2, length 3.)
420
- expect(store.entries.length).toBe(3);
421
- expect(store.currentIndex).toBe(2);
422
- });
423
-
424
- it('reset() clears any armed merge flag', async () => {
425
- const store = useHistoryStore();
426
-
427
- await store.captureBaseline();
428
- await elapseSettleWindow();
429
-
430
- // Land a real entry first so reset has something to reset.
431
- liveData.v = 100;
432
- await flushWatcher();
433
-
434
- store.mergeNextMutation();
435
- store.reset();
436
-
437
- // First real mutation after reset must push, not merge.
438
- await elapseSettleWindow();
439
- liveData.postReset = 'edit';
440
- await flushWatcher();
441
-
442
- expect(store.entries.length).toBe(2);
443
- });
444
- });
445
-
446
- it('reentrancy guard: capture no-ops when isApplyingSnapshot is true', async () => {
447
- const store = useHistoryStore();
448
-
449
- await store.captureBaseline();
450
- const initialLength = store.entries.length;
451
-
452
- store.isApplyingSnapshot = true;
453
- store.capture({ source: 'manual_edit', actor: 'user' });
454
-
455
- expect(store.entries.length).toBe(initialLength);
456
- store.isApplyingSnapshot = false;
457
- });
458
-
459
- it('canUndo requires currentIndex > 0', async () => {
460
- const store = useHistoryStore();
461
-
462
- expect(store.canUndo).toBe(false);
463
-
464
- await store.captureBaseline();
465
- expect(store.canUndo).toBe(false); // only baseline
466
-
467
- captureMutated(store);
468
- expect(store.canUndo).toBe(true);
469
- });
470
-
471
- it('canRedo requires currentIndex < entries.length - 1', async () => {
472
- const store = useHistoryStore();
473
-
474
- expect(store.canRedo).toBe(false);
475
-
476
- await store.captureBaseline();
477
- captureMutated(store);
478
- expect(store.canRedo).toBe(false);
479
-
480
- await store.undo();
481
- expect(store.canRedo).toBe(true);
482
- });
483
-
484
- it('undo decrements pointer, keeps stack intact, calls applyHistorySnapshot', async () => {
485
- const store = useHistoryStore();
486
-
487
- await store.captureBaseline();
488
- const baselineBrandPageData = JSON.parse(store.entries[0].snapshot);
489
-
490
- captureMutated(store);
491
-
492
- expect(store.currentIndex).toBe(1);
493
- expect(store.entries.length).toBe(2);
494
-
495
- await store.undo();
496
-
497
- expect(store.currentIndex).toBe(0);
498
- expect(store.entries.length).toBe(2); // stack is unchanged, pointer moved
499
- expect(mockMakerStore.applyHistorySnapshot).toHaveBeenCalledTimes(1);
500
- // Undo restores the baseline snapshot — the parsed BrandPageModel must
501
- // round-trip back through applyHistorySnapshot. Second arg is the
502
- // resolved activePageId (undefined here because no entry has one).
503
- // Third arg is the captured logoEdits JSON sidecar.
504
- expect(mockMakerStore.applyHistorySnapshot).toHaveBeenCalledWith(
505
- expect.objectContaining(baselineBrandPageData),
506
- undefined,
507
- expect.any(String),
508
- );
509
- });
510
-
511
- it('redo increments pointer, keeps stack intact, calls applyHistorySnapshot', async () => {
512
- const store = useHistoryStore();
513
-
514
- await store.captureBaseline();
515
- captureMutated(store);
516
- await store.undo();
517
- mockMakerStore.applyHistorySnapshot.mockClear();
518
-
519
- expect(store.currentIndex).toBe(0);
520
- expect(store.canRedo).toBe(true);
521
-
522
- await store.redo();
523
-
524
- expect(store.currentIndex).toBe(1);
525
- expect(store.entries.length).toBe(2);
526
- expect(mockMakerStore.applyHistorySnapshot).toHaveBeenCalledTimes(1);
527
- });
528
-
529
- it('baseline invariant: undo refuses to go below index 0', async () => {
530
- const store = useHistoryStore();
531
-
532
- await store.captureBaseline();
533
-
534
- expect(store.canUndo).toBe(false);
535
- await store.undo();
536
-
537
- expect(store.currentIndex).toBe(0);
538
- expect(mockMakerStore.applyHistorySnapshot).not.toHaveBeenCalled();
539
- });
540
-
541
- it('getCurrentSizeBytes sums byte sizes across all entries', async () => {
542
- const store = useHistoryStore();
543
-
544
- expect(store.getCurrentSizeBytes()).toBe(0);
545
-
546
- await store.captureBaseline();
547
- const baselineSize = store.getCurrentSizeBytes();
548
-
549
- expect(baselineSize).toBe(store.entries[0].sizeBytes);
550
- expect(baselineSize).toBeGreaterThan(0);
551
-
552
- captureMutated(store);
553
- expect(store.getCurrentSizeBytes()).toBe(
554
- store.entries[0].sizeBytes + store.entries[1].sizeBytes,
555
- );
556
- });
557
-
558
- it('getCurrentEntryCount reflects the stack length', async () => {
559
- const store = useHistoryStore();
560
-
561
- expect(store.getCurrentEntryCount()).toBe(0);
562
-
563
- await store.captureBaseline();
564
- expect(store.getCurrentEntryCount()).toBe(1);
565
-
566
- captureMutated(store);
567
- expect(store.getCurrentEntryCount()).toBe(2);
568
- });
569
-
570
- // --- U1 finalize (FR-12 / FR-13 / FR-13c) ---
571
-
572
- it('FR-12: caps the stack at 40 entries, dropping oldest non-baseline', async () => {
573
- const store = useHistoryStore();
574
-
575
- await store.captureBaseline();
576
- const baselineSnapshot = store.entries[0].snapshot;
577
-
578
- // Baseline + 50 edits — stack should cap at 40.
579
- for (let i = 0; i < 50; i++) {
580
- // Mutate the mock so each snapshot is distinguishable.
581
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
582
- (mockMakerStore.brandPageData as any).v = i + 100;
583
- store.capture({ source: 'manual_edit', actor: 'user' });
584
- }
585
-
586
- expect(store.entries.length).toBe(40);
587
- // Baseline is preserved at index 0.
588
- expect(store.entries[0].snapshot).toBe(baselineSnapshot);
589
- // Pointer is at the new end.
590
- expect(store.currentIndex).toBe(39);
591
- });
592
-
593
- it('FR-13c: skips oversize captures (> 25 MB) and leaves the stack alone', async () => {
594
- const store = useHistoryStore();
595
-
596
- await store.captureBaseline();
597
- const lengthBefore = store.entries.length;
598
-
599
- // Swap in a mock that produces a > 25 MB snapshot on serialize.
600
- const originalData = mockMakerStore.brandPageData;
601
-
602
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
603
- (mockMakerStore as any).brandPageData = {
604
- v: 1,
605
- pages: [],
606
- styles: {},
607
- _padding: 'x'.repeat(26 * 1024 * 1024),
608
- };
609
-
610
- const result = store.capture({ source: 'manual_edit', actor: 'user' });
611
-
612
- expect(result).toBe('oversized');
613
- expect(store.entries.length).toBe(lengthBefore);
614
-
615
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
616
- (mockMakerStore as any).brandPageData = originalData;
617
- });
618
-
619
- it('FR-13: evicts oldest non-baseline when total memory exceeds 100 MB', async () => {
620
- const store = useHistoryStore();
621
-
622
- await store.captureBaseline();
623
- const baselineSnapshot = store.entries[0].snapshot;
624
-
625
- // Each capture is ~4 MB, under the 5 MB per-entry cap. Push 30
626
- // of them — total would be ~120 MB, so memory eviction should kick in.
627
- const originalData = mockMakerStore.brandPageData;
628
-
629
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
630
- (mockMakerStore as any).brandPageData = {
631
- v: 1,
632
- pages: [],
633
- styles: {},
634
- _padding: 'x'.repeat(4 * 1024 * 1024),
635
- };
636
-
637
- for (let i = 0; i < 30; i++) {
638
- store.capture({ source: 'manual_edit', actor: 'user' });
639
- }
640
-
641
- expect(store.getCurrentSizeBytes()).toBeLessThanOrEqual(100 * 1024 * 1024);
642
- // Baseline must survive memory eviction.
643
- expect(store.entries[0].snapshot).toBe(baselineSnapshot);
644
-
645
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
646
- (mockMakerStore as any).brandPageData = originalData;
647
- });
648
-
649
- // --- U5 analytics ---
650
-
651
- it('emits undo / redo events with entries_count + actor of the affected entry', async () => {
652
- const received: { name: string; props: unknown }[] = [];
653
-
654
- useEventTracker().subscribe((event) =>
655
- received.push({ name: event.eventName, props: event.eventProps }),
656
- );
657
-
658
- const store = useHistoryStore();
659
-
660
- await store.captureBaseline();
661
- captureMutated(store, { source: 'ai_changeset', actor: 'ai_builder' });
662
- await store.undo();
663
- await store.redo();
664
-
665
- const undoEvent = received.find((e) => e.name === 'website_maker_undo');
666
- const redoEvent = received.find((e) => e.name === 'website_maker_redo');
667
-
668
- expect(undoEvent).toBeDefined();
669
- expect(redoEvent).toBeDefined();
670
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
671
- expect((undoEvent!.props as any).entries_count).toBe(2);
672
- // Undo reports the actor of the entry being undone — the ai_builder change.
673
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
674
- expect((undoEvent!.props as any).actor).toBe('ai_builder');
675
- // Redo reports the actor of the entry being re-applied — same ai_builder change.
676
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
677
- expect((redoEvent!.props as any).actor).toBe('ai_builder');
678
- });
679
-
680
- it('emits oversized event when a capture exceeds 25 MB', async () => {
681
- const received: { name: string; props: unknown }[] = [];
682
-
683
- useEventTracker().subscribe((event) =>
684
- received.push({ name: event.eventName, props: event.eventProps }),
685
- );
686
-
687
- const store = useHistoryStore();
688
-
689
- await store.captureBaseline();
690
-
691
- const originalData = mockMakerStore.brandPageData;
692
-
693
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
694
- (mockMakerStore as any).brandPageData = {
695
- v: 1,
696
- pages: [],
697
- styles: {},
698
- _padding: 'x'.repeat(26 * 1024 * 1024),
699
- };
700
-
701
- store.capture({ source: 'manual_edit', actor: 'user' });
702
-
703
- const oversized = received.find(
704
- (e) => e.name === 'website_maker_history_oversized',
705
- );
706
-
707
- expect(oversized).toBeDefined();
708
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
709
- expect((oversized!.props as any).attempted_bytes).toBeGreaterThan(
710
- 25 * 1024 * 1024,
711
- );
712
-
713
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
714
- (mockMakerStore as any).brandPageData = originalData;
715
- });
716
-
717
- // --- BP-5506 (page-tab highlight + active-page tracking) ---
718
-
719
- describe('activePageId capture and restore', () => {
720
- it('captures the current activePageId on baseline', async () => {
721
- mockPageNavigationStore.activePageId = 'page-home';
722
-
723
- const store = useHistoryStore();
724
-
725
- await store.captureBaseline();
726
-
727
- expect(store.entries[0].activePageId).toBe('page-home');
728
- });
729
-
730
- it('captures the current activePageId on each force-commit', async () => {
731
- mockPageNavigationStore.activePageId = 'page-home';
732
- const store = useHistoryStore();
733
-
734
- await store.captureBaseline();
735
-
736
- mockPageNavigationStore.activePageId = 'page-2';
737
- captureMutated(store);
738
-
739
- expect(store.entries[1].activePageId).toBe('page-2');
740
- });
741
-
742
- it('passes the captured activePageId to applyHistorySnapshot when the page still exists', async () => {
743
- mockPageNavigationStore.activePageId = 'page-home';
744
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
745
- (mockMakerStore.brandPageData as any).pages = [
746
- { id: 'page-home', contents: [] },
747
- ];
748
-
749
- const store = useHistoryStore();
750
-
751
- await store.captureBaseline();
752
-
753
- mockPageNavigationStore.activePageId = 'page-2';
754
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
755
- (mockMakerStore.brandPageData as any).pages = [
756
- { id: 'page-home', contents: [] },
757
- { id: 'page-2', contents: [] },
758
- ];
759
- captureMutated(store);
760
-
761
- mockMakerStore.applyHistorySnapshot.mockClear();
762
- await store.undo();
763
-
764
- // Baseline restored — its captured page was 'page-home', and that
765
- // page exists in the restored snapshot, so it round-trips to apply.
766
- expect(mockMakerStore.applyHistorySnapshot).toHaveBeenCalledWith(
767
- expect.anything(),
768
- 'page-home',
769
- expect.any(String),
770
- );
771
- });
772
-
773
- it('walks back when the captured activePageId no longer exists in the restored snapshot (page-add undo)', async () => {
774
- // Baseline: only page-home, user is on page-home.
775
- mockPageNavigationStore.activePageId = 'page-home';
776
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
777
- (mockMakerStore.brandPageData as any).pages = [
778
- { id: 'page-home', contents: [] },
779
- ];
780
-
781
- const store = useHistoryStore();
782
-
783
- await store.captureBaseline();
784
-
785
- // User adds a new page. Snapshot now has both pages, user is on the
786
- // new page.
787
- mockPageNavigationStore.activePageId = 'page-new';
788
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
789
- (mockMakerStore.brandPageData as any).pages = [
790
- { id: 'page-home', contents: [] },
791
- { id: 'page-new', contents: [] },
792
- ];
793
- captureMutated(store);
794
-
795
- mockMakerStore.applyHistorySnapshot.mockClear();
796
- await store.undo();
797
-
798
- // Baseline only has 'page-home'. The entry being applied has
799
- // activePageId='page-home', so we resolve to that.
800
- expect(mockMakerStore.applyHistorySnapshot).toHaveBeenCalledWith(
801
- expect.anything(),
802
- 'page-home',
803
- expect.any(String),
804
- );
805
- });
806
-
807
- it('navigates to the restored page when undoing a delete (page present in target but not source)', async () => {
808
- // Baseline: home + about, user is on about (the page that will be deleted).
809
- mockPageNavigationStore.activePageId = 'page-about';
810
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
811
- (mockMakerStore.brandPageData as any).pages = [
812
- { id: 'page-home', contents: [] },
813
- { id: 'page-about', contents: [] },
814
- ];
815
-
816
- const store = useHistoryStore();
817
-
818
- await store.captureBaseline();
819
-
820
- // User deletes about. The delete handler navigates them to home,
821
- // then captures. Snapshot has only home; activePageId on the entry
822
- // is page-home (not page-about — the user was already off it by
823
- // the time the watcher fired).
824
- mockPageNavigationStore.activePageId = 'page-home';
825
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
826
- (mockMakerStore.brandPageData as any).pages = [
827
- { id: 'page-home', contents: [] },
828
- ];
829
- captureMutated(store);
830
-
831
- mockMakerStore.applyHistorySnapshot.mockClear();
832
- await store.undo();
833
-
834
- // Diff resolution: target snapshot (baseline) has page-about,
835
- // source snapshot (post-delete entry 1) does not. So page-about
836
- // is being restored — navigate there. Without diff resolution
837
- // the walk-back would land on page-home (the activePageId on
838
- // entry 1 and entry 0 both happen to be page-home in this
839
- // scenario), leaving the user staring at home after un-deleting
840
- // about.
841
- expect(mockMakerStore.applyHistorySnapshot).toHaveBeenCalledWith(
842
- expect.anything(),
843
- 'page-about',
844
- expect.any(String),
845
- );
846
- });
847
-
848
- it('navigates to the newly-added page when redoing an add (page present in target but not source)', async () => {
849
- // Baseline: only home.
850
- mockPageNavigationStore.activePageId = 'page-home';
851
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
852
- (mockMakerStore.brandPageData as any).pages = [
853
- { id: 'page-home', contents: [] },
854
- ];
855
-
856
- const store = useHistoryStore();
857
-
858
- await store.captureBaseline();
859
-
860
- // User adds page-new and is navigated there.
861
- mockPageNavigationStore.activePageId = 'page-new';
862
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
863
- (mockMakerStore.brandPageData as any).pages = [
864
- { id: 'page-home', contents: [] },
865
- { id: 'page-new', contents: [] },
866
- ];
867
- captureMutated(store);
868
-
869
- // Undo the add — back to baseline.
870
- await store.undo();
871
-
872
- // Redo the add. Source (baseline) has only home; target (entry 1)
873
- // has both home and new. The diff identifies page-new as the
874
- // page being restored by this transition — navigate there.
875
- mockMakerStore.applyHistorySnapshot.mockClear();
876
- await store.redo();
877
-
878
- expect(mockMakerStore.applyHistorySnapshot).toHaveBeenCalledWith(
879
- expect.anything(),
880
- 'page-new',
881
- expect.any(String),
882
- );
883
- });
884
-
885
- it('falls through to undefined when no entry walking back has a viable id', async () => {
886
- // Baseline page exists, captured id was 'page-home'.
887
- mockPageNavigationStore.activePageId = 'page-home';
888
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
889
- (mockMakerStore.brandPageData as any).pages = [
890
- { id: 'page-home', contents: [] },
891
- ];
892
-
893
- const store = useHistoryStore();
894
-
895
- await store.captureBaseline();
896
-
897
- // Snapshot contains a totally different page (e.g. template switch
898
- // replaced everything) and the user is on its new page.
899
- mockPageNavigationStore.activePageId = 'page-x';
900
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
901
- (mockMakerStore.brandPageData as any).pages = [
902
- { id: 'page-x', contents: [] },
903
- ];
904
- captureMutated(store);
905
-
906
- mockMakerStore.applyHistorySnapshot.mockClear();
907
- await store.undo();
908
-
909
- // Baseline restored — its activePageId 'page-home' exists in its own
910
- // snapshot, so resolution finds it on the first walk-back hop.
911
- expect(mockMakerStore.applyHistorySnapshot).toHaveBeenCalledWith(
912
- expect.anything(),
913
- 'page-home',
914
- expect.any(String),
915
- );
916
-
917
- // Now redo. Pointer moves forward to entry 1 (page-x captured),
918
- // page-x exists in entry-1's snapshot, so resolves to 'page-x'.
919
- mockMakerStore.applyHistorySnapshot.mockClear();
920
- await store.redo();
921
- expect(mockMakerStore.applyHistorySnapshot).toHaveBeenCalledWith(
922
- expect.anything(),
923
- 'page-x',
924
- expect.any(String),
925
- );
926
- });
927
- });
928
-
929
- describe('reset()', () => {
930
- it('clears entries and re-baselines against the current state', async () => {
931
- mockPageNavigationStore.activePageId = 'page-home';
932
-
933
- const store = useHistoryStore();
934
-
935
- await store.captureBaseline();
936
- captureMutated(store);
937
- captureMutated(store);
938
-
939
- expect(store.entries.length).toBe(3);
940
-
941
- // Mutate the mock so the new baseline reflects the just-saved state.
942
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
943
- (mockMakerStore.brandPageData as any).v = 9999;
944
-
945
- store.reset();
946
-
947
- expect(store.entries.length).toBe(1);
948
- expect(store.currentIndex).toBe(0);
949
- expect(store.baselineCaptured).toBe(true);
950
- expect(JSON.parse(store.entries[0].snapshot).v).toBe(9999);
951
- expect(store.canUndo).toBe(false);
952
- expect(store.canRedo).toBe(false);
953
- });
954
-
955
- it('captures activePageId on the new baseline after reset', async () => {
956
- mockPageNavigationStore.activePageId = 'page-home';
957
-
958
- const store = useHistoryStore();
959
-
960
- await store.captureBaseline();
961
- captureMutated(store);
962
-
963
- mockPageNavigationStore.activePageId = 'page-2';
964
- store.reset();
965
-
966
- expect(store.entries[0].activePageId).toBe('page-2');
967
- });
968
-
969
- it('no-ops when no baseline has ever been captured (unflagged session)', () => {
970
- // Simulates a session where the undo/redo feature flag is off, so
971
- // captureBaseline is never called. saveBrandPage calls reset()
972
- // unconditionally — it must not install the watcher or capture a
973
- // baseline as a side effect, otherwise unflagged sessions start
974
- // accumulating snapshots in memory.
975
- const store = useHistoryStore();
976
-
977
- expect(store.baselineCaptured).toBe(false);
978
- expect(store.entries.length).toBe(0);
979
-
980
- store.reset();
981
-
982
- expect(store.baselineCaptured).toBe(false);
983
- expect(store.entries.length).toBe(0);
984
- expect(store.currentIndex).toBe(-1);
985
- });
986
- });
987
-
988
- // BP-5506 follow-up: pre-purchase logo-tab edits (template switch, logo
989
- // colors, logo text, support text) author through `editableLogoStore.logoEdits`,
990
- // a sidecar that's NOT in `brandPageData`. The history entry carries a
991
- // `logoEdits` JSON sidecar — captured at the same instant as the snapshot,
992
- // applied on undo via `_syncLogoSettingsFromSnapshot`. Without this,
993
- // save-after-undo would re-derive metadata from the post-edit `logoEdits`
994
- // and silently reapply the change the user undid.
995
- describe('logoEdits sidecar capture', () => {
996
- it('stashes editableLogoStore.logoEdits as a JSON string on baseline', async () => {
997
- const editableLogoStore = useUiEditableLogoStore();
998
-
999
- editableLogoStore.logoEdits.textObjects = [
1000
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
1001
- {
1002
- id: 't1',
1003
- role: 'primary-text',
1004
- text: 'Acme',
1005
- textEnabled: true,
1006
- } as any,
1007
- ];
1008
-
1009
- const store = useHistoryStore();
1010
-
1011
- await store.captureBaseline();
1012
-
1013
- const stashed = store.entries[0].logoEdits;
1014
-
1015
- expect(typeof stashed).toBe('string');
1016
-
1017
- const parsed = JSON.parse(stashed!);
1018
-
1019
- expect(parsed.textObjects).toEqual([
1020
- { id: 't1', role: 'primary-text', text: 'Acme', textEnabled: true },
1021
- ]);
1022
- });
1023
-
1024
- it('captures distinct logoEdits per entry as the user edits', async () => {
1025
- const editableLogoStore = useUiEditableLogoStore();
1026
-
1027
- editableLogoStore.logoEdits.textObjects = [
1028
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
1029
- {
1030
- id: 't1',
1031
- role: 'primary-text',
1032
- text: 'Acme',
1033
- textEnabled: true,
1034
- } as any,
1035
- ];
1036
-
1037
- const store = useHistoryStore();
1038
-
1039
- await store.captureBaseline();
1040
-
1041
- // User types into the logo-text input — `logoEdits.textObjects[0].text`
1042
- // mutates and `updateEditableLogoImageUrlAsync` writes new base64 into
1043
- // brandPageData. The base64 mutation is what triggers the watcher; we
1044
- // simulate by also bumping `v` so the snapshot bytes change.
1045
- editableLogoStore.logoEdits.textObjects[0].text = 'AcmeCo';
1046
- captureMutated(store);
1047
-
1048
- const baselineLogoEdits = JSON.parse(store.entries[0].logoEdits!);
1049
- const editLogoEdits = JSON.parse(store.entries[1].logoEdits!);
1050
-
1051
- expect(baselineLogoEdits.textObjects[0].text).toBe('Acme');
1052
- expect(editLogoEdits.textObjects[0].text).toBe('AcmeCo');
1053
- });
1054
-
1055
- it('forwards captured logoEdits to applyHistorySnapshot on undo', async () => {
1056
- const editableLogoStore = useUiEditableLogoStore();
1057
-
1058
- editableLogoStore.logoEdits.textObjects = [
1059
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
1060
- {
1061
- id: 't1',
1062
- role: 'primary-text',
1063
- text: 'Acme',
1064
- textEnabled: true,
1065
- } as any,
1066
- ];
1067
-
1068
- const store = useHistoryStore();
1069
-
1070
- await store.captureBaseline();
1071
-
1072
- editableLogoStore.logoEdits.textObjects[0].text = 'AcmeCo';
1073
- captureMutated(store);
1074
-
1075
- mockMakerStore.applyHistorySnapshot.mockClear();
1076
- await store.undo();
1077
-
1078
- // Third arg is the entry's `logoEdits` — undo should hand the
1079
- // baseline's pre-edit blob back to the maker store.
1080
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
1081
- const callArgs = mockMakerStore.applyHistorySnapshot.mock
1082
- .calls[0] as any[];
1083
- const passedLogoEdits = JSON.parse(callArgs[2]);
1084
-
1085
- expect(passedLogoEdits.textObjects[0].text).toBe('Acme');
1086
- });
1087
-
1088
- it('forwards the post-edit logoEdits on redo', async () => {
1089
- const editableLogoStore = useUiEditableLogoStore();
1090
-
1091
- editableLogoStore.logoEdits.textObjects = [
1092
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
1093
- {
1094
- id: 't1',
1095
- role: 'primary-text',
1096
- text: 'Acme',
1097
- textEnabled: true,
1098
- } as any,
1099
- ];
1100
-
1101
- const store = useHistoryStore();
1102
-
1103
- await store.captureBaseline();
1104
-
1105
- editableLogoStore.logoEdits.textObjects[0].text = 'AcmeCo';
1106
- captureMutated(store);
1107
-
1108
- await store.undo();
1109
- mockMakerStore.applyHistorySnapshot.mockClear();
1110
- await store.redo();
1111
-
1112
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
1113
- const callArgs = mockMakerStore.applyHistorySnapshot.mock
1114
- .calls[0] as any[];
1115
- const passedLogoEdits = JSON.parse(callArgs[2]);
1116
-
1117
- expect(passedLogoEdits.textObjects[0].text).toBe('AcmeCo');
1118
- });
1119
- });
1120
- });