@prenta/admin 1.13.0 → 1.15.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 (100) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/dist/__tests__/lib/api.test.d.ts +2 -0
  3. package/dist/__tests__/lib/api.test.d.ts.map +1 -0
  4. package/dist/__tests__/lib/api.test.js +58 -0
  5. package/dist/__tests__/lib/api.test.js.map +1 -0
  6. package/dist/__tests__/lib/seo-service-autopilot.test.d.ts +2 -0
  7. package/dist/__tests__/lib/seo-service-autopilot.test.d.ts.map +1 -0
  8. package/dist/__tests__/lib/seo-service-autopilot.test.js +171 -0
  9. package/dist/__tests__/lib/seo-service-autopilot.test.js.map +1 -0
  10. package/dist/__tests__/lib/seo-service-proposals.test.d.ts +2 -0
  11. package/dist/__tests__/lib/seo-service-proposals.test.d.ts.map +1 -0
  12. package/dist/__tests__/lib/seo-service-proposals.test.js +99 -0
  13. package/dist/__tests__/lib/seo-service-proposals.test.js.map +1 -0
  14. package/dist/__tests__/router/seo-tab-for-path.test.js +1 -0
  15. package/dist/__tests__/router/seo-tab-for-path.test.js.map +1 -1
  16. package/dist/__tests__/views/redirects-pane.render.test.js +30 -12
  17. package/dist/__tests__/views/redirects-pane.render.test.js.map +1 -1
  18. package/dist/__tests__/views/seo-autopilot-applied.render.test.d.ts +2 -0
  19. package/dist/__tests__/views/seo-autopilot-applied.render.test.d.ts.map +1 -0
  20. package/dist/__tests__/views/seo-autopilot-applied.render.test.js +330 -0
  21. package/dist/__tests__/views/seo-autopilot-applied.render.test.js.map +1 -0
  22. package/dist/__tests__/views/seo-autopilot-card.render.test.d.ts +2 -0
  23. package/dist/__tests__/views/seo-autopilot-card.render.test.d.ts.map +1 -0
  24. package/dist/__tests__/views/seo-autopilot-card.render.test.js +512 -0
  25. package/dist/__tests__/views/seo-autopilot-card.render.test.js.map +1 -0
  26. package/dist/__tests__/views/seo-proposals-tab.render.test.d.ts +2 -0
  27. package/dist/__tests__/views/seo-proposals-tab.render.test.d.ts.map +1 -0
  28. package/dist/__tests__/views/seo-proposals-tab.render.test.js +431 -0
  29. package/dist/__tests__/views/seo-proposals-tab.render.test.js.map +1 -0
  30. package/dist/__tests__/views/seo-settings.render.test.js +41 -4
  31. package/dist/__tests__/views/seo-settings.render.test.js.map +1 -1
  32. package/dist/components/seo/ChangePreview.d.ts +6 -0
  33. package/dist/components/seo/ChangePreview.d.ts.map +1 -0
  34. package/dist/components/seo/ChangePreview.js +18 -0
  35. package/dist/components/seo/ChangePreview.js.map +1 -0
  36. package/dist/components/seo/SeoIssueFixPanel.d.ts.map +1 -1
  37. package/dist/components/seo/SeoIssueFixPanel.js +1 -14
  38. package/dist/components/seo/SeoIssueFixPanel.js.map +1 -1
  39. package/dist/lib/api.d.ts +13 -2
  40. package/dist/lib/api.d.ts.map +1 -1
  41. package/dist/lib/api.js +1 -2
  42. package/dist/lib/api.js.map +1 -1
  43. package/dist/lib/seo-service.d.ts +293 -2
  44. package/dist/lib/seo-service.d.ts.map +1 -1
  45. package/dist/lib/seo-service.js +170 -0
  46. package/dist/lib/seo-service.js.map +1 -1
  47. package/dist/prenta-admin.css +1 -1
  48. package/dist/views/SEO.d.ts +5 -1
  49. package/dist/views/SEO.d.ts.map +1 -1
  50. package/dist/views/SEO.js +28 -9
  51. package/dist/views/SEO.js.map +1 -1
  52. package/dist/views/seo/AutopilotAppliedSection.d.ts +10 -0
  53. package/dist/views/seo/AutopilotAppliedSection.d.ts.map +1 -0
  54. package/dist/views/seo/AutopilotAppliedSection.js +295 -0
  55. package/dist/views/seo/AutopilotAppliedSection.js.map +1 -0
  56. package/dist/views/seo/ProposalsTab.d.ts +7 -0
  57. package/dist/views/seo/ProposalsTab.d.ts.map +1 -0
  58. package/dist/views/seo/ProposalsTab.js +334 -0
  59. package/dist/views/seo/ProposalsTab.js.map +1 -0
  60. package/dist/views/seo/RedirectsTab.d.ts +3 -1
  61. package/dist/views/seo/RedirectsTab.d.ts.map +1 -1
  62. package/dist/views/seo/RedirectsTab.js +14 -19
  63. package/dist/views/seo/RedirectsTab.js.map +1 -1
  64. package/dist/views/seo/relative-age.d.ts +7 -0
  65. package/dist/views/seo/relative-age.d.ts.map +1 -0
  66. package/dist/views/seo/relative-age.js +20 -0
  67. package/dist/views/seo/relative-age.js.map +1 -0
  68. package/dist/views/settings/SeoAutopilotCard.d.ts +30 -0
  69. package/dist/views/settings/SeoAutopilotCard.d.ts.map +1 -0
  70. package/dist/views/settings/SeoAutopilotCard.js +375 -0
  71. package/dist/views/settings/SeoAutopilotCard.js.map +1 -0
  72. package/dist/views/settings/SeoSettingsTab.d.ts.map +1 -1
  73. package/dist/views/settings/SeoSettingsTab.js +2 -1
  74. package/dist/views/settings/SeoSettingsTab.js.map +1 -1
  75. package/dist/views/settings/components.d.ts +3 -1
  76. package/dist/views/settings/components.d.ts.map +1 -1
  77. package/dist/views/settings/components.js +2 -2
  78. package/dist/views/settings/components.js.map +1 -1
  79. package/package.json +3 -3
  80. package/src/__tests__/lib/api.test.ts +76 -0
  81. package/src/__tests__/lib/seo-service-autopilot.test.ts +196 -0
  82. package/src/__tests__/lib/seo-service-proposals.test.ts +108 -0
  83. package/src/__tests__/router/seo-tab-for-path.test.ts +1 -0
  84. package/src/__tests__/views/redirects-pane.render.test.tsx +36 -16
  85. package/src/__tests__/views/seo-autopilot-applied.render.test.tsx +382 -0
  86. package/src/__tests__/views/seo-autopilot-card.render.test.tsx +610 -0
  87. package/src/__tests__/views/seo-proposals-tab.render.test.tsx +531 -0
  88. package/src/__tests__/views/seo-settings.render.test.tsx +47 -7
  89. package/src/components/seo/ChangePreview.tsx +47 -0
  90. package/src/components/seo/SeoIssueFixPanel.tsx +1 -44
  91. package/src/lib/api.ts +19 -3
  92. package/src/lib/seo-service.ts +455 -2
  93. package/src/views/SEO.tsx +41 -8
  94. package/src/views/seo/AutopilotAppliedSection.tsx +597 -0
  95. package/src/views/seo/ProposalsTab.tsx +761 -0
  96. package/src/views/seo/RedirectsTab.tsx +39 -60
  97. package/src/views/seo/relative-age.ts +15 -0
  98. package/src/views/settings/SeoAutopilotCard.tsx +826 -0
  99. package/src/views/settings/SeoSettingsTab.tsx +2 -0
  100. package/src/views/settings/components.tsx +10 -1
@@ -0,0 +1,531 @@
1
+ // @vitest-environment happy-dom
2
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
3
+ import { fireEvent, render, screen, waitFor } from '@testing-library/react'
4
+
5
+ import type {
6
+ SeoAutoApplyList,
7
+ SeoProposal,
8
+ SeoProposalBulkApplyResult,
9
+ SeoProposalsPayload,
10
+ } from '../../lib/seo-service.js'
11
+
12
+ function proposal(over: Partial<SeoProposal> = {}): SeoProposal {
13
+ return {
14
+ id: 'issue:i1',
15
+ kind: 'issue-fix',
16
+ title: 'Duplicate meta title',
17
+ entity: { type: 'page', id: 'd1', title: 'Pricing', url: '/pricing' },
18
+ changes: [{ field: 'metaTitle', label: 'Meta title', before: 'Dup', after: 'Unique Pricing' }],
19
+ justification: 'AI drafted a unique title.',
20
+ source: 'ai',
21
+ confidence: null,
22
+ brandAlignment: 88,
23
+ generatedAt: new Date(Date.now() - 3600_000).toISOString(),
24
+ fingerprint: 'fp-1',
25
+ stale: false,
26
+ issueType: 'duplicate-meta-title',
27
+ severity: 'warning',
28
+ issueId: 'i1',
29
+ suggestionId: null,
30
+ ...over,
31
+ }
32
+ }
33
+
34
+ function payload(over: Partial<SeoProposalsPayload> = {}): SeoProposalsPayload {
35
+ return {
36
+ proposals: [
37
+ proposal(),
38
+ proposal({
39
+ id: 'issue:i2',
40
+ issueId: 'i2',
41
+ fingerprint: 'fp-2',
42
+ title: 'Missing description',
43
+ brandAlignment: 60,
44
+ }),
45
+ proposal({
46
+ id: 'redirect:s1',
47
+ kind: 'redirect',
48
+ title: 'Redirect /old → /new',
49
+ source: 'ai-404-recovery',
50
+ confidence: 0.82,
51
+ brandAlignment: null,
52
+ fingerprint: null,
53
+ issueId: null,
54
+ suggestionId: 's1',
55
+ severity: null,
56
+ changes: [{ field: 'redirect', label: 'Redirect', before: '/old', after: '/new' }],
57
+ }),
58
+ ],
59
+ counts: { issueFixes: 2, redirects: 1, stale: 0, eligibleForGeneration: 5 },
60
+ governance: { issueFixBulk: true, redirectBulk: true },
61
+ autopilot: { enabled: false, pausedReason: null, lastRunAt: null, appliedLast7d: 0 },
62
+ ...over,
63
+ }
64
+ }
65
+
66
+ const fetchSeoProposals = vi.fn(async () => payload())
67
+ const fetchAutopilotApplied = vi.fn(
68
+ async (_params: unknown): Promise<SeoAutoApplyList> => ({
69
+ docs: [],
70
+ total: 0,
71
+ page: 1,
72
+ pageSize: 20,
73
+ canRevert: false,
74
+ }),
75
+ )
76
+ const revertAutopilotChange = vi.fn(async (_id: string, _force?: boolean) => ({
77
+ ok: true as const,
78
+ }))
79
+ const generateSeoProposals = vi.fn(async () => ({
80
+ result: { generated: 2, skipped: 0, failed: 0, remaining: 0, haltedReason: null },
81
+ }))
82
+ const bulkApplySeoProposals = vi.fn(
83
+ async (_items: unknown): Promise<{ result?: SeoProposalBulkApplyResult; error?: string }> => ({
84
+ result: { results: [], applied: 0, failed: 0, pending: 0 },
85
+ }),
86
+ )
87
+ const dismissSeoIssueFix = vi.fn(async (_id: string): Promise<{ error?: string }> => ({}))
88
+ const dismissRedirectSuggestion = vi.fn(async (_id: string): Promise<{ error?: string }> => ({}))
89
+
90
+ vi.mock('../../lib/seo-service.js', () => ({
91
+ fetchSeoProposals: () => fetchSeoProposals(),
92
+ generateSeoProposals: () => generateSeoProposals(),
93
+ bulkApplySeoProposals: (items: unknown) => bulkApplySeoProposals(items),
94
+ dismissSeoIssueFix: (id: string) => dismissSeoIssueFix(id),
95
+ dismissRedirectSuggestion: (id: string) => dismissRedirectSuggestion(id),
96
+ fetchAutopilotApplied: (params: unknown) => fetchAutopilotApplied(params),
97
+ revertAutopilotChange: (id: string, force?: boolean) => revertAutopilotChange(id, force),
98
+ }))
99
+ vi.mock('sonner', () => ({
100
+ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn(), loading: vi.fn() },
101
+ }))
102
+
103
+ const { ProposalsTab } = await import('../../views/seo/ProposalsTab.js')
104
+ const { toast } = await import('sonner')
105
+
106
+ function deferred<T>() {
107
+ let resolve!: (value: T) => void
108
+ let reject!: (reason: unknown) => void
109
+ const promise = new Promise<T>((res, rej) => {
110
+ resolve = res
111
+ reject = rej
112
+ })
113
+ return { promise, resolve, reject }
114
+ }
115
+
116
+ /** Let any already-settled promise continuations run. */
117
+ async function flushMicrotasks() {
118
+ await new Promise((r) => setTimeout(r, 0))
119
+ }
120
+
121
+ beforeEach(() => {
122
+ fetchSeoProposals.mockClear()
123
+ fetchSeoProposals.mockImplementation(async () => payload())
124
+ generateSeoProposals.mockClear()
125
+ bulkApplySeoProposals.mockClear()
126
+ dismissSeoIssueFix.mockClear()
127
+ dismissRedirectSuggestion.mockClear()
128
+ fetchAutopilotApplied.mockClear()
129
+ revertAutopilotChange.mockClear()
130
+ vi.mocked(toast.success).mockClear()
131
+ vi.mocked(toast.error).mockClear()
132
+ })
133
+
134
+ describe('ProposalsTab', () => {
135
+ it('renders rows with kind, badges, and expandable change preview', async () => {
136
+ render(<ProposalsTab planInfo={null} />)
137
+ expect(await screen.findByText('Duplicate meta title')).toBeTruthy()
138
+ expect(screen.getByText('82%')).toBeTruthy()
139
+ expect(screen.getByLabelText('Brand voice score 88 out of 100')).toBeTruthy()
140
+ fireEvent.click(screen.getByRole('button', { name: 'Show changes for Duplicate meta title' }))
141
+ expect(screen.getByRole('table', { name: 'Proposed SEO changes' })).toBeTruthy()
142
+ expect(screen.getByText('Unique Pricing')).toBeTruthy()
143
+ })
144
+
145
+ it('select-all selects only live rows and shows the bulk bar', async () => {
146
+ fetchSeoProposals.mockImplementationOnce(async () =>
147
+ payload({
148
+ proposals: [
149
+ proposal(),
150
+ proposal({ id: 'issue:stale', issueId: 'stale', stale: true, title: 'Stale one' }),
151
+ ],
152
+ counts: { issueFixes: 1, redirects: 0, stale: 1, eligibleForGeneration: 1 },
153
+ }),
154
+ )
155
+ render(<ProposalsTab planInfo={null} />)
156
+ await screen.findByText('Stale one')
157
+ expect(screen.getByText('Stale — regenerate')).toBeTruthy()
158
+ expect(screen.queryByLabelText('Select proposal Stale one')).toBeNull()
159
+ fireEvent.click(screen.getByLabelText('Select all proposals'))
160
+ expect(screen.getByRole('region', { name: 'Bulk actions' }).textContent).toContain('1 selected')
161
+ })
162
+
163
+ it('disables batch approve with a settings callout when governance forbids bulk apply', async () => {
164
+ fetchSeoProposals.mockImplementationOnce(async () =>
165
+ payload({ governance: { issueFixBulk: false, redirectBulk: true } }),
166
+ )
167
+ const onNavigate = vi.fn()
168
+ render(<ProposalsTab planInfo={null} onNavigate={onNavigate} />)
169
+ await screen.findByText('Duplicate meta title')
170
+ fireEvent.click(screen.getByLabelText('Select proposal Duplicate meta title'))
171
+ fireEvent.click(screen.getByLabelText('Select proposal Missing description'))
172
+ const approve = screen.getByRole('button', { name: 'Approve selected' })
173
+ expect(approve).toHaveProperty('disabled', true)
174
+ expect(screen.getByText(/Allow bulk apply/)).toBeTruthy()
175
+ expect(screen.queryByRole('link')).toBeNull()
176
+ fireEvent.click(screen.getByRole('button', { name: 'Open Settings → AI' }))
177
+ expect(onNavigate).toHaveBeenCalledWith('/settings?tab=ai')
178
+ })
179
+
180
+ it('renders the governance callout without a settings control when onNavigate is absent', async () => {
181
+ fetchSeoProposals.mockImplementationOnce(async () =>
182
+ payload({ governance: { issueFixBulk: false, redirectBulk: true } }),
183
+ )
184
+ render(<ProposalsTab planInfo={null} />)
185
+ await screen.findByText('Duplicate meta title')
186
+ fireEvent.click(screen.getByLabelText('Select all proposals'))
187
+ expect(screen.getByText(/Allow bulk apply/)).toBeTruthy()
188
+ expect(screen.queryByRole('button', { name: 'Open Settings → AI' })).toBeNull()
189
+ expect(screen.queryByRole('link')).toBeNull()
190
+ })
191
+
192
+ it('issues a single bulk-apply request when approve is triggered twice while in flight', async () => {
193
+ const inflight = deferred<{ result?: SeoProposalBulkApplyResult; error?: string }>()
194
+ bulkApplySeoProposals.mockImplementationOnce(() => inflight.promise)
195
+ render(<ProposalsTab planInfo={null} />)
196
+ await screen.findByText('Duplicate meta title')
197
+ const approve = screen.getByRole('button', { name: 'Approve Duplicate meta title' })
198
+ fireEvent.click(approve)
199
+ fireEvent.click(approve)
200
+ fireEvent.click(screen.getByRole('button', { name: 'Approve Missing description' }))
201
+ await flushMicrotasks()
202
+ expect(bulkApplySeoProposals).toHaveBeenCalledTimes(1)
203
+ expect(approve).toHaveProperty('disabled', true)
204
+ inflight.resolve({ result: { results: [], applied: 0, failed: 0, pending: 0 } })
205
+ await waitFor(() =>
206
+ expect(screen.getByRole('button', { name: 'Approve Duplicate meta title' })).toHaveProperty(
207
+ 'disabled',
208
+ false,
209
+ ),
210
+ )
211
+ })
212
+
213
+ it('disables every other mutation control while an apply is in flight', async () => {
214
+ const inflight = deferred<{ result?: SeoProposalBulkApplyResult; error?: string }>()
215
+ bulkApplySeoProposals.mockImplementationOnce(() => inflight.promise)
216
+ render(<ProposalsTab planInfo={null} />)
217
+ await screen.findByText('Duplicate meta title')
218
+ fireEvent.click(screen.getByLabelText('Select all proposals'))
219
+ fireEvent.click(screen.getByRole('button', { name: 'Approve selected' }))
220
+ fireEvent.click(await screen.findByRole('button', { name: 'Apply 3 proposals' }))
221
+ await waitFor(() => expect(bulkApplySeoProposals).toHaveBeenCalledTimes(1))
222
+ expect(screen.getByRole('button', { name: 'Dismiss selected' })).toHaveProperty(
223
+ 'disabled',
224
+ true,
225
+ )
226
+ expect(screen.getByRole('button', { name: 'Approve selected' })).toHaveProperty(
227
+ 'disabled',
228
+ true,
229
+ )
230
+ expect(screen.getByRole('button', { name: 'Dismiss Duplicate meta title' })).toHaveProperty(
231
+ 'disabled',
232
+ true,
233
+ )
234
+ expect(screen.getByRole('button', { name: 'Generate proposals (5 eligible)' })).toHaveProperty(
235
+ 'disabled',
236
+ true,
237
+ )
238
+ fireEvent.click(screen.getByRole('button', { name: 'Dismiss selected' }))
239
+ expect(dismissSeoIssueFix).not.toHaveBeenCalled()
240
+ inflight.resolve({ result: { results: [], applied: 0, failed: 0, pending: 0 } })
241
+ await waitFor(() =>
242
+ expect(
243
+ screen.getByRole('button', { name: 'Generate proposals (5 eligible)' }),
244
+ ).toHaveProperty('disabled', false),
245
+ )
246
+ })
247
+
248
+ it('re-enables controls and toasts when the apply request throws', async () => {
249
+ bulkApplySeoProposals.mockRejectedValueOnce(new Error('network down'))
250
+ render(<ProposalsTab planInfo={null} />)
251
+ await screen.findByText('Duplicate meta title')
252
+ fireEvent.click(screen.getByRole('button', { name: 'Approve Duplicate meta title' }))
253
+ await waitFor(() => expect(toast.error).toHaveBeenCalledWith('network down'))
254
+ await waitFor(() =>
255
+ expect(screen.getByRole('button', { name: 'Approve Duplicate meta title' })).toHaveProperty(
256
+ 'disabled',
257
+ false,
258
+ ),
259
+ )
260
+ expect(screen.getByRole('button', { name: 'Dismiss Duplicate meta title' })).toHaveProperty(
261
+ 'disabled',
262
+ false,
263
+ )
264
+ })
265
+
266
+ it('stops the generate loop and stays silent after unmount', async () => {
267
+ const firstPass = deferred<{
268
+ result: {
269
+ generated: number
270
+ skipped: number
271
+ failed: number
272
+ remaining: number
273
+ haltedReason: null
274
+ }
275
+ }>()
276
+ generateSeoProposals.mockImplementationOnce(() => firstPass.promise)
277
+ const { unmount } = render(<ProposalsTab planInfo={null} />)
278
+ fireEvent.click(await screen.findByRole('button', { name: 'Generate proposals (5 eligible)' }))
279
+ fireEvent.click(await screen.findByRole('button', { name: 'Generate' }))
280
+ await waitFor(() => expect(generateSeoProposals).toHaveBeenCalledTimes(1))
281
+ unmount()
282
+ firstPass.resolve({
283
+ result: { generated: 1, skipped: 0, failed: 0, remaining: 1, haltedReason: null },
284
+ })
285
+ await flushMicrotasks()
286
+ await flushMicrotasks()
287
+ expect(generateSeoProposals).toHaveBeenCalledTimes(1)
288
+ expect(toast.success).not.toHaveBeenCalled()
289
+ expect(toast.error).not.toHaveBeenCalled()
290
+ })
291
+
292
+ it('confirming batch approve calls bulkApplySeoProposals with selected items and reports partial results', async () => {
293
+ bulkApplySeoProposals.mockResolvedValueOnce({
294
+ result: {
295
+ results: [
296
+ { kind: 'issue-fix', id: 'i1', status: 'applied' },
297
+ { kind: 'issue-fix', id: 'i2', status: 'stale', reason: 'stale' },
298
+ ],
299
+ applied: 1,
300
+ failed: 1,
301
+ pending: 0,
302
+ },
303
+ })
304
+ const { toast } = await import('sonner')
305
+ render(<ProposalsTab planInfo={null} />)
306
+ await screen.findByText('Duplicate meta title')
307
+ fireEvent.click(screen.getByLabelText('Select proposal Duplicate meta title'))
308
+ fireEvent.click(screen.getByLabelText('Select proposal Missing description'))
309
+ fireEvent.click(screen.getByRole('button', { name: 'Approve selected' }))
310
+ fireEvent.click(await screen.findByRole('button', { name: 'Apply 2 proposals' }))
311
+ await waitFor(() =>
312
+ expect(bulkApplySeoProposals).toHaveBeenCalledWith([
313
+ { kind: 'issue-fix', id: 'i1', fingerprint: 'fp-1' },
314
+ { kind: 'issue-fix', id: 'i2', fingerprint: 'fp-2' },
315
+ ]),
316
+ )
317
+ await waitFor(() => expect(toast.success).toHaveBeenCalledWith('1 proposal applied.'))
318
+ expect(toast.error).toHaveBeenCalledWith(expect.stringContaining('1 could not be applied'))
319
+ })
320
+
321
+ it('offers "Apply remaining" when the server reports pending items', async () => {
322
+ bulkApplySeoProposals.mockResolvedValueOnce({
323
+ result: {
324
+ results: [
325
+ { kind: 'issue-fix', id: 'i1', status: 'applied' },
326
+ { kind: 'issue-fix', id: 'i2', status: 'pending' },
327
+ ],
328
+ applied: 1,
329
+ failed: 0,
330
+ pending: 1,
331
+ },
332
+ })
333
+ render(<ProposalsTab planInfo={null} />)
334
+ await screen.findByText('Duplicate meta title')
335
+ fireEvent.click(screen.getByLabelText('Select all proposals'))
336
+ fireEvent.click(screen.getByRole('button', { name: 'Approve selected' }))
337
+ fireEvent.click(await screen.findByRole('button', { name: 'Apply 3 proposals' }))
338
+ expect(await screen.findByRole('button', { name: 'Apply remaining (1)' })).toBeTruthy()
339
+ })
340
+
341
+ it('dismiss selected fans out to the per-kind dismiss helpers', async () => {
342
+ render(<ProposalsTab planInfo={null} />)
343
+ await screen.findByText('Duplicate meta title')
344
+ fireEvent.click(screen.getByLabelText('Select all proposals'))
345
+ fireEvent.click(screen.getByRole('button', { name: 'Dismiss selected' }))
346
+ await waitFor(() => expect(dismissSeoIssueFix).toHaveBeenCalledTimes(2))
347
+ expect(dismissRedirectSuggestion).toHaveBeenCalledWith('s1')
348
+ })
349
+
350
+ it('generate loops while remaining > 0 and shows the eligible count', async () => {
351
+ generateSeoProposals
352
+ .mockResolvedValueOnce({
353
+ result: { generated: 10, skipped: 0, failed: 0, remaining: 3, haltedReason: null },
354
+ })
355
+ .mockResolvedValueOnce({
356
+ result: { generated: 3, skipped: 0, failed: 0, remaining: 0, haltedReason: null },
357
+ })
358
+ const { toast } = await import('sonner')
359
+ render(<ProposalsTab planInfo={null} />)
360
+ const button = await screen.findByRole('button', { name: 'Generate proposals (5 eligible)' })
361
+ fireEvent.click(button)
362
+ fireEvent.click(await screen.findByRole('button', { name: 'Generate' }))
363
+ await waitFor(() => expect(generateSeoProposals).toHaveBeenCalledTimes(2))
364
+ await waitFor(() => expect(toast.success).toHaveBeenCalledWith('13 proposals generated.'))
365
+ })
366
+
367
+ it('disables Generate when nothing is eligible and shows the empty state', async () => {
368
+ fetchSeoProposals.mockImplementationOnce(async () =>
369
+ payload({
370
+ proposals: [],
371
+ counts: { issueFixes: 0, redirects: 0, stale: 0, eligibleForGeneration: 0 },
372
+ }),
373
+ )
374
+ render(<ProposalsTab planInfo={null} />)
375
+ expect(await screen.findByText('No proposals yet')).toBeTruthy()
376
+ expect(screen.getByRole('button', { name: 'Generate proposals (0 eligible)' })).toHaveProperty(
377
+ 'disabled',
378
+ true,
379
+ )
380
+ })
381
+
382
+ it('renders the applied-by-autopilot section under the proposals card', async () => {
383
+ render(<ProposalsTab planInfo={null} />)
384
+ await screen.findByText('Duplicate meta title')
385
+ expect(await screen.findByText('Applied by autopilot')).toBeTruthy()
386
+ await waitFor(() => expect(fetchAutopilotApplied).toHaveBeenCalledTimes(1))
387
+ expect(await screen.findByText('Autopilot has not applied anything yet')).toBeTruthy()
388
+ })
389
+
390
+ it('shows no autopilot chip while autopilot is disabled', async () => {
391
+ render(<ProposalsTab planInfo={null} />)
392
+ await screen.findByText('Duplicate meta title')
393
+ expect(screen.queryByRole('button', { name: /autopilot (on|paused)/i })).toBeNull()
394
+ })
395
+
396
+ it('shows an "Autopilot on" chip with the weekly count that opens SEO settings', async () => {
397
+ fetchSeoProposals.mockImplementationOnce(async () =>
398
+ payload({
399
+ autopilot: {
400
+ enabled: true,
401
+ pausedReason: null,
402
+ lastRunAt: '2026-09-07T03:00:00.000Z',
403
+ appliedLast7d: 4,
404
+ },
405
+ }),
406
+ )
407
+ const onNavigate = vi.fn()
408
+ render(<ProposalsTab planInfo={null} onNavigate={onNavigate} />)
409
+ await screen.findByText('Duplicate meta title')
410
+ const chip = screen.getByRole('button', { name: 'Autopilot on · 4 applied this week' })
411
+ fireEvent.click(chip)
412
+ expect(onNavigate).toHaveBeenCalledWith('/settings?tab=seo')
413
+ })
414
+
415
+ it('shows a paused chip with a short reason when autopilot is paused', async () => {
416
+ fetchSeoProposals.mockImplementationOnce(async () =>
417
+ payload({
418
+ autopilot: {
419
+ enabled: true,
420
+ pausedReason: 'outcome_regression',
421
+ lastRunAt: null,
422
+ appliedLast7d: 1,
423
+ },
424
+ }),
425
+ )
426
+ render(<ProposalsTab planInfo={null} />)
427
+ await screen.findByText('Duplicate meta title')
428
+ expect(
429
+ screen.getByRole('button', { name: 'Autopilot paused — traffic regression' }),
430
+ ).toBeTruthy()
431
+ expect(screen.queryByRole('button', { name: /applied this week/ })).toBeNull()
432
+ })
433
+
434
+ it.each([
435
+ ['owner_unavailable', 'Autopilot paused — owner unavailable'],
436
+ ['manual', 'Autopilot paused — paused by admin'],
437
+ ['some_future_reason', 'Autopilot paused — some_future_reason'],
438
+ ])('labels the %s pause reason as "%s"', async (pausedReason, name) => {
439
+ fetchSeoProposals.mockImplementationOnce(async () =>
440
+ payload({ autopilot: { enabled: true, pausedReason, lastRunAt: null, appliedLast7d: 0 } }),
441
+ )
442
+ render(<ProposalsTab planInfo={null} />)
443
+ await screen.findByText('Duplicate meta title')
444
+ expect(screen.getByRole('button', { name })).toBeTruthy()
445
+ })
446
+
447
+ it('shows no chip when autopilot was disabled while still paused', async () => {
448
+ // `PUT { enabled: false }` does not clear the pause; a disabled engine is
449
+ // simply off, not "paused".
450
+ fetchSeoProposals.mockImplementationOnce(async () =>
451
+ payload({
452
+ autopilot: { enabled: false, pausedReason: 'manual', lastRunAt: null, appliedLast7d: 0 },
453
+ }),
454
+ )
455
+ render(<ProposalsTab planInfo={null} />)
456
+ await screen.findByText('Duplicate meta title')
457
+ expect(screen.queryByRole('button', { name: /autopilot/i })).toBeNull()
458
+ })
459
+
460
+ it('shows no chip and does not throw when the payload predates autopilot', async () => {
461
+ fetchSeoProposals.mockImplementationOnce(async () => {
462
+ // A `@prenta/core` deployed before autopilot shipped omits the field entirely.
463
+ const legacy: Partial<SeoProposalsPayload> = payload()
464
+ delete legacy.autopilot
465
+ return legacy as SeoProposalsPayload
466
+ })
467
+ render(<ProposalsTab planInfo={null} />)
468
+ expect(await screen.findByText('Duplicate meta title')).toBeTruthy()
469
+ expect(screen.queryByRole('button', { name: /autopilot (on|paused)/i })).toBeNull()
470
+ expect(screen.getByRole('button', { name: 'Generate proposals (5 eligible)' })).toBeTruthy()
471
+ })
472
+
473
+ it('refreshes the applied section after a proposal is approved', async () => {
474
+ render(<ProposalsTab planInfo={null} />)
475
+ await screen.findByText('Duplicate meta title')
476
+ await waitFor(() => expect(fetchAutopilotApplied).toHaveBeenCalledTimes(1))
477
+ fireEvent.click(screen.getByRole('button', { name: 'Approve Duplicate meta title' }))
478
+ await waitFor(() => expect(bulkApplySeoProposals).toHaveBeenCalledTimes(1))
479
+ await waitFor(() => expect(fetchAutopilotApplied).toHaveBeenCalledTimes(2))
480
+ })
481
+
482
+ it('refetches the inbox and notifies the parent after an autopilot change is reverted', async () => {
483
+ fetchAutopilotApplied.mockImplementation(async () => ({
484
+ docs: [
485
+ {
486
+ id: 'la-1',
487
+ kind: 'issue-fix',
488
+ issueType: 'missing-meta-title',
489
+ entityId: 'd1',
490
+ collection: 'pages',
491
+ entityTitle: 'Pricing',
492
+ entityUrl: '/pricing',
493
+ path: '/pricing',
494
+ before: { metaTitle: null },
495
+ after: { metaTitle: 'Pricing for growing teams' },
496
+ gate: { source: 'ai', brandAlignment: 92, confidence: null, checks: {} },
497
+ status: 'applied',
498
+ outcome: 'pending',
499
+ outcomeMetrics: null,
500
+ appliedAt: new Date().toISOString(),
501
+ revertedAt: null,
502
+ revertReason: null,
503
+ },
504
+ ],
505
+ total: 1,
506
+ page: 1,
507
+ pageSize: 20,
508
+ canRevert: true,
509
+ }))
510
+ const onChanged = vi.fn()
511
+ render(<ProposalsTab planInfo={null} onChanged={onChanged} />)
512
+ await screen.findByText('Duplicate meta title')
513
+ fireEvent.click(await screen.findByRole('button', { name: 'Revert change to Pricing' }))
514
+ fireEvent.click(await screen.findByRole('button', { name: 'Revert change' }))
515
+ await waitFor(() => expect(revertAutopilotChange).toHaveBeenCalledWith('la-1', false))
516
+ // The revert reopened the issue server-side: the inbox and the SEO page must both refresh.
517
+ await waitFor(() => expect(fetchSeoProposals).toHaveBeenCalledTimes(2))
518
+ await waitFor(() => expect(onChanged).toHaveBeenCalledTimes(1))
519
+ // The ledger reloads itself once; the parent must not bump `refreshKey` on top.
520
+ await waitFor(() => expect(fetchAutopilotApplied).toHaveBeenCalledTimes(2))
521
+ await flushMicrotasks()
522
+ expect(fetchAutopilotApplied).toHaveBeenCalledTimes(2)
523
+ })
524
+
525
+ it('hides Generate behind the plan callout when seo.inlineApply is off', async () => {
526
+ render(<ProposalsTab planInfo={{ tier: 'starter', features: [], upgradeUrl: null }} />)
527
+ await screen.findByText('Duplicate meta title')
528
+ expect(screen.queryByRole('button', { name: /Generate proposals/ })).toBeNull()
529
+ expect(screen.getByText(/Upgrade to unlock SEO inline apply/)).toBeTruthy()
530
+ })
531
+ })
@@ -2,10 +2,11 @@
2
2
  import { beforeEach, describe, expect, it, vi } from 'vitest'
3
3
  import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
4
4
 
5
- // The SEO defaults tab reads six shared backends: /seo/config (meta defaults +
5
+ // The SEO defaults tab reads seven shared backends: /seo/config (meta defaults +
6
6
  // verification), /seo/crawl-settings (sitemap toggles), /seo/sitemap/status
7
7
  // (read-only summary), /seo/index-status (environment), /seo/search-console
8
- // (Search data card), and /seo/digest-settings (Weekly SEO digest card).
8
+ // (Search data card), /seo/autopilot/settings (SEO Autopilot card), and
9
+ // /seo/digest-settings (Weekly SEO digest card).
9
10
  // `defaultApi` is the per-path route map; individual tests override the mock
10
11
  // implementation and delegate back to it for unrelated paths.
11
12
  type MockApiResponse = { data?: unknown; error?: string; status: number }
@@ -80,6 +81,28 @@ async function defaultApi(
80
81
  status: 200,
81
82
  }
82
83
  }
84
+ if (endpoint === '/seo/autopilot/settings' && method === 'GET') {
85
+ return {
86
+ data: {
87
+ enabled: false,
88
+ enabledById: null,
89
+ pausedAt: null,
90
+ pausedReason: null,
91
+ types: {},
92
+ pausedTypes: {},
93
+ minBrandAlignment: 85,
94
+ redirectMinConfidence: 85,
95
+ maxPerRun: 5,
96
+ maxPerDay: 20,
97
+ verifyFailuresPerRun: 2,
98
+ outcomeWatch: { enabled: true, days: 14, dropRatio: 0.4, minPriorClicks: 50 },
99
+ lastRunAt: null,
100
+ lastRun: null,
101
+ governance: { issueFixApprovalRequired: false, redirectApprovalRequired: false },
102
+ },
103
+ status: 200,
104
+ }
105
+ }
83
106
  return { data: {}, status: 200 }
84
107
  }
85
108
 
@@ -98,6 +121,7 @@ vi.mock('sonner', () => ({
98
121
  success: (...args: unknown[]) => toastSuccess(...args),
99
122
  error: (...args: unknown[]) => toastError(...args),
100
123
  warning: vi.fn(),
124
+ info: vi.fn(),
101
125
  loading: vi.fn(),
102
126
  },
103
127
  }))
@@ -331,6 +355,23 @@ describe('SeoSettingsTab', () => {
331
355
  expect(body.serviceAccountKey).toContain('svc@acme.iam.gserviceaccount.com')
332
356
  })
333
357
 
358
+ it('renders the SEO Autopilot card between Search data and the digest', async () => {
359
+ render(<SeoSettingsTab role="ADMIN" />)
360
+ // The title also renders in the card's skeleton state; wait for a control
361
+ // that only exists once the settings have loaded.
362
+ const toggle = await screen.findByRole('switch', { name: 'Enable autopilot' })
363
+ expect(toggle.getAttribute('aria-checked')).toBe('false')
364
+ const regions = screen
365
+ .getAllByRole('region')
366
+ .map((r) => r.getAttribute('aria-labelledby'))
367
+ .map((id) => (id ? document.getElementById(id)?.textContent : null))
368
+ const order = ['Search data', 'SEO Autopilot', 'Weekly SEO digest'].map((t) =>
369
+ regions.indexOf(t),
370
+ )
371
+ expect(order.every((i) => i >= 0)).toBe(true)
372
+ expect(order).toEqual([...order].sort((a, b) => a - b))
373
+ })
374
+
334
375
  it('renders the Weekly SEO digest card with the toggle off by default', async () => {
335
376
  render(<SeoSettingsTab role="ADMIN" />)
336
377
  expect(await screen.findByText('Weekly SEO digest')).toBeTruthy()
@@ -342,12 +383,13 @@ describe('SeoSettingsTab', () => {
342
383
 
343
384
  it('saves recipients as a trimmed, comma-split list', async () => {
344
385
  render(<SeoSettingsTab role="ADMIN" />)
345
- await screen.findByText('Weekly SEO digest')
386
+ // 'Weekly SEO digest' is also the loading-state title; wait for the form.
387
+ const save = await screen.findByRole('button', { name: 'Save recipients' })
346
388
 
347
389
  fireEvent.change(screen.getByLabelText('Recipients'), {
348
390
  target: { value: ' a@x.com , b@x.com ' },
349
391
  })
350
- fireEvent.click(screen.getByRole('button', { name: 'Save recipients' }))
392
+ fireEvent.click(save)
351
393
 
352
394
  await waitFor(() =>
353
395
  expect(cmsApi).toHaveBeenCalledWith(
@@ -370,9 +412,7 @@ describe('SeoSettingsTab', () => {
370
412
  return defaultApi(endpoint, options)
371
413
  })
372
414
  render(<SeoSettingsTab role="ADMIN" />)
373
- await screen.findByText('Weekly SEO digest')
374
-
375
- fireEvent.click(screen.getByRole('button', { name: /Send test digest/ }))
415
+ fireEvent.click(await screen.findByRole('button', { name: /Send test digest/ }))
376
416
 
377
417
  await waitFor(() =>
378
418
  expect(toastError).toHaveBeenCalledWith('No completed SEO audit yet — run an audit first'),
@@ -0,0 +1,47 @@
1
+ 'use client'
2
+
3
+ import type { SeoFixFieldChange } from '../../lib/seo-service.js'
4
+
5
+ function formatValue(value: string | boolean | null): string {
6
+ if (value === null || value === '') return '—'
7
+ if (typeof value === 'boolean') return value ? 'Yes' : 'No'
8
+ if (value.length > 280) return `${value.slice(0, 277)}…`
9
+ return value
10
+ }
11
+
12
+ /** Before/after table for a proposed SEO change set. Shared by the issue fix panel and the proposals inbox. */
13
+ export function ChangePreview({ changes }: { changes: SeoFixFieldChange[] }) {
14
+ if (changes.length === 0) return null
15
+ return (
16
+ <div className="border-border overflow-x-auto rounded-md border">
17
+ <table className="w-full min-w-[480px] text-sm" aria-label="Proposed SEO changes">
18
+ <thead>
19
+ <tr className="border-border bg-muted/40 text-muted-foreground border-b text-left">
20
+ <th scope="col" className="py-2 pr-3 pl-3 font-medium">
21
+ Field
22
+ </th>
23
+ <th scope="col" className="py-2 pr-3 font-medium">
24
+ Current
25
+ </th>
26
+ <th scope="col" className="py-2 pr-3 font-medium">
27
+ Proposed
28
+ </th>
29
+ </tr>
30
+ </thead>
31
+ <tbody className="divide-border divide-y">
32
+ {changes.map((c) => (
33
+ <tr key={c.field}>
34
+ <td className="text-foreground py-2 pr-3 pl-3 font-medium">{c.label}</td>
35
+ <td className="text-muted-foreground max-w-48 py-2 pr-3 wrap-break-word">
36
+ {formatValue(c.before)}
37
+ </td>
38
+ <td className="text-foreground max-w-48 py-2 pr-3 wrap-break-word">
39
+ {formatValue(c.after)}
40
+ </td>
41
+ </tr>
42
+ ))}
43
+ </tbody>
44
+ </table>
45
+ </div>
46
+ )
47
+ }