@prenta/admin 2.7.0 → 2.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (163) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/design-system/tokens.css +17 -0
  3. package/design-system/tokens.json +35 -1
  4. package/design-system/tokens.scss +15 -0
  5. package/design-system/tokens.tailwind.cjs +14 -0
  6. package/dist/AdminRoot.d.ts.map +1 -1
  7. package/dist/AdminRoot.js +2 -2
  8. package/dist/AdminRoot.js.map +1 -1
  9. package/dist/__tests__/lib/batch-wizard.test.d.ts +2 -0
  10. package/dist/__tests__/lib/batch-wizard.test.d.ts.map +1 -0
  11. package/dist/__tests__/lib/batch-wizard.test.js +144 -0
  12. package/dist/__tests__/lib/batch-wizard.test.js.map +1 -0
  13. package/dist/__tests__/lib/job-drawer.test.js +2 -0
  14. package/dist/__tests__/lib/job-drawer.test.js.map +1 -1
  15. package/dist/__tests__/lib/job-view.test.js +100 -3
  16. package/dist/__tests__/lib/job-view.test.js.map +1 -1
  17. package/dist/__tests__/lib/jobs-service.test.js +56 -2
  18. package/dist/__tests__/lib/jobs-service.test.js.map +1 -1
  19. package/dist/__tests__/views/appearance-chrome.render.test.d.ts +2 -0
  20. package/dist/__tests__/views/appearance-chrome.render.test.d.ts.map +1 -0
  21. package/dist/__tests__/views/appearance-chrome.render.test.js +688 -0
  22. package/dist/__tests__/views/appearance-chrome.render.test.js.map +1 -0
  23. package/dist/__tests__/views/header-footer-form.test.d.ts +2 -0
  24. package/dist/__tests__/views/header-footer-form.test.d.ts.map +1 -0
  25. package/dist/__tests__/views/header-footer-form.test.js +266 -0
  26. package/dist/__tests__/views/header-footer-form.test.js.map +1 -0
  27. package/dist/__tests__/views/jobs-drawer.render.test.js +442 -2
  28. package/dist/__tests__/views/jobs-drawer.render.test.js.map +1 -1
  29. package/dist/__tests__/views/jobs-list.render.test.js +7 -5
  30. package/dist/__tests__/views/jobs-list.render.test.js.map +1 -1
  31. package/dist/__tests__/views/jobs-wizard.render.test.d.ts +2 -0
  32. package/dist/__tests__/views/jobs-wizard.render.test.d.ts.map +1 -0
  33. package/dist/__tests__/views/jobs-wizard.render.test.js +588 -0
  34. package/dist/__tests__/views/jobs-wizard.render.test.js.map +1 -0
  35. package/dist/lib/api.d.ts +2 -0
  36. package/dist/lib/api.d.ts.map +1 -1
  37. package/dist/lib/api.js +8 -1
  38. package/dist/lib/api.js.map +1 -1
  39. package/dist/lib/batch-wizard.d.ts +84 -0
  40. package/dist/lib/batch-wizard.d.ts.map +1 -0
  41. package/dist/lib/batch-wizard.js +122 -0
  42. package/dist/lib/batch-wizard.js.map +1 -0
  43. package/dist/lib/chrome-service.d.ts +69 -0
  44. package/dist/lib/chrome-service.d.ts.map +1 -0
  45. package/dist/lib/chrome-service.js +108 -0
  46. package/dist/lib/chrome-service.js.map +1 -0
  47. package/dist/lib/job-view.d.ts +43 -0
  48. package/dist/lib/job-view.d.ts.map +1 -1
  49. package/dist/lib/job-view.js +57 -1
  50. package/dist/lib/job-view.js.map +1 -1
  51. package/dist/lib/jobs-service.d.ts +19 -1
  52. package/dist/lib/jobs-service.d.ts.map +1 -1
  53. package/dist/lib/jobs-service.js +41 -0
  54. package/dist/lib/jobs-service.js.map +1 -1
  55. package/dist/prenta-admin.css +1 -1
  56. package/dist/views/AppearanceChrome.d.ts +22 -4
  57. package/dist/views/AppearanceChrome.d.ts.map +1 -1
  58. package/dist/views/AppearanceChrome.js +451 -15
  59. package/dist/views/AppearanceChrome.js.map +1 -1
  60. package/dist/views/Jobs/CancelJobDialog.d.ts +7 -1
  61. package/dist/views/Jobs/CancelJobDialog.d.ts.map +1 -1
  62. package/dist/views/Jobs/CancelJobDialog.js +13 -3
  63. package/dist/views/Jobs/CancelJobDialog.js.map +1 -1
  64. package/dist/views/Jobs/DrawerReview.d.ts +7 -0
  65. package/dist/views/Jobs/DrawerReview.d.ts.map +1 -1
  66. package/dist/views/Jobs/DrawerReview.js +1 -1
  67. package/dist/views/Jobs/DrawerReview.js.map +1 -1
  68. package/dist/views/Jobs/JobDrawer.d.ts +23 -3
  69. package/dist/views/Jobs/JobDrawer.d.ts.map +1 -1
  70. package/dist/views/Jobs/JobDrawer.js +30 -5
  71. package/dist/views/Jobs/JobDrawer.js.map +1 -1
  72. package/dist/views/Jobs/JobsTable.d.ts +4 -2
  73. package/dist/views/Jobs/JobsTable.d.ts.map +1 -1
  74. package/dist/views/Jobs/JobsTable.js +6 -5
  75. package/dist/views/Jobs/JobsTable.js.map +1 -1
  76. package/dist/views/Jobs/NewBatchJob.d.ts +34 -0
  77. package/dist/views/Jobs/NewBatchJob.d.ts.map +1 -0
  78. package/dist/views/Jobs/NewBatchJob.js +282 -0
  79. package/dist/views/Jobs/NewBatchJob.js.map +1 -0
  80. package/dist/views/Jobs/WorkerStrip.d.ts +3 -1
  81. package/dist/views/Jobs/WorkerStrip.d.ts.map +1 -1
  82. package/dist/views/Jobs/WorkerStrip.js +2 -2
  83. package/dist/views/Jobs/WorkerStrip.js.map +1 -1
  84. package/dist/views/Jobs/focus.d.ts +6 -0
  85. package/dist/views/Jobs/focus.d.ts.map +1 -1
  86. package/dist/views/Jobs/focus.js +10 -0
  87. package/dist/views/Jobs/focus.js.map +1 -1
  88. package/dist/views/Jobs.d.ts +12 -3
  89. package/dist/views/Jobs.d.ts.map +1 -1
  90. package/dist/views/Jobs.js +109 -31
  91. package/dist/views/Jobs.js.map +1 -1
  92. package/dist/views/header-footer/AnnouncementTab.d.ts +15 -0
  93. package/dist/views/header-footer/AnnouncementTab.d.ts.map +1 -0
  94. package/dist/views/header-footer/AnnouncementTab.js +43 -0
  95. package/dist/views/header-footer/AnnouncementTab.js.map +1 -0
  96. package/dist/views/header-footer/ChromeField.d.ts +25 -0
  97. package/dist/views/header-footer/ChromeField.d.ts.map +1 -0
  98. package/dist/views/header-footer/ChromeField.js +99 -0
  99. package/dist/views/header-footer/ChromeField.js.map +1 -0
  100. package/dist/views/header-footer/ChromePreview.d.ts +31 -0
  101. package/dist/views/header-footer/ChromePreview.d.ts.map +1 -0
  102. package/dist/views/header-footer/ChromePreview.js +195 -0
  103. package/dist/views/header-footer/ChromePreview.js.map +1 -0
  104. package/dist/views/header-footer/DeveloperTab.d.ts +17 -0
  105. package/dist/views/header-footer/DeveloperTab.d.ts.map +1 -0
  106. package/dist/views/header-footer/DeveloperTab.js +98 -0
  107. package/dist/views/header-footer/DeveloperTab.js.map +1 -0
  108. package/dist/views/header-footer/RegionTab.d.ts +27 -0
  109. package/dist/views/header-footer/RegionTab.d.ts.map +1 -0
  110. package/dist/views/header-footer/RegionTab.js +80 -0
  111. package/dist/views/header-footer/RegionTab.js.map +1 -0
  112. package/dist/views/header-footer/chrome-form.d.ts +137 -0
  113. package/dist/views/header-footer/chrome-form.d.ts.map +1 -0
  114. package/dist/views/header-footer/chrome-form.js +518 -0
  115. package/dist/views/header-footer/chrome-form.js.map +1 -0
  116. package/dist/views/header-footer/code-samples.d.ts +30 -0
  117. package/dist/views/header-footer/code-samples.d.ts.map +1 -0
  118. package/dist/views/header-footer/code-samples.js +127 -0
  119. package/dist/views/header-footer/code-samples.js.map +1 -0
  120. package/dist/views/header-footer/controls.d.ts +57 -0
  121. package/dist/views/header-footer/controls.d.ts.map +1 -0
  122. package/dist/views/header-footer/controls.js +43 -0
  123. package/dist/views/header-footer/controls.js.map +1 -0
  124. package/package.json +3 -3
  125. package/src/AdminRoot.tsx +16 -2
  126. package/src/__tests__/lib/batch-wizard.test.ts +199 -0
  127. package/src/__tests__/lib/job-drawer.test.ts +2 -0
  128. package/src/__tests__/lib/job-view.test.ts +123 -2
  129. package/src/__tests__/lib/jobs-service.test.ts +72 -3
  130. package/src/__tests__/views/appearance-chrome.render.test.tsx +818 -0
  131. package/src/__tests__/views/header-footer-form.test.ts +333 -0
  132. package/src/__tests__/views/jobs-drawer.render.test.tsx +508 -2
  133. package/src/__tests__/views/jobs-list.render.test.tsx +8 -5
  134. package/src/__tests__/views/jobs-wizard.render.test.tsx +707 -0
  135. package/src/lib/api.ts +12 -1
  136. package/src/lib/batch-wizard.ts +188 -0
  137. package/src/lib/chrome-service.ts +200 -0
  138. package/src/lib/job-view.ts +95 -1
  139. package/src/lib/jobs-service.ts +56 -1
  140. package/src/styles/prenta-tokens.css +17 -0
  141. package/src/styles/theme.css +15 -0
  142. package/src/views/AppearanceChrome.tsx +704 -46
  143. package/src/views/Jobs/CancelJobDialog.tsx +20 -2
  144. package/src/views/Jobs/DrawerReview.tsx +1 -1
  145. package/src/views/Jobs/JobDrawer.tsx +83 -3
  146. package/src/views/Jobs/JobsTable.tsx +11 -4
  147. package/src/views/Jobs/NewBatchJob.tsx +857 -0
  148. package/src/views/Jobs/WorkerStrip.tsx +16 -11
  149. package/src/views/Jobs/focus.ts +11 -0
  150. package/src/views/Jobs.tsx +163 -49
  151. package/src/views/header-footer/AnnouncementTab.tsx +231 -0
  152. package/src/views/header-footer/ChromeField.tsx +358 -0
  153. package/src/views/header-footer/ChromePreview.tsx +497 -0
  154. package/src/views/header-footer/DeveloperTab.tsx +331 -0
  155. package/src/views/header-footer/RegionTab.tsx +398 -0
  156. package/src/views/header-footer/chrome-form.ts +603 -0
  157. package/src/views/header-footer/code-samples.ts +146 -0
  158. package/src/views/header-footer/controls.tsx +166 -0
  159. package/dist/views/Jobs/placeholders.d.ts +0 -6
  160. package/dist/views/Jobs/placeholders.d.ts.map +0 -1
  161. package/dist/views/Jobs/placeholders.js +0 -20
  162. package/dist/views/Jobs/placeholders.js.map +0 -1
  163. package/src/views/Jobs/placeholders.tsx +0 -66
@@ -0,0 +1,818 @@
1
+ // @vitest-environment happy-dom
2
+ // @vitest-environment-options {"settings":{"disableIframePageLoading":true}}
3
+ import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
5
+
6
+ /**
7
+ * Appearance → Header & Footer (P-11, P-12, P-13) against a fake `/chrome/*`
8
+ * API: every tab renders from the schema and saved values, saves send the
9
+ * ETag as `If-Match`, and the Developer tab's Copy puts the exact sample on
10
+ * the clipboard.
11
+ */
12
+
13
+ interface Call {
14
+ endpoint: string
15
+ method: string
16
+ headers: Record<string, string>
17
+ body: any
18
+ }
19
+
20
+ const HOUR = 3_600_000
21
+
22
+ function schemas() {
23
+ const readAt = new Date(Date.now() - 2 * HOUR).toISOString()
24
+ const source = { repo: 'actuate/website', branch: 'main', commit: 'a41f9c2e0b1d', readAt }
25
+ return [
26
+ {
27
+ region: 'header',
28
+ component: './src/components/site/Header.astro',
29
+ variants: { default: 'Default', centered: 'Centered', minimal: 'Logo + CTA' },
30
+ fields: {
31
+ logo: { type: 'brand', key: 'logo', label: 'Logo' },
32
+ menu: {
33
+ type: 'menu',
34
+ default: 'main',
35
+ label: 'Menu',
36
+ help: 'Mobile uses the menu assigned to Mobile, or this one.',
37
+ },
38
+ cta: { type: 'link', optional: true, label: 'Call-to-action button' },
39
+ phone: { type: 'text', optional: true, format: 'tel', label: 'Phone number' },
40
+ topMenu: { type: 'menu', optional: true, label: 'Top bar' },
41
+ search: { type: 'boolean', default: false, label: 'Search' },
42
+ sticky: { type: 'boolean', default: true, label: 'Stick to the top when scrolling' },
43
+ ...extraHeaderFields,
44
+ },
45
+ source,
46
+ },
47
+ {
48
+ region: 'footer',
49
+ component: './src/components/site/Footer.astro',
50
+ variants: { columns: 'Columns', compact: 'Compact', minimal: 'Single line' },
51
+ fields: {
52
+ tone: { type: 'select', options: ['dark', 'light'], default: 'dark', label: 'Background' },
53
+ about: { type: 'textarea', max: 200, label: 'About text' },
54
+ cols: { type: 'menu', list: true, max: 3, label: 'Link columns' },
55
+ social: { type: 'social', label: 'Social links' },
56
+ legalMenu: { type: 'menu', optional: true, label: 'Legal links' },
57
+ copy: { type: 'text', tokens: ['{year}'], label: 'Copyright' },
58
+ },
59
+ source,
60
+ },
61
+ ]
62
+ }
63
+
64
+ let extraHeaderFields: Record<string, unknown> = {}
65
+ let header: any
66
+ let footer: any
67
+ let announcement: any
68
+ let settings: { revalidateUrl: string | null; previewUrl: string | null }
69
+ let overridesStatus = 200
70
+ let calls: Call[] = []
71
+ let failNext: { endpoint: string; status: number; error: string } | null = null
72
+
73
+ function reset() {
74
+ extraHeaderFields = {}
75
+ header = {
76
+ region: 'header',
77
+ variant: 'default',
78
+ fields: {
79
+ menu: 'main',
80
+ cta: { on: true, label: 'Get a free audit', url: '/seo-audit' },
81
+ phone: { on: true, value: '(757) 555-0100' },
82
+ topMenu: { on: true, value: 'top' },
83
+ search: false,
84
+ sticky: true,
85
+ },
86
+ orphans: [{ key: 'tagline', value: 'Since 2009', orphanedAt: new Date().toISOString() }],
87
+ updatedBy: null,
88
+ updatedAt: null,
89
+ }
90
+ footer = {
91
+ region: 'footer',
92
+ variant: 'columns',
93
+ fields: {
94
+ tone: 'dark',
95
+ about: 'Digital marketing for growing businesses.',
96
+ cols: ['company'],
97
+ social: [{ platform: 'LinkedIn', url: 'https://linkedin.com/company/actuate' }],
98
+ legalMenu: { on: true, value: 'legal' },
99
+ copy: '© {year} Actuate Media. All rights reserved.',
100
+ },
101
+ orphans: [],
102
+ updatedBy: null,
103
+ updatedAt: null,
104
+ }
105
+ announcement = {
106
+ id: 'ann_1',
107
+ on: true,
108
+ text: 'Free SEO audits are back for Q4. Limited spots.',
109
+ link: { label: 'Book yours', url: '/seo-audit' },
110
+ tone: 'brand',
111
+ dismissible: true,
112
+ schedule: null,
113
+ }
114
+ settings = { revalidateUrl: 'https://actuatemedia.com/api/revalidate', previewUrl: null }
115
+ overridesStatus = 200
116
+ calls = []
117
+ failNext = null
118
+ }
119
+
120
+ const MENUS = [
121
+ {
122
+ slug: 'main',
123
+ title: 'Main',
124
+ data: {
125
+ slug: 'main',
126
+ name: 'Main',
127
+ items: [
128
+ { id: 'm1', label: 'Services' },
129
+ { id: 'm2', label: 'Work' },
130
+ ],
131
+ },
132
+ },
133
+ {
134
+ slug: 'top',
135
+ title: 'Top',
136
+ data: { slug: 'top', name: 'Top bar', items: [{ id: 't1', label: 'Careers' }] },
137
+ },
138
+ {
139
+ slug: 'company',
140
+ title: 'Footer · Company',
141
+ data: { slug: 'company', name: 'Footer · Company', items: [{ id: 'c1', label: 'About' }] },
142
+ },
143
+ {
144
+ slug: 'legal',
145
+ title: 'Legal',
146
+ data: { slug: 'legal', name: 'Legal', items: [{ id: 'l1', label: 'Privacy' }] },
147
+ },
148
+ ]
149
+
150
+ const OVERRIDES = {
151
+ pages: [
152
+ {
153
+ id: 'p1',
154
+ collection: 'pages',
155
+ title: 'Free SEO Audit',
156
+ path: '/seo-audit',
157
+ locale: null,
158
+ header: { variant: 'minimal' },
159
+ },
160
+ {
161
+ id: 'p2',
162
+ collection: 'pages',
163
+ title: 'Careers',
164
+ path: '/careers',
165
+ locale: null,
166
+ header: { fields: { cta: { on: false } } },
167
+ },
168
+ {
169
+ id: 'p3',
170
+ collection: 'pages',
171
+ title: 'Thank you',
172
+ path: '/thank-you',
173
+ locale: null,
174
+ footer: null,
175
+ },
176
+ ],
177
+ truncated: false,
178
+ }
179
+
180
+ function etag(name: string, value: unknown) {
181
+ return `W/"${name}-${JSON.stringify(value).length}"`
182
+ }
183
+
184
+ const cmsApi = vi.fn(async (endpoint: string, options: any = {}) => {
185
+ const method = (options.method ?? 'GET').toUpperCase()
186
+ const body = options.body ? JSON.parse(options.body) : undefined
187
+ calls.push({ endpoint, method, headers: options.headers ?? {}, body })
188
+ if (failNext && failNext.endpoint === endpoint && method !== 'GET') {
189
+ const { status, error } = failNext
190
+ failNext = null
191
+ return { error, status }
192
+ }
193
+ if (endpoint === '/chrome/schema') {
194
+ return {
195
+ data: {
196
+ regions: schemas(),
197
+ sync: {
198
+ status: 'synced',
199
+ source: schemas()[0]!.source,
200
+ regions: ['header', 'footer'],
201
+ fieldCount: 13,
202
+ },
203
+ },
204
+ status: 200,
205
+ }
206
+ }
207
+ if (endpoint === '/chrome/header' || endpoint === '/chrome/footer') {
208
+ const region = endpoint === '/chrome/header' ? 'header' : 'footer'
209
+ if (method === 'PUT') {
210
+ const next = {
211
+ ...(region === 'header' ? header : footer),
212
+ variant: body.variant,
213
+ fields: body.fields,
214
+ }
215
+ if (region === 'header') header = next
216
+ else footer = next
217
+ }
218
+ const value = region === 'header' ? header : footer
219
+ return { data: value, status: 200, etag: etag(region, value) }
220
+ }
221
+ if (endpoint.startsWith('/chrome/header/orphans/') && method === 'DELETE') {
222
+ const key = decodeURIComponent(endpoint.split('/').pop()!)
223
+ header = { ...header, orphans: header.orphans.filter((o: any) => o.key !== key) }
224
+ return { data: header, status: 200, etag: etag('header', header) }
225
+ }
226
+ if (endpoint === '/chrome/announcement') {
227
+ if (method === 'PUT') {
228
+ const changed =
229
+ body.text !== announcement.text ||
230
+ body.tone !== announcement.tone ||
231
+ JSON.stringify(body.link) !== JSON.stringify(announcement.link)
232
+ announcement = { ...body, id: changed ? 'ann_2' : announcement.id }
233
+ }
234
+ return { data: announcement, status: 200, etag: etag('announcement', announcement) }
235
+ }
236
+ if (endpoint === '/chrome/settings') {
237
+ if (method === 'PUT') {
238
+ const created = !settings.revalidateUrl && !!body.revalidateUrl
239
+ // A key left out is left alone, as on the server.
240
+ settings = {
241
+ revalidateUrl: 'revalidateUrl' in body ? body.revalidateUrl : settings.revalidateUrl,
242
+ previewUrl: 'previewUrl' in body ? body.previewUrl : settings.previewUrl,
243
+ }
244
+ return { data: { ...settings, ...(created ? { secret: 'whsec_test' } : {}) }, status: 200 }
245
+ }
246
+ return { data: settings, status: 200 }
247
+ }
248
+ if (endpoint === '/chrome/overrides') {
249
+ return overridesStatus === 200
250
+ ? { data: OVERRIDES, status: 200 }
251
+ : { error: 'Not found', status: overridesStatus }
252
+ }
253
+ if (endpoint.startsWith('/collections/navigations')) {
254
+ return { data: { docs: MENUS }, status: 200 }
255
+ }
256
+ if (endpoint === '/appearance') return { data: { siteTitle: 'Actuate' }, status: 200 }
257
+ return { error: `Unhandled ${method} ${endpoint}`, status: 404 }
258
+ })
259
+
260
+ vi.mock('../../lib/api.js', () => ({ cmsApi: (e: string, o?: unknown) => cmsApi(e, o) }))
261
+ const toast = vi.hoisted(() => Object.assign(vi.fn(), { error: vi.fn(), success: vi.fn() }))
262
+ vi.mock('sonner', () => ({ toast }))
263
+
264
+ const { AppearanceChrome } = await import('../../views/AppearanceChrome.js')
265
+ const { CODE_SAMPLES } = await import('../../views/header-footer/code-samples.js')
266
+
267
+ const navigate = vi.fn()
268
+ const COLLECTIONS = new Map([
269
+ ['pages', { slug: 'pages', type: 'page' }],
270
+ ['navigations', { slug: 'navigations' }],
271
+ ])
272
+
273
+ function renderScreen(role = 'EDITOR', tab?: string) {
274
+ window.history.replaceState(
275
+ null,
276
+ '',
277
+ tab ? `/admin/appearance/header?tab=${tab}` : '/admin/appearance/header',
278
+ )
279
+ return render(
280
+ <AppearanceChrome
281
+ onNavigate={navigate}
282
+ session={{ user: { role } }}
283
+ collections={COLLECTIONS}
284
+ />,
285
+ )
286
+ }
287
+
288
+ async function loaded() {
289
+ await screen.findByRole('heading', { name: 'Header & Footer', level: 1 })
290
+ await waitFor(() => expect(screen.queryByText(/Loading the header and footer/)).toBeNull())
291
+ }
292
+
293
+ function lastCall(endpoint: string, method: string) {
294
+ return [...calls].reverse().find((c) => c.endpoint === endpoint && c.method === method)
295
+ }
296
+
297
+ beforeEach(() => {
298
+ reset()
299
+ cmsApi.mockClear()
300
+ toast.mockClear()
301
+ toast.error.mockClear()
302
+ navigate.mockClear()
303
+ })
304
+
305
+ afterEach(() => {
306
+ vi.unstubAllGlobals()
307
+ })
308
+
309
+ describe('Header & Footer frame', () => {
310
+ it('shows the kit header, tabs in order and the Live badge', async () => {
311
+ renderScreen()
312
+ await loaded()
313
+ expect(screen.getByText(/Built by your developer in code\./)).toBeTruthy()
314
+ const tabs = screen.getAllByRole('tab').map((tab) => tab.textContent)
315
+ expect(tabs).toEqual(['Announcement barLive', 'Header', 'Footer', 'Developer'])
316
+ expect(
317
+ screen.getByRole('tab', { name: /Announcement bar/ }).getAttribute('aria-selected'),
318
+ ).toBe('true')
319
+ fireEvent.click(screen.getByRole('button', { name: 'Edit menus' }))
320
+ expect(navigate).toHaveBeenCalledWith('/appearance/menus')
321
+ })
322
+
323
+ it('opens the tab named in ?tab= and puts tab changes in the URL', async () => {
324
+ renderScreen('EDITOR', 'footer')
325
+ await loaded()
326
+ expect(screen.getByRole('tab', { name: 'Footer' }).getAttribute('aria-selected')).toBe('true')
327
+ fireEvent.click(screen.getByRole('tab', { name: 'Developer' }))
328
+ expect(navigate).toHaveBeenCalledWith('/appearance/header?tab=developer')
329
+ })
330
+ })
331
+
332
+ describe('Announcement bar tab (P-12)', () => {
333
+ it('renders the message with a live counter, the link, tones and switches', async () => {
334
+ renderScreen()
335
+ await loaded()
336
+ const message = screen.getByLabelText('Message') as HTMLInputElement
337
+ expect(message.value).toBe(announcement.text)
338
+ expect(message.maxLength).toBe(110)
339
+ expect(screen.getByText(/· keep it to one line on mobile\./).textContent).toBe(
340
+ '47/110 · keep it to one line on mobile.',
341
+ )
342
+ fireEvent.change(message, { target: { value: 'Short' } })
343
+ expect(screen.getByText(/· keep it to one line on mobile\./).textContent).toBe(
344
+ '5/110 · keep it to one line on mobile.',
345
+ )
346
+ expect((screen.getByLabelText('Link text') as HTMLInputElement).value).toBe('Book yours')
347
+ expect((screen.getByLabelText('Links to') as HTMLInputElement).value).toBe('/seo-audit')
348
+ const style = screen.getByRole('radiogroup', { name: 'Style' })
349
+ expect(
350
+ within(style)
351
+ .getAllByRole('radio')
352
+ .map((r) => (r as HTMLInputElement).value),
353
+ ).toEqual(['brand', 'dark', 'alert'])
354
+ expect(screen.getByText('Your site styles each tone with its own colors.')).toBeTruthy()
355
+ expect(
356
+ screen.getByRole('switch', { name: 'Visitors can dismiss it' }).getAttribute('aria-checked'),
357
+ ).toBe('true')
358
+ // The preview mock shows the draft bar.
359
+ expect(document.querySelector('[data-preview-announcement]')?.textContent).toContain('Short')
360
+ })
361
+
362
+ it('saves with If-Match, without an id, and a changed message gets a new id', async () => {
363
+ renderScreen()
364
+ await loaded()
365
+ fireEvent.change(screen.getByLabelText('Message'), { target: { value: 'New message' } })
366
+ fireEvent.click(screen.getByRole('radio', { name: 'Alert' }))
367
+ fireEvent.click(await screen.findByRole('button', { name: 'Save' }))
368
+ await waitFor(() => expect(toast).toHaveBeenCalledWith('Saved · site revalidated'))
369
+ const put = lastCall('/chrome/announcement', 'PUT')!
370
+ expect(put.headers['If-Match']).toMatch(/^W\/"announcement-/)
371
+ expect(put.body).toEqual({
372
+ on: true,
373
+ text: 'New message',
374
+ link: { label: 'Book yours', url: '/seo-audit' },
375
+ tone: 'alert',
376
+ dismissible: true,
377
+ schedule: null,
378
+ })
379
+ expect(announcement.id).toBe('ann_2')
380
+ await waitFor(() => expect(screen.queryByRole('button', { name: 'Save' })).toBeNull())
381
+ })
382
+
383
+ it('turning the bar off greys the body, and the badge follows the save', async () => {
384
+ renderScreen()
385
+ await loaded()
386
+ fireEvent.click(screen.getByRole('switch', { name: 'Show announcement bar' }))
387
+ const body = document.querySelector('[data-off]') as HTMLElement
388
+ expect(body.className).toContain('opacity-50')
389
+ expect(body.hasAttribute('inert')).toBe(true)
390
+ // Live until the change is saved.
391
+ expect(screen.getByRole('tab', { name: /Announcement bar/ }).textContent).toContain('Live')
392
+ fireEvent.click(screen.getByRole('button', { name: 'Save' }))
393
+ await waitFor(() =>
394
+ expect(screen.getByRole('tab', { name: /Announcement bar/ }).textContent).toContain('Off'),
395
+ )
396
+ expect(lastCall('/chrome/announcement', 'PUT')!.body.on).toBe(false)
397
+ })
398
+
399
+ it('schedules with zoned ISO times and refuses an end before the start', async () => {
400
+ renderScreen()
401
+ await loaded()
402
+ fireEvent.click(screen.getByRole('switch', { name: 'Schedule' }))
403
+ const starts = screen.getByLabelText('Starts')
404
+ const ends = screen.getByLabelText('Ends')
405
+ fireEvent.change(starts, { target: { value: '2026-11-01T09:00' } })
406
+ fireEvent.change(ends, { target: { value: '2026-10-31T09:00' } })
407
+ expect(await screen.findByText('The end has to be after the start.')).toBeTruthy()
408
+ expect((screen.getByRole('button', { name: 'Save' }) as HTMLButtonElement).disabled).toBe(true)
409
+
410
+ fireEvent.change(ends, { target: { value: '2026-11-08T17:30' } })
411
+ fireEvent.click(screen.getByRole('button', { name: 'Save' }))
412
+ await waitFor(() => expect(lastCall('/chrome/announcement', 'PUT')).toBeTruthy())
413
+ const { schedule } = lastCall('/chrome/announcement', 'PUT')!.body
414
+ expect(schedule).toEqual({
415
+ startsAt: new Date('2026-11-01T09:00').toISOString(),
416
+ endsAt: new Date('2026-11-08T17:30').toISOString(),
417
+ })
418
+ expect(schedule.startsAt).toMatch(/Z$/)
419
+ })
420
+
421
+ it('keeps the draft and says why when the save is stale', async () => {
422
+ renderScreen()
423
+ await loaded()
424
+ failNext = {
425
+ endpoint: '/chrome/announcement',
426
+ status: 412,
427
+ error: 'This changed since you opened it. Reload to see the latest, then save again.',
428
+ }
429
+ fireEvent.change(screen.getByLabelText('Message'), { target: { value: 'Mine' } })
430
+ fireEvent.click(screen.getByRole('button', { name: 'Save' }))
431
+ await waitFor(() =>
432
+ expect(toast.error).toHaveBeenCalledWith(
433
+ 'Announcement bar: This changed since you opened it. Reload to see the latest, then save again.',
434
+ ),
435
+ )
436
+ expect((screen.getByLabelText('Message') as HTMLInputElement).value).toBe('Mine')
437
+ expect(screen.getByRole('button', { name: 'Save' })).toBeTruthy()
438
+ })
439
+ })
440
+
441
+ describe('Header tab (P-11)', () => {
442
+ it('shows the source, variants from the schema and a field per schema key', async () => {
443
+ renderScreen('EDITOR', 'header')
444
+ await loaded()
445
+ expect(screen.getByText('src/components/site/Header.astro')).toBeTruthy()
446
+ expect(screen.getByText('Schema in sync · actuate/website@a41f9c2 · 2 hours ago')).toBeTruthy()
447
+ expect(screen.getByRole('link', { name: 'View in repo ↗' }).getAttribute('href')).toBe(
448
+ 'https://github.com/actuate/website/blob/a41f9c2e0b1d/src/components/site/Header.astro',
449
+ )
450
+ const variants = screen.getByRole('radiogroup', { name: 'header variant' })
451
+ expect(
452
+ within(variants)
453
+ .getAllByRole('radio')
454
+ .map((r) => r.textContent),
455
+ ).toEqual(['Defaultdefault', 'Centeredcentered', 'Logo + CTAminimal'])
456
+ expect(
457
+ within(variants)
458
+ .getByRole('radio', { name: /Default/ })
459
+ .getAttribute('aria-checked'),
460
+ ).toBe('true')
461
+ const keys = [...document.querySelectorAll('[data-field]')].map((el) =>
462
+ el.getAttribute('data-field'),
463
+ )
464
+ expect(keys).toEqual(['logo', 'menu', 'cta', 'phone', 'topMenu', 'search', 'sticky'])
465
+ expect(screen.getByText('From brand settings')).toBeTruthy()
466
+ expect((screen.getByLabelText('Button text') as HTMLInputElement).value).toBe(
467
+ 'Get a free audit',
468
+ )
469
+ expect((screen.getByRole('textbox', { name: 'Phone number' }) as HTMLInputElement).value).toBe(
470
+ '(757) 555-0100',
471
+ )
472
+ expect(
473
+ screen
474
+ .getByRole('switch', { name: 'Stick to the top when scrolling' })
475
+ .getAttribute('aria-checked'),
476
+ ).toBe('true')
477
+ })
478
+
479
+ it('shows a field the schema adds, with no admin change', async () => {
480
+ extraHeaderFields = {
481
+ promo: { type: 'text', label: 'Promo line', help: 'Shown beside the logo.' },
482
+ }
483
+ renderScreen('EDITOR', 'header')
484
+ await loaded()
485
+ expect(document.querySelector('[data-field="promo"]')).toBeTruthy()
486
+ const promo = screen.getByLabelText('Promo line') as HTMLInputElement
487
+ expect(screen.getByText('Shown beside the logo.')).toBeTruthy()
488
+ fireEvent.change(promo, { target: { value: 'Now booking' } })
489
+ fireEvent.click(screen.getByRole('button', { name: 'Save' }))
490
+ await waitFor(() => expect(lastCall('/chrome/header', 'PUT')).toBeTruthy())
491
+ expect(lastCall('/chrome/header', 'PUT')!.body.fields.promo).toBe('Now booking')
492
+ })
493
+
494
+ it('saves the variant and fields with If-Match', async () => {
495
+ renderScreen('EDITOR', 'header')
496
+ await loaded()
497
+ fireEvent.click(screen.getByRole('radio', { name: /Logo \+ CTA/ }))
498
+ fireEvent.click(screen.getByRole('switch', { name: 'Search' }))
499
+ fireEvent.click(screen.getByRole('button', { name: 'Save' }))
500
+ await waitFor(() => expect(toast).toHaveBeenCalledWith('Saved · site revalidated'))
501
+ const put = lastCall('/chrome/header', 'PUT')!
502
+ expect(put.headers['If-Match']).toMatch(/^W\/"header-/)
503
+ expect(put.body).toEqual({
504
+ variant: 'minimal',
505
+ fields: {
506
+ menu: 'main',
507
+ cta: { on: true, label: 'Get a free audit', url: '/seo-audit' },
508
+ phone: { on: true, value: '(757) 555-0100' },
509
+ topMenu: { on: true, value: 'top' },
510
+ search: true,
511
+ sticky: true,
512
+ },
513
+ })
514
+ })
515
+
516
+ it('blocks Save with the reason when a value would be refused', async () => {
517
+ renderScreen('EDITOR', 'header')
518
+ await loaded()
519
+ fireEvent.change(screen.getByLabelText('Links to'), {
520
+ target: { value: 'javascript:alert(1)' },
521
+ })
522
+ expect(
523
+ await screen.findByText('Links can be a site path or an http(s), mailto or tel link.'),
524
+ ).toBeTruthy()
525
+ expect((screen.getByRole('button', { name: 'Save' }) as HTMLButtonElement).disabled).toBe(true)
526
+ })
527
+
528
+ it('shows the orphan row from `orphans`, and Remove now deletes it', async () => {
529
+ renderScreen('EDITOR', 'header')
530
+ await loaded()
531
+ expect(screen.getByText('1 saved value isn’t in the schema anymore:')).toBeTruthy()
532
+ expect(screen.getByText('header.tagline')).toBeTruthy()
533
+ expect(
534
+ screen.getByText(/It’s no longer sent to the site and will be removed in 30 days\./),
535
+ ).toBeTruthy()
536
+ fireEvent.click(screen.getByRole('button', { name: 'Remove now' }))
537
+ await waitFor(() => expect(toast).toHaveBeenCalledWith('Removed header.tagline'))
538
+ expect(lastCall('/chrome/header/orphans/tagline', 'DELETE')).toBeTruthy()
539
+ expect(screen.queryByText('header.tagline')).toBeNull()
540
+ // Not a form change.
541
+ expect(screen.queryByRole('button', { name: 'Save' })).toBeNull()
542
+ })
543
+
544
+ it('keeps the old ETag when Remove now shows someone else saved meanwhile', async () => {
545
+ const loadedTag = etag('header', header)
546
+ renderScreen('EDITOR', 'header')
547
+ await loaded()
548
+ // Another editor saves the header after this form loaded.
549
+ header = { ...header, fields: { ...header.fields, sticky: false } }
550
+ fireEvent.click(screen.getByRole('button', { name: 'Remove now' }))
551
+ await waitFor(() =>
552
+ expect(toast.error).toHaveBeenCalledWith(
553
+ 'Removed. Someone else changed the header since you opened it: reload to see their changes before you save.',
554
+ ),
555
+ )
556
+ expect(screen.queryByText('header.tagline')).toBeNull()
557
+ fireEvent.change(screen.getByLabelText('Button text'), { target: { value: 'Mine' } })
558
+ fireEvent.click(screen.getByRole('button', { name: 'Save' }))
559
+ await waitFor(() => expect(lastCall('/chrome/header', 'PUT')).toBeTruthy())
560
+ // The stale form saves against the ETag it loaded with, so the server can refuse it.
561
+ expect(lastCall('/chrome/header', 'PUT')!.headers['If-Match']).toBe(loadedTag)
562
+ })
563
+
564
+ it('lists the pages that override the header', async () => {
565
+ renderScreen('EDITOR', 'header')
566
+ await loaded()
567
+ const card = screen.getByRole('region', { name: 'Page overrides' })
568
+ const rows = within(card).getAllByRole('link')
569
+ expect(rows.map((row) => row.textContent)).toEqual([
570
+ 'Free SEO Auditheader:minimal/seo-audit',
571
+ 'Careersheader.cta: off/careers',
572
+ ])
573
+ fireEvent.click(rows[0]!)
574
+ expect(navigate).toHaveBeenCalledWith('/pages/p1/edit')
575
+ })
576
+
577
+ it('says so when the CMS is too old to list overrides', async () => {
578
+ overridesStatus = 404
579
+ renderScreen('EDITOR', 'header')
580
+ await loaded()
581
+ expect(
582
+ screen.getByText('Update the CMS to list the pages that override the header.'),
583
+ ).toBeTruthy()
584
+ })
585
+
586
+ it('says so when the overrides fail to load', async () => {
587
+ overridesStatus = 500
588
+ renderScreen('EDITOR', 'header')
589
+ await loaded()
590
+ expect(screen.getByText('Couldn’t load the pages that override the header.')).toBeTruthy()
591
+ })
592
+
593
+ it('previews the draft in the mock, at desktop and mobile widths', async () => {
594
+ renderScreen('EDITOR', 'header')
595
+ await loaded()
596
+ expect(
597
+ screen.getByText('built-in mock · set a Preview URL to render your components'),
598
+ ).toBeTruthy()
599
+ const head = document.querySelector('[data-preview-header]') as HTMLElement
600
+ expect(head.textContent).toContain('Services')
601
+ expect(head.textContent).toContain('Get a free audit')
602
+ fireEvent.change(screen.getByLabelText('Button text'), { target: { value: 'Call us' } })
603
+ expect(document.querySelector('[data-preview-header]')!.textContent).toContain('Call us')
604
+ fireEvent.click(screen.getByRole('radio', { name: 'Mobile' }))
605
+ expect(document.querySelector('[data-preview-site]')!.getAttribute('data-viewport')).toBe('mob')
606
+ })
607
+
608
+ it('is read-only for a viewer', async () => {
609
+ renderScreen('VIEWER', 'header')
610
+ await loaded()
611
+ expect(screen.getByRole('note').textContent).toContain('only editors and admins can change')
612
+ expect((screen.getByLabelText('Button text') as HTMLInputElement).disabled).toBe(true)
613
+ expect((screen.getByRole('button', { name: 'Remove now' }) as HTMLButtonElement).disabled).toBe(
614
+ true,
615
+ )
616
+ expect((screen.getByRole('radio', { name: /Centered/ }) as HTMLButtonElement).disabled).toBe(
617
+ true,
618
+ )
619
+ })
620
+ })
621
+
622
+ describe('Footer tab (P-11)', () => {
623
+ it('renders the footer variants and fields from its schema', async () => {
624
+ renderScreen('EDITOR', 'footer')
625
+ await loaded()
626
+ expect(screen.getByText('src/components/site/Footer.astro')).toBeTruthy()
627
+ const variants = screen.getByRole('radiogroup', { name: 'footer variant' })
628
+ expect(
629
+ within(variants)
630
+ .getAllByRole('radio')
631
+ .map((r) => r.textContent),
632
+ ).toEqual(['Columnscolumns', 'Compactcompact', 'Single lineminimal'])
633
+ const background = screen.getByRole('radiogroup', { name: 'Background' })
634
+ expect(within(background).getAllByRole('radio')).toHaveLength(2)
635
+ expect((screen.getByLabelText('Link columns 1') as HTMLSelectElement).value).toBe('company')
636
+ expect(screen.getByLabelText('Link columns 3')).toBeTruthy()
637
+ expect(screen.getByText('{year} updates automatically.')).toBeTruthy()
638
+ const card = screen.getByRole('region', { name: 'Page overrides' })
639
+ expect(
640
+ within(card)
641
+ .getAllByRole('link')
642
+ .map((row) => row.textContent),
643
+ ).toEqual(['Thank youfooter: none/thank-you'])
644
+ // The mock fills {year}.
645
+ expect(document.querySelector('[data-preview-footer]')!.textContent).toContain(
646
+ `© ${new Date().getFullYear()} Actuate Media`,
647
+ )
648
+ })
649
+
650
+ it('adds and removes social links and saves the list', async () => {
651
+ renderScreen('EDITOR', 'footer')
652
+ await loaded()
653
+ fireEvent.click(screen.getByRole('button', { name: 'Add link' }))
654
+ const urls = screen.getAllByLabelText(/ URL$/) as HTMLInputElement[]
655
+ expect(urls).toHaveLength(2)
656
+ fireEvent.change(urls[1]!, { target: { value: 'https://facebook.com/actuate' } })
657
+ fireEvent.click(screen.getByRole('button', { name: 'Remove LinkedIn' }))
658
+ fireEvent.click(screen.getByRole('button', { name: 'Save' }))
659
+ await waitFor(() => expect(lastCall('/chrome/footer', 'PUT')).toBeTruthy())
660
+ expect(lastCall('/chrome/footer', 'PUT')!.body.fields.social).toEqual([
661
+ { platform: 'Facebook', url: 'https://facebook.com/actuate' },
662
+ ])
663
+ })
664
+ })
665
+
666
+ describe('Preview URL', () => {
667
+ it('puts the token in the query, keeping a fragment and replacing an old draft', async () => {
668
+ settings.previewUrl = 'https://preview.example.com/__prenta/chrome?draft=old&site=main#chrome'
669
+ renderScreen('EDITOR', 'header')
670
+ await loaded()
671
+ const frame = screen.getByTitle('Site preview with draft values') as HTMLIFrameElement
672
+ const src = new URL(frame.getAttribute('src')!)
673
+ expect(src.pathname).toBe('/__prenta/chrome')
674
+ expect(src.hash).toBe('#chrome')
675
+ expect(src.searchParams.getAll('draft')).toHaveLength(1)
676
+ expect(src.searchParams.get('draft')).toMatch(/^[0-9a-f]{24}$/)
677
+ expect(src.searchParams.get('site')).toBe('main')
678
+ })
679
+
680
+ it('renders the site in an iframe and posts the draft to its origin only', async () => {
681
+ settings.previewUrl = 'https://preview.example.com/__prenta/chrome'
682
+ renderScreen('EDITOR', 'header')
683
+ await loaded()
684
+ const frame = screen.getByTitle('Site preview with draft values') as HTMLIFrameElement
685
+ const src = new URL(frame.getAttribute('src')!)
686
+ expect(src.origin + src.pathname).toBe('https://preview.example.com/__prenta/chrome')
687
+ const token = src.searchParams.get('draft')!
688
+ expect(token).toMatch(/^[0-9a-f]{24}$/)
689
+ expect(screen.getByText('preview.example.com · draft values')).toBeTruthy()
690
+
691
+ const post = vi.fn()
692
+ Object.defineProperty(frame, 'contentWindow', {
693
+ value: { postMessage: post },
694
+ configurable: true,
695
+ })
696
+ fireEvent.change(screen.getByLabelText('Button text'), { target: { value: 'Call us' } })
697
+ await waitFor(() => expect(post).toHaveBeenCalled())
698
+ const [message, origin] = post.mock.calls.at(-1)!
699
+ expect(origin).toBe('https://preview.example.com')
700
+ expect(message.type).toBe('prenta:chrome-preview')
701
+ expect(message.token).toBe(token)
702
+ expect(message.chrome.header.fields.cta).toEqual({ label: 'Call us', url: '/seo-audit' })
703
+ expect(message.chrome.header.fields.menu).toEqual({
704
+ id: 'main',
705
+ name: 'Main',
706
+ items: [
707
+ { label: 'Services', url: '' },
708
+ { label: 'Work', url: '' },
709
+ ],
710
+ })
711
+ expect(message.chrome.announcement).toMatchObject({ id: 'ann_1', tone: 'brand' })
712
+
713
+ // A ready message from the frame gets the draft again; one from elsewhere doesn't.
714
+ post.mockClear()
715
+ await act(async () => {
716
+ window.dispatchEvent(
717
+ new MessageEvent('message', {
718
+ origin: 'https://evil.example',
719
+ data: { type: 'prenta:chrome-preview-ready', token },
720
+ }),
721
+ )
722
+ })
723
+ expect(post).not.toHaveBeenCalled()
724
+ })
725
+ })
726
+
727
+ describe('Developer tab (P-13)', () => {
728
+ it('shows the samples, the live schema status, settings and local dev', async () => {
729
+ renderScreen('ADMIN', 'developer')
730
+ await loaded()
731
+ const wired = screen.getByRole('region', { name: 'How it’s wired' })
732
+ expect(
733
+ within(wired)
734
+ .getAllByRole('tab')
735
+ .map((t) => t.textContent),
736
+ ).toEqual(['Schema', 'Layout', 'API'])
737
+ expect(within(wired).getByText('prenta.config.ts')).toBeTruthy()
738
+ expect(within(wired).getByRole('tabpanel').textContent).toContain(
739
+ "import { defineRegion, field } from '@prenta/core/config';",
740
+ )
741
+ fireEvent.click(within(wired).getByRole('tab', { name: 'API' }))
742
+ expect(within(wired).getByText('GET /api/cms/public/chrome?path=/seo-audit')).toBeTruthy()
743
+
744
+ const schema = screen.getByRole('region', { name: 'Schema' })
745
+ expect(within(schema).getByText('● In sync')).toBeTruthy()
746
+ expect(within(schema).getByText('actuate/website@main')).toBeTruthy()
747
+ expect(within(schema).getByText('a41f9c2 · 2 hours ago')).toBeTruthy()
748
+ expect(within(schema).getByText('header · footer · announcement')).toBeTruthy()
749
+ expect(within(schema).getByText('13')).toBeTruthy()
750
+
751
+ expect((screen.getByLabelText('Revalidate URL') as HTMLInputElement).value).toBe(
752
+ 'https://actuatemedia.com/api/revalidate',
753
+ )
754
+ expect((screen.getByLabelText('Preview URL') as HTMLInputElement).value).toBe('')
755
+ const local = screen.getByRole('region', { name: 'Local dev' })
756
+ expect(local.textContent).toContain('npx prenta pull chrome // writes .prenta/chrome.json')
757
+ })
758
+
759
+ it('Copy puts the exact sample on the clipboard', async () => {
760
+ const writeText = vi.fn(async () => undefined)
761
+ vi.stubGlobal('navigator', { ...navigator, clipboard: { writeText } })
762
+ renderScreen('ADMIN', 'developer')
763
+ await loaded()
764
+ const wired = screen.getByRole('region', { name: 'How it’s wired' })
765
+ for (const sample of CODE_SAMPLES) {
766
+ fireEvent.click(within(wired).getByRole('tab', { name: sample.label }))
767
+ fireEvent.click(within(wired).getByRole('button', { name: `Copy ${sample.file}` }))
768
+ await waitFor(() => expect(writeText).toHaveBeenLastCalledWith(sample.src))
769
+ // The rendered code is the same text.
770
+ expect(within(wired).getByRole('tabpanel').textContent).toBe(
771
+ `Copy${sample.file}${sample.src}`,
772
+ )
773
+ }
774
+ expect(toast).toHaveBeenCalledWith('Copied')
775
+ })
776
+
777
+ it('saves the settings and shows a new signing secret once', async () => {
778
+ settings.revalidateUrl = null
779
+ renderScreen('ADMIN', 'developer')
780
+ await loaded()
781
+ fireEvent.change(screen.getByLabelText('Revalidate URL'), {
782
+ target: { value: 'https://example.com/api/revalidate' },
783
+ })
784
+ fireEvent.change(screen.getByLabelText('Preview URL'), {
785
+ target: { value: 'https://preview.example.com/__prenta/chrome' },
786
+ })
787
+ fireEvent.click(screen.getByRole('button', { name: 'Save' }))
788
+ await waitFor(() => expect(toast).toHaveBeenCalledWith('Saved'))
789
+ expect(lastCall('/chrome/settings', 'PUT')!.body).toEqual({
790
+ revalidateUrl: 'https://example.com/api/revalidate',
791
+ previewUrl: 'https://preview.example.com/__prenta/chrome',
792
+ })
793
+ expect(screen.getByText('whsec_test')).toBeTruthy()
794
+ })
795
+
796
+ it('sends only the URL that changed', async () => {
797
+ renderScreen('ADMIN', 'developer')
798
+ await loaded()
799
+ fireEvent.change(screen.getByLabelText('Preview URL'), {
800
+ target: { value: 'https://preview.example.com/__prenta/chrome' },
801
+ })
802
+ fireEvent.click(screen.getByRole('button', { name: 'Save' }))
803
+ await waitFor(() => expect(toast).toHaveBeenCalledWith('Saved'))
804
+ expect(lastCall('/chrome/settings', 'PUT')!.body).toEqual({
805
+ previewUrl: 'https://preview.example.com/__prenta/chrome',
806
+ })
807
+ })
808
+
809
+ it('only admins can change the settings', async () => {
810
+ settings.revalidateUrl = null
811
+ renderScreen('EDITOR', 'developer')
812
+ await loaded()
813
+ const revalidate = screen.getByLabelText('Revalidate URL') as HTMLInputElement
814
+ expect(revalidate.disabled).toBe(true)
815
+ expect(revalidate.placeholder).toBe('Only admins can see this')
816
+ expect((screen.getByLabelText('Preview URL') as HTMLInputElement).disabled).toBe(true)
817
+ })
818
+ })