@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.
- package/CHANGELOG.md +34 -0
- package/design-system/tokens.css +17 -0
- package/design-system/tokens.json +35 -1
- package/design-system/tokens.scss +15 -0
- package/design-system/tokens.tailwind.cjs +14 -0
- package/dist/AdminRoot.d.ts.map +1 -1
- package/dist/AdminRoot.js +2 -2
- package/dist/AdminRoot.js.map +1 -1
- package/dist/__tests__/lib/batch-wizard.test.d.ts +2 -0
- package/dist/__tests__/lib/batch-wizard.test.d.ts.map +1 -0
- package/dist/__tests__/lib/batch-wizard.test.js +144 -0
- package/dist/__tests__/lib/batch-wizard.test.js.map +1 -0
- package/dist/__tests__/lib/job-drawer.test.js +2 -0
- package/dist/__tests__/lib/job-drawer.test.js.map +1 -1
- package/dist/__tests__/lib/job-view.test.js +100 -3
- package/dist/__tests__/lib/job-view.test.js.map +1 -1
- package/dist/__tests__/lib/jobs-service.test.js +56 -2
- package/dist/__tests__/lib/jobs-service.test.js.map +1 -1
- package/dist/__tests__/views/appearance-chrome.render.test.d.ts +2 -0
- package/dist/__tests__/views/appearance-chrome.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/appearance-chrome.render.test.js +688 -0
- package/dist/__tests__/views/appearance-chrome.render.test.js.map +1 -0
- package/dist/__tests__/views/header-footer-form.test.d.ts +2 -0
- package/dist/__tests__/views/header-footer-form.test.d.ts.map +1 -0
- package/dist/__tests__/views/header-footer-form.test.js +266 -0
- package/dist/__tests__/views/header-footer-form.test.js.map +1 -0
- package/dist/__tests__/views/jobs-drawer.render.test.js +442 -2
- package/dist/__tests__/views/jobs-drawer.render.test.js.map +1 -1
- package/dist/__tests__/views/jobs-list.render.test.js +7 -5
- package/dist/__tests__/views/jobs-list.render.test.js.map +1 -1
- package/dist/__tests__/views/jobs-wizard.render.test.d.ts +2 -0
- package/dist/__tests__/views/jobs-wizard.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/jobs-wizard.render.test.js +588 -0
- package/dist/__tests__/views/jobs-wizard.render.test.js.map +1 -0
- package/dist/lib/api.d.ts +2 -0
- package/dist/lib/api.d.ts.map +1 -1
- package/dist/lib/api.js +8 -1
- package/dist/lib/api.js.map +1 -1
- package/dist/lib/batch-wizard.d.ts +84 -0
- package/dist/lib/batch-wizard.d.ts.map +1 -0
- package/dist/lib/batch-wizard.js +122 -0
- package/dist/lib/batch-wizard.js.map +1 -0
- package/dist/lib/chrome-service.d.ts +69 -0
- package/dist/lib/chrome-service.d.ts.map +1 -0
- package/dist/lib/chrome-service.js +108 -0
- package/dist/lib/chrome-service.js.map +1 -0
- package/dist/lib/job-view.d.ts +43 -0
- package/dist/lib/job-view.d.ts.map +1 -1
- package/dist/lib/job-view.js +57 -1
- package/dist/lib/job-view.js.map +1 -1
- package/dist/lib/jobs-service.d.ts +19 -1
- package/dist/lib/jobs-service.d.ts.map +1 -1
- package/dist/lib/jobs-service.js +41 -0
- package/dist/lib/jobs-service.js.map +1 -1
- package/dist/prenta-admin.css +1 -1
- package/dist/views/AppearanceChrome.d.ts +22 -4
- package/dist/views/AppearanceChrome.d.ts.map +1 -1
- package/dist/views/AppearanceChrome.js +451 -15
- package/dist/views/AppearanceChrome.js.map +1 -1
- package/dist/views/Jobs/CancelJobDialog.d.ts +7 -1
- package/dist/views/Jobs/CancelJobDialog.d.ts.map +1 -1
- package/dist/views/Jobs/CancelJobDialog.js +13 -3
- package/dist/views/Jobs/CancelJobDialog.js.map +1 -1
- package/dist/views/Jobs/DrawerReview.d.ts +7 -0
- package/dist/views/Jobs/DrawerReview.d.ts.map +1 -1
- package/dist/views/Jobs/DrawerReview.js +1 -1
- package/dist/views/Jobs/DrawerReview.js.map +1 -1
- package/dist/views/Jobs/JobDrawer.d.ts +23 -3
- package/dist/views/Jobs/JobDrawer.d.ts.map +1 -1
- package/dist/views/Jobs/JobDrawer.js +30 -5
- package/dist/views/Jobs/JobDrawer.js.map +1 -1
- package/dist/views/Jobs/JobsTable.d.ts +4 -2
- package/dist/views/Jobs/JobsTable.d.ts.map +1 -1
- package/dist/views/Jobs/JobsTable.js +6 -5
- package/dist/views/Jobs/JobsTable.js.map +1 -1
- package/dist/views/Jobs/NewBatchJob.d.ts +34 -0
- package/dist/views/Jobs/NewBatchJob.d.ts.map +1 -0
- package/dist/views/Jobs/NewBatchJob.js +282 -0
- package/dist/views/Jobs/NewBatchJob.js.map +1 -0
- package/dist/views/Jobs/WorkerStrip.d.ts +3 -1
- package/dist/views/Jobs/WorkerStrip.d.ts.map +1 -1
- package/dist/views/Jobs/WorkerStrip.js +2 -2
- package/dist/views/Jobs/WorkerStrip.js.map +1 -1
- package/dist/views/Jobs/focus.d.ts +6 -0
- package/dist/views/Jobs/focus.d.ts.map +1 -1
- package/dist/views/Jobs/focus.js +10 -0
- package/dist/views/Jobs/focus.js.map +1 -1
- package/dist/views/Jobs.d.ts +12 -3
- package/dist/views/Jobs.d.ts.map +1 -1
- package/dist/views/Jobs.js +109 -31
- package/dist/views/Jobs.js.map +1 -1
- package/dist/views/header-footer/AnnouncementTab.d.ts +15 -0
- package/dist/views/header-footer/AnnouncementTab.d.ts.map +1 -0
- package/dist/views/header-footer/AnnouncementTab.js +43 -0
- package/dist/views/header-footer/AnnouncementTab.js.map +1 -0
- package/dist/views/header-footer/ChromeField.d.ts +25 -0
- package/dist/views/header-footer/ChromeField.d.ts.map +1 -0
- package/dist/views/header-footer/ChromeField.js +99 -0
- package/dist/views/header-footer/ChromeField.js.map +1 -0
- package/dist/views/header-footer/ChromePreview.d.ts +31 -0
- package/dist/views/header-footer/ChromePreview.d.ts.map +1 -0
- package/dist/views/header-footer/ChromePreview.js +195 -0
- package/dist/views/header-footer/ChromePreview.js.map +1 -0
- package/dist/views/header-footer/DeveloperTab.d.ts +17 -0
- package/dist/views/header-footer/DeveloperTab.d.ts.map +1 -0
- package/dist/views/header-footer/DeveloperTab.js +98 -0
- package/dist/views/header-footer/DeveloperTab.js.map +1 -0
- package/dist/views/header-footer/RegionTab.d.ts +27 -0
- package/dist/views/header-footer/RegionTab.d.ts.map +1 -0
- package/dist/views/header-footer/RegionTab.js +80 -0
- package/dist/views/header-footer/RegionTab.js.map +1 -0
- package/dist/views/header-footer/chrome-form.d.ts +137 -0
- package/dist/views/header-footer/chrome-form.d.ts.map +1 -0
- package/dist/views/header-footer/chrome-form.js +518 -0
- package/dist/views/header-footer/chrome-form.js.map +1 -0
- package/dist/views/header-footer/code-samples.d.ts +30 -0
- package/dist/views/header-footer/code-samples.d.ts.map +1 -0
- package/dist/views/header-footer/code-samples.js +127 -0
- package/dist/views/header-footer/code-samples.js.map +1 -0
- package/dist/views/header-footer/controls.d.ts +57 -0
- package/dist/views/header-footer/controls.d.ts.map +1 -0
- package/dist/views/header-footer/controls.js +43 -0
- package/dist/views/header-footer/controls.js.map +1 -0
- package/package.json +3 -3
- package/src/AdminRoot.tsx +16 -2
- package/src/__tests__/lib/batch-wizard.test.ts +199 -0
- package/src/__tests__/lib/job-drawer.test.ts +2 -0
- package/src/__tests__/lib/job-view.test.ts +123 -2
- package/src/__tests__/lib/jobs-service.test.ts +72 -3
- package/src/__tests__/views/appearance-chrome.render.test.tsx +818 -0
- package/src/__tests__/views/header-footer-form.test.ts +333 -0
- package/src/__tests__/views/jobs-drawer.render.test.tsx +508 -2
- package/src/__tests__/views/jobs-list.render.test.tsx +8 -5
- package/src/__tests__/views/jobs-wizard.render.test.tsx +707 -0
- package/src/lib/api.ts +12 -1
- package/src/lib/batch-wizard.ts +188 -0
- package/src/lib/chrome-service.ts +200 -0
- package/src/lib/job-view.ts +95 -1
- package/src/lib/jobs-service.ts +56 -1
- package/src/styles/prenta-tokens.css +17 -0
- package/src/styles/theme.css +15 -0
- package/src/views/AppearanceChrome.tsx +704 -46
- package/src/views/Jobs/CancelJobDialog.tsx +20 -2
- package/src/views/Jobs/DrawerReview.tsx +1 -1
- package/src/views/Jobs/JobDrawer.tsx +83 -3
- package/src/views/Jobs/JobsTable.tsx +11 -4
- package/src/views/Jobs/NewBatchJob.tsx +857 -0
- package/src/views/Jobs/WorkerStrip.tsx +16 -11
- package/src/views/Jobs/focus.ts +11 -0
- package/src/views/Jobs.tsx +163 -49
- package/src/views/header-footer/AnnouncementTab.tsx +231 -0
- package/src/views/header-footer/ChromeField.tsx +358 -0
- package/src/views/header-footer/ChromePreview.tsx +497 -0
- package/src/views/header-footer/DeveloperTab.tsx +331 -0
- package/src/views/header-footer/RegionTab.tsx +398 -0
- package/src/views/header-footer/chrome-form.ts +603 -0
- package/src/views/header-footer/code-samples.ts +146 -0
- package/src/views/header-footer/controls.tsx +166 -0
- package/dist/views/Jobs/placeholders.d.ts +0 -6
- package/dist/views/Jobs/placeholders.d.ts.map +0 -1
- package/dist/views/Jobs/placeholders.js +0 -20
- package/dist/views/Jobs/placeholders.js.map +0 -1
- package/src/views/Jobs/placeholders.tsx +0 -66
|
@@ -0,0 +1,707 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
3
|
+
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
|
4
|
+
import type { Job, JobWorkerStatus } from '@prenta/core'
|
|
5
|
+
import type { NewBatchJobProps } from '../../views/Jobs/NewBatchJob.js'
|
|
6
|
+
|
|
7
|
+
/*
|
|
8
|
+
* The New batch AI job wizard (P-20): the three steps with the kit's copy,
|
|
9
|
+
* the estimate from `/jobs/batch-ai/estimate`, Try it from `/preview`, Queue
|
|
10
|
+
* to `/jobs/batch-ai`, "Apply automatically" for admins only, validation,
|
|
11
|
+
* loading and error states, a picked selection, and focus. The wizard's
|
|
12
|
+
* calls are answered by the `cmsApi` mock; the Jobs list's refresh by the
|
|
13
|
+
* `fetch` stub. Both read `server` below.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
interface Reply {
|
|
17
|
+
data?: unknown
|
|
18
|
+
error?: string
|
|
19
|
+
status: number
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface Call {
|
|
23
|
+
endpoint: string
|
|
24
|
+
method: string
|
|
25
|
+
body: Record<string, unknown> | undefined
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface Server {
|
|
29
|
+
estimate: (query: URLSearchParams) => Reply | Promise<Reply>
|
|
30
|
+
preview: (body: Record<string, unknown>) => Reply | Promise<Reply>
|
|
31
|
+
create: (body: Record<string, unknown>) => Reply | Promise<Reply>
|
|
32
|
+
jobs: Job[]
|
|
33
|
+
calls: Call[]
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const server: Server = {
|
|
37
|
+
estimate: () => ({ error: 'not stubbed', status: 500 }),
|
|
38
|
+
preview: () => ({ error: 'not stubbed', status: 500 }),
|
|
39
|
+
create: () => ({ error: 'not stubbed', status: 500 }),
|
|
40
|
+
jobs: [],
|
|
41
|
+
calls: [],
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
vi.mock('../../lib/api.js', () => ({
|
|
45
|
+
getApiBase: () => '/api/cms',
|
|
46
|
+
cmsApi: vi.fn(async (endpoint: string, init: RequestInit = {}) => {
|
|
47
|
+
const method = (init.method ?? 'GET').toUpperCase()
|
|
48
|
+
const body = typeof init.body === 'string' ? JSON.parse(init.body) : undefined
|
|
49
|
+
server.calls.push({ endpoint, method, body })
|
|
50
|
+
const url = new URL(endpoint, 'http://x')
|
|
51
|
+
if (url.pathname === '/jobs/batch-ai/estimate') return server.estimate(url.searchParams)
|
|
52
|
+
if (url.pathname === '/jobs/batch-ai/preview') return server.preview(body)
|
|
53
|
+
if (url.pathname === '/jobs/batch-ai' && method === 'POST') return server.create(body)
|
|
54
|
+
return { error: `unexpected ${method} ${endpoint}`, status: 404 }
|
|
55
|
+
}),
|
|
56
|
+
}))
|
|
57
|
+
|
|
58
|
+
const toast = vi.hoisted(() => Object.assign(vi.fn(), { error: vi.fn() }))
|
|
59
|
+
vi.mock('sonner', () => ({ toast }))
|
|
60
|
+
|
|
61
|
+
const { NewBatchJob } = await import('../../views/Jobs/NewBatchJob.js')
|
|
62
|
+
const { Jobs } = await import('../../views/Jobs.js')
|
|
63
|
+
|
|
64
|
+
const CONFIG = {
|
|
65
|
+
collections: {
|
|
66
|
+
posts: { slug: 'posts', type: 'post', labels: { plural: 'Posts' } },
|
|
67
|
+
pages: { slug: 'pages', type: 'page', labels: { plural: 'Pages' } },
|
|
68
|
+
menus: { slug: 'menus', labels: { plural: 'Menus' } },
|
|
69
|
+
tags: { slug: 'tags', type: 'post', labels: { plural: 'Tags' }, admin: { hidden: true } },
|
|
70
|
+
},
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const SAMPLE = [
|
|
74
|
+
{ title: 'How to choose a CMS in 2026', url: '/blog/choose-cms-2026' },
|
|
75
|
+
{ title: 'Local SEO for multi-location brands', url: '/blog/local-seo-multi-location' },
|
|
76
|
+
{ title: 'Core Web Vitals checklist', url: '/blog/core-web-vitals-checklist' },
|
|
77
|
+
]
|
|
78
|
+
|
|
79
|
+
/** The kit's numbers: 42 posts, 900 tokens and a minute each, 4% of the month. */
|
|
80
|
+
function estimate(count = 42, pct: number | null = 4): Reply {
|
|
81
|
+
return {
|
|
82
|
+
data: {
|
|
83
|
+
count,
|
|
84
|
+
sample: SAMPLE.slice(0, Math.min(3, count)),
|
|
85
|
+
tokens: count * 900,
|
|
86
|
+
minutes: count,
|
|
87
|
+
pctOfMonthlyLimit: pct,
|
|
88
|
+
},
|
|
89
|
+
status: 200,
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const OUTPUT = [
|
|
94
|
+
'Compare headless and traditional CMS options, real costs and migration risks.',
|
|
95
|
+
'How multi-location brands win local search: one profile per location.',
|
|
96
|
+
'A practical Core Web Vitals checklist: fix LCP, INP and CLS in order.',
|
|
97
|
+
]
|
|
98
|
+
|
|
99
|
+
function deferred<T>() {
|
|
100
|
+
let resolve!: (value: T) => void
|
|
101
|
+
const promise = new Promise<T>((r) => (resolve = r))
|
|
102
|
+
return { promise, resolve }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function iso(offsetMs = 0): string {
|
|
106
|
+
return new Date(Date.now() + offsetMs).toISOString()
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function job(overrides: Partial<Job> = {}): Job {
|
|
110
|
+
return {
|
|
111
|
+
id: 'job_420',
|
|
112
|
+
type: 'batch-ai',
|
|
113
|
+
status: 'queued',
|
|
114
|
+
title: 'Rewrite meta descriptions',
|
|
115
|
+
scope: '42 posts · missing or over 160 characters',
|
|
116
|
+
runAt: iso(),
|
|
117
|
+
attempts: 0,
|
|
118
|
+
maxAttempts: 3,
|
|
119
|
+
progress: { done: 0, total: 42, unit: 'documents', cursor: null, completed: 0 },
|
|
120
|
+
lastError: null,
|
|
121
|
+
explain: null,
|
|
122
|
+
summary: null,
|
|
123
|
+
lockedAt: null,
|
|
124
|
+
cancelRequestedAt: null,
|
|
125
|
+
cancelledBy: null,
|
|
126
|
+
createdBy: { id: 'u1', name: 'Brandon West', initials: 'BW' },
|
|
127
|
+
createdAt: iso(),
|
|
128
|
+
updatedAt: iso(),
|
|
129
|
+
finishedAt: null,
|
|
130
|
+
usage: { tokensIn: 0, tokensOut: 0 },
|
|
131
|
+
payload: {
|
|
132
|
+
operation: 'meta',
|
|
133
|
+
instruction: null,
|
|
134
|
+
source: 'posts',
|
|
135
|
+
statusFilter: 'published',
|
|
136
|
+
documentIds: [],
|
|
137
|
+
review: 'review',
|
|
138
|
+
onApprove: 'draft',
|
|
139
|
+
},
|
|
140
|
+
review: { pending: 0, approved: 0, rejected: 0 },
|
|
141
|
+
...overrides,
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function workerStatus(): JobWorkerStatus {
|
|
146
|
+
return {
|
|
147
|
+
lastTickAt: iso(-24_000),
|
|
148
|
+
lastTickMs: 1800,
|
|
149
|
+
nextTickAt: iso(36_000),
|
|
150
|
+
budgetMs: 20_000,
|
|
151
|
+
schedules: [],
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** The Jobs list's refresh: `GET /jobs` and `GET /jobs/worker`. */
|
|
156
|
+
function fetchStub(url: string) {
|
|
157
|
+
const endpoint = url.replace(/^\/api\/cms/, '')
|
|
158
|
+
const body =
|
|
159
|
+
endpoint === '/jobs?limit=50'
|
|
160
|
+
? { data: { jobs: server.jobs } }
|
|
161
|
+
: endpoint === '/jobs/worker'
|
|
162
|
+
? { data: workerStatus() }
|
|
163
|
+
: null
|
|
164
|
+
return Promise.resolve({
|
|
165
|
+
ok: body !== null,
|
|
166
|
+
status: body ? 200 : 404,
|
|
167
|
+
headers: { get: (name: string) => (name === 'date' ? new Date().toUTCString() : null) },
|
|
168
|
+
json: async () => body ?? { error: 'not found' },
|
|
169
|
+
})
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
beforeEach(() => {
|
|
173
|
+
vi.stubGlobal('fetch', vi.fn(fetchStub))
|
|
174
|
+
server.calls = []
|
|
175
|
+
server.jobs = []
|
|
176
|
+
server.estimate = () => estimate()
|
|
177
|
+
server.preview = () => ({
|
|
178
|
+
data: {
|
|
179
|
+
items: SAMPLE.map((s, i) => ({ documentId: `d${i}`, title: s.title, after: OUTPUT[i] })),
|
|
180
|
+
},
|
|
181
|
+
status: 200,
|
|
182
|
+
})
|
|
183
|
+
// As the server sends it: queued, with no total until the worker's first slice.
|
|
184
|
+
server.create = () => {
|
|
185
|
+
const created = job({
|
|
186
|
+
progress: { done: 0, total: 0, unit: 'documents', cursor: null, completed: 0 },
|
|
187
|
+
payload: {
|
|
188
|
+
operation: 'meta',
|
|
189
|
+
instruction: null,
|
|
190
|
+
source: 'posts',
|
|
191
|
+
statusFilter: 'published',
|
|
192
|
+
documentIds: Array.from({ length: 42 }, (_, i) => `doc-${i}`),
|
|
193
|
+
review: 'review',
|
|
194
|
+
onApprove: 'draft',
|
|
195
|
+
},
|
|
196
|
+
})
|
|
197
|
+
server.jobs = [created, ...server.jobs]
|
|
198
|
+
return { data: created, status: 201 }
|
|
199
|
+
}
|
|
200
|
+
toast.mockClear()
|
|
201
|
+
})
|
|
202
|
+
afterEach(() => {
|
|
203
|
+
vi.unstubAllGlobals()
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
function renderWizard(props: Partial<NewBatchJobProps> = {}) {
|
|
207
|
+
const onClose = vi.fn()
|
|
208
|
+
const onQueued = vi.fn()
|
|
209
|
+
render(
|
|
210
|
+
<NewBatchJob role="ADMIN" config={CONFIG} onClose={onClose} onQueued={onQueued} {...props} />,
|
|
211
|
+
)
|
|
212
|
+
return { dialog: screen.getByRole('dialog'), onClose, onQueued }
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function estimateCalls(): URLSearchParams[] {
|
|
216
|
+
return server.calls
|
|
217
|
+
.filter((c) => c.endpoint.startsWith('/jobs/batch-ai/estimate'))
|
|
218
|
+
.map((c) => new URL(c.endpoint, 'http://x').searchParams)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function current(dialog: HTMLElement): string {
|
|
222
|
+
return within(dialog).getByRole('list', { name: 'Steps' }).querySelector('[aria-current="step"]')!
|
|
223
|
+
.textContent!
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async function toStep2(dialog: HTMLElement) {
|
|
227
|
+
fireEvent.click(within(dialog).getByRole('button', { name: 'Continue' }))
|
|
228
|
+
await within(dialog).findByText('posts match')
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async function toStep3(dialog: HTMLElement) {
|
|
232
|
+
await toStep2(dialog)
|
|
233
|
+
fireEvent.click(within(dialog).getByRole('button', { name: 'Continue' }))
|
|
234
|
+
await within(dialog).findByText('Before anything changes')
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
describe('step 1: what to change', () => {
|
|
238
|
+
it('shows the six operations with the kit copy, meta descriptions picked and focused', () => {
|
|
239
|
+
const { dialog } = renderWizard()
|
|
240
|
+
expect(dialog.getAttribute('aria-modal')).toBe('true')
|
|
241
|
+
expect(within(dialog).getByRole('heading', { name: 'New batch AI job' })).toBeTruthy()
|
|
242
|
+
expect(current(dialog)).toBe('What to change')
|
|
243
|
+
const ops = within(dialog).getAllByRole('radio')
|
|
244
|
+
expect(ops.map((op) => op.closest('label')!.querySelector('b')!.textContent)).toEqual([
|
|
245
|
+
'Rewrite meta descriptions',
|
|
246
|
+
'Generate image alt text',
|
|
247
|
+
'Shorten long titles',
|
|
248
|
+
'Write excerpts',
|
|
249
|
+
'Add internal links',
|
|
250
|
+
'Custom instruction',
|
|
251
|
+
])
|
|
252
|
+
expect(
|
|
253
|
+
within(dialog).getByText('Suggests up to 3 links to related pages in the body.'),
|
|
254
|
+
).toBeTruthy()
|
|
255
|
+
const meta = within(dialog).getByRole('radio', { name: /Rewrite meta descriptions/ })
|
|
256
|
+
expect((meta as HTMLInputElement).checked).toBe(true)
|
|
257
|
+
expect(document.activeElement).toBe(meta)
|
|
258
|
+
expect(within(dialog).getByLabelText('Extra guidance (optional)')).toBeTruthy()
|
|
259
|
+
expect(
|
|
260
|
+
within(dialog).getByText('The AI also follows your brand voice from Settings → AI.'),
|
|
261
|
+
).toBeTruthy()
|
|
262
|
+
expect(within(dialog).getByRole('button', { name: 'Cancel' })).toBeTruthy()
|
|
263
|
+
// Nothing is counted until step 2.
|
|
264
|
+
expect(estimateCalls()).toHaveLength(0)
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
it('needs a custom instruction of more than 8 characters, and its field', async () => {
|
|
268
|
+
const { dialog } = renderWizard()
|
|
269
|
+
fireEvent.click(within(dialog).getByRole('radio', { name: /Custom instruction/ }))
|
|
270
|
+
const instruction = within(dialog).getByLabelText('Instruction') as HTMLTextAreaElement
|
|
271
|
+
expect(instruction.placeholder).toBe(
|
|
272
|
+
'e.g. Translate the excerpt to Spanish. Keep brand names in English.',
|
|
273
|
+
)
|
|
274
|
+
const cont = within(dialog).getByRole('button', { name: 'Continue' })
|
|
275
|
+
expect(cont.getAttribute('aria-disabled')).toBe('true')
|
|
276
|
+
|
|
277
|
+
fireEvent.change(instruction, { target: { value: 'Shorter' } })
|
|
278
|
+
fireEvent.click(cont)
|
|
279
|
+
const error = within(dialog).getByText('A custom instruction needs more than 8 characters.')
|
|
280
|
+
expect(instruction.getAttribute('aria-invalid')).toBe('true')
|
|
281
|
+
expect(instruction.getAttribute('aria-describedby')).toBe(error.id)
|
|
282
|
+
expect(document.activeElement).toBe(instruction)
|
|
283
|
+
expect(current(dialog)).toBe('What to change')
|
|
284
|
+
expect(estimateCalls()).toHaveLength(0)
|
|
285
|
+
|
|
286
|
+
fireEvent.change(within(dialog).getByLabelText('Field'), { target: { value: 'excerpt' } })
|
|
287
|
+
fireEvent.change(instruction, { target: { value: ' Translate the excerpt to Spanish. ' } })
|
|
288
|
+
expect(within(dialog).queryByText(/needs more than 8 characters/)).toBeNull()
|
|
289
|
+
expect(cont.getAttribute('aria-disabled')).toBeNull()
|
|
290
|
+
await toStep2(dialog)
|
|
291
|
+
const query = estimateCalls()[0]!
|
|
292
|
+
expect(query.get('operation')).toBe('custom')
|
|
293
|
+
expect(query.get('field')).toBe('excerpt')
|
|
294
|
+
expect(query.get('instruction')).toBe('Translate the excerpt to Spanish.')
|
|
295
|
+
expect(within(dialog).getByText('Excerpt:')).toBeTruthy()
|
|
296
|
+
expect(within(dialog).getByText('Any')).toBeTruthy()
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
it('closes on Esc and from Cancel', () => {
|
|
300
|
+
const { dialog, onClose } = renderWizard()
|
|
301
|
+
fireEvent.keyDown(dialog, { key: 'Escape' })
|
|
302
|
+
expect(onClose).toHaveBeenCalledTimes(1)
|
|
303
|
+
fireEvent.click(within(dialog).getByRole('button', { name: 'Cancel' }))
|
|
304
|
+
expect(onClose).toHaveBeenCalledTimes(2)
|
|
305
|
+
})
|
|
306
|
+
})
|
|
307
|
+
|
|
308
|
+
describe('step 2: which documents', () => {
|
|
309
|
+
it('counts the matches with /estimate and shows the sample', async () => {
|
|
310
|
+
const gate = deferred<Reply>()
|
|
311
|
+
server.estimate = () => gate.promise
|
|
312
|
+
const { dialog } = renderWizard()
|
|
313
|
+
fireEvent.click(within(dialog).getByRole('button', { name: 'Continue' }))
|
|
314
|
+
expect(current(dialog)).toBe('Which documents')
|
|
315
|
+
expect(within(dialog).getByText('Counting documents…').closest('[role="status"]')).toBeTruthy()
|
|
316
|
+
expect(screen.getByText('Step 2 of 3: Which documents')).toBeTruthy()
|
|
317
|
+
const content = within(dialog).getByLabelText('Content') as HTMLSelectElement
|
|
318
|
+
expect(document.activeElement).toBe(content)
|
|
319
|
+
// Continue waits for the count.
|
|
320
|
+
fireEvent.click(within(dialog).getByRole('button', { name: 'Continue' }))
|
|
321
|
+
expect(current(dialog)).toBe('Which documents')
|
|
322
|
+
|
|
323
|
+
await act(async () => gate.resolve(estimate()))
|
|
324
|
+
const query = estimateCalls()[0]!
|
|
325
|
+
expect(Object.fromEntries(query)).toEqual({
|
|
326
|
+
operation: 'meta',
|
|
327
|
+
source: 'posts',
|
|
328
|
+
statusFilter: 'published',
|
|
329
|
+
})
|
|
330
|
+
expect([...content.options].map((o) => o.textContent)).toEqual(['Posts', 'Pages'])
|
|
331
|
+
expect(within(dialog).getByText('Meta description:')).toBeTruthy()
|
|
332
|
+
expect(within(dialog).getByText('Missing or over 160 characters')).toBeTruthy()
|
|
333
|
+
expect(within(dialog).getByText('42')).toBeTruthy()
|
|
334
|
+
expect(within(dialog).getByText('one AI call per document')).toBeTruthy()
|
|
335
|
+
expect(within(dialog).getByText('/blog/local-seo-multi-location')).toBeTruthy()
|
|
336
|
+
expect(within(dialog).getByText('and 39 more')).toBeTruthy()
|
|
337
|
+
})
|
|
338
|
+
|
|
339
|
+
it('counts again when the content or status changes', async () => {
|
|
340
|
+
const { dialog } = renderWizard()
|
|
341
|
+
await toStep2(dialog)
|
|
342
|
+
server.estimate = (query) =>
|
|
343
|
+
estimate(query.get('source') === 'pages' ? 11 : query.get('statusFilter') === 'all' ? 48 : 42)
|
|
344
|
+
fireEvent.change(within(dialog).getByLabelText('Status'), { target: { value: 'all' } })
|
|
345
|
+
await within(dialog).findByText('and 45 more')
|
|
346
|
+
fireEvent.change(within(dialog).getByLabelText('Content'), { target: { value: 'pages' } })
|
|
347
|
+
await within(dialog).findByText('pages match')
|
|
348
|
+
expect(within(dialog).getByText('11')).toBeTruthy()
|
|
349
|
+
expect(estimateCalls().map((q) => `${q.get('source')} ${q.get('statusFilter')}`)).toEqual([
|
|
350
|
+
'posts published',
|
|
351
|
+
'posts all',
|
|
352
|
+
'pages all',
|
|
353
|
+
])
|
|
354
|
+
})
|
|
355
|
+
|
|
356
|
+
it('shows a failed count with Try again, and holds Continue', async () => {
|
|
357
|
+
server.estimate = () => ({ error: 'Request failed (500)', status: 500 })
|
|
358
|
+
const { dialog } = renderWizard()
|
|
359
|
+
fireEvent.click(within(dialog).getByRole('button', { name: 'Continue' }))
|
|
360
|
+
const alert = await within(dialog).findByRole('alert')
|
|
361
|
+
expect(alert.textContent).toBe('Couldn’t count the documents: Request failed (500)')
|
|
362
|
+
fireEvent.click(within(dialog).getByRole('button', { name: 'Continue' }))
|
|
363
|
+
expect(current(dialog)).toBe('Which documents')
|
|
364
|
+
|
|
365
|
+
server.estimate = () => estimate()
|
|
366
|
+
fireEvent.click(within(dialog).getByRole('button', { name: 'Try again' }))
|
|
367
|
+
await within(dialog).findByText('posts match')
|
|
368
|
+
expect(estimateCalls()).toHaveLength(2)
|
|
369
|
+
})
|
|
370
|
+
|
|
371
|
+
it('holds Continue when nothing matches, or more than a batch holds', async () => {
|
|
372
|
+
server.estimate = () => estimate(0)
|
|
373
|
+
const { dialog } = renderWizard()
|
|
374
|
+
fireEvent.click(within(dialog).getByRole('button', { name: 'Continue' }))
|
|
375
|
+
expect((await within(dialog).findByRole('alert')).textContent).toBe(
|
|
376
|
+
'No documents match this filter.',
|
|
377
|
+
)
|
|
378
|
+
const cont = within(dialog).getByRole('button', { name: 'Continue' })
|
|
379
|
+
expect(cont.getAttribute('aria-disabled')).toBe('true')
|
|
380
|
+
fireEvent.click(cont)
|
|
381
|
+
expect(current(dialog)).toBe('Which documents')
|
|
382
|
+
|
|
383
|
+
server.estimate = () => estimate(1200)
|
|
384
|
+
fireEvent.change(within(dialog).getByLabelText('Status'), { target: { value: 'all' } })
|
|
385
|
+
expect((await within(dialog).findByText(/Narrow it/)).textContent).toBe(
|
|
386
|
+
'This matches 1,200 documents. Narrow it to 1,000 or fewer.',
|
|
387
|
+
)
|
|
388
|
+
expect(within(dialog).queryByText('/blog/choose-cms-2026')).toBeNull()
|
|
389
|
+
})
|
|
390
|
+
|
|
391
|
+
it('locks Content to the media library for alt text', async () => {
|
|
392
|
+
const { dialog } = renderWizard()
|
|
393
|
+
fireEvent.click(within(dialog).getByRole('radio', { name: /Generate image alt text/ }))
|
|
394
|
+
fireEvent.click(within(dialog).getByRole('button', { name: 'Continue' }))
|
|
395
|
+
await within(dialog).findByText('media library match')
|
|
396
|
+
const content = within(dialog).getByLabelText('Content') as HTMLSelectElement
|
|
397
|
+
expect(content.disabled).toBe(true)
|
|
398
|
+
expect(content.selectedOptions[0]!.textContent).toBe('Media library')
|
|
399
|
+
expect((within(dialog).getByLabelText('Status') as HTMLSelectElement).disabled).toBe(true)
|
|
400
|
+
expect(within(dialog).getByText('Alt text:')).toBeTruthy()
|
|
401
|
+
expect(within(dialog).getByText('No alt text')).toBeTruthy()
|
|
402
|
+
expect(estimateCalls()[0]!.get('source')).toBe('media')
|
|
403
|
+
})
|
|
404
|
+
})
|
|
405
|
+
|
|
406
|
+
describe('step 3: review and queue', () => {
|
|
407
|
+
it('shows the approval choices and the estimate tiles', async () => {
|
|
408
|
+
const { dialog } = renderWizard()
|
|
409
|
+
await toStep3(dialog)
|
|
410
|
+
expect(current(dialog)).toBe('Review & queue')
|
|
411
|
+
const review = within(dialog).getByRole('radio', { name: /Review each change/ })
|
|
412
|
+
expect((review as HTMLInputElement).checked).toBe(true)
|
|
413
|
+
expect(document.activeElement).toBe(review)
|
|
414
|
+
expect(within(dialog).getByText('RECOMMENDED')).toBeTruthy()
|
|
415
|
+
expect(within(dialog).getByText('When a change is approved')).toBeTruthy()
|
|
416
|
+
expect(
|
|
417
|
+
(within(dialog).getByRole('radio', { name: /Save as draft/ }) as HTMLInputElement).checked,
|
|
418
|
+
).toBe(true)
|
|
419
|
+
const tiles = dialog.querySelector('dl')!
|
|
420
|
+
expect(
|
|
421
|
+
[...tiles.querySelectorAll('div')].map((d) => [
|
|
422
|
+
d.querySelector('dt')!.textContent,
|
|
423
|
+
d.querySelector('dd')!.textContent,
|
|
424
|
+
]),
|
|
425
|
+
).toEqual([
|
|
426
|
+
['Documents', '42'],
|
|
427
|
+
['Estimated time', '~42 min'],
|
|
428
|
+
['AI usage', '~38k tokens'],
|
|
429
|
+
])
|
|
430
|
+
expect(
|
|
431
|
+
within(dialog).getByText(
|
|
432
|
+
'One document per slice, about one a minute. That’s 4% of this month’s AI limit.',
|
|
433
|
+
),
|
|
434
|
+
).toBeTruthy()
|
|
435
|
+
expect(within(dialog).getByRole('button', { name: 'Queue 42 documents' })).toBeTruthy()
|
|
436
|
+
// The tiles reuse step 2's count.
|
|
437
|
+
expect(estimateCalls()).toHaveLength(1)
|
|
438
|
+
})
|
|
439
|
+
|
|
440
|
+
it('leaves out the limit sentence when no monthly limit is set', async () => {
|
|
441
|
+
server.estimate = () => estimate(42, null)
|
|
442
|
+
const { dialog } = renderWizard()
|
|
443
|
+
await toStep3(dialog)
|
|
444
|
+
expect(within(dialog).getByText('One document per slice, about one a minute.')).toBeTruthy()
|
|
445
|
+
})
|
|
446
|
+
|
|
447
|
+
it('previews 3 documents with /preview, writing nothing', async () => {
|
|
448
|
+
const gate = deferred<Reply>()
|
|
449
|
+
server.preview = () => gate.promise
|
|
450
|
+
const { dialog } = renderWizard()
|
|
451
|
+
await toStep3(dialog)
|
|
452
|
+
expect(
|
|
453
|
+
within(dialog).getByText(
|
|
454
|
+
'See sample output before you queue the whole batch. Previews don’t change anything.',
|
|
455
|
+
),
|
|
456
|
+
).toBeTruthy()
|
|
457
|
+
const button = within(dialog).getByRole('button', { name: 'Try it' })
|
|
458
|
+
fireEvent.click(button)
|
|
459
|
+
expect(button.textContent).toBe('Writing…')
|
|
460
|
+
expect(button.getAttribute('aria-busy')).toBe('true')
|
|
461
|
+
fireEvent.click(button)
|
|
462
|
+
expect(server.calls.filter((c) => c.endpoint === '/jobs/batch-ai/preview')).toHaveLength(1)
|
|
463
|
+
|
|
464
|
+
await act(async () =>
|
|
465
|
+
gate.resolve({
|
|
466
|
+
data: {
|
|
467
|
+
items: SAMPLE.map((s, i) => ({ documentId: `d${i}`, title: s.title, after: OUTPUT[i] })),
|
|
468
|
+
},
|
|
469
|
+
status: 200,
|
|
470
|
+
}),
|
|
471
|
+
)
|
|
472
|
+
expect(within(dialog).getByText(OUTPUT[1]!)).toBeTruthy()
|
|
473
|
+
expect(within(dialog).getByText('Core Web Vitals checklist')).toBeTruthy()
|
|
474
|
+
expect(button.textContent).toBe('Try again')
|
|
475
|
+
expect(server.calls.find((c) => c.endpoint === '/jobs/batch-ai/preview')!.body).toEqual({
|
|
476
|
+
operation: 'meta',
|
|
477
|
+
source: 'posts',
|
|
478
|
+
statusFilter: 'published',
|
|
479
|
+
review: 'review',
|
|
480
|
+
onApprove: 'draft',
|
|
481
|
+
instruction: null,
|
|
482
|
+
})
|
|
483
|
+
})
|
|
484
|
+
|
|
485
|
+
it('shows a refused preview, and the text of a link as text', async () => {
|
|
486
|
+
server.preview = () => ({
|
|
487
|
+
error: 'This month’s AI limit is used up.',
|
|
488
|
+
status: 429,
|
|
489
|
+
})
|
|
490
|
+
const { dialog } = renderWizard()
|
|
491
|
+
fireEvent.click(within(dialog).getByRole('radio', { name: /Add internal links/ }))
|
|
492
|
+
await toStep3(dialog)
|
|
493
|
+
fireEvent.click(within(dialog).getByRole('button', { name: 'Try it' }))
|
|
494
|
+
expect((await within(dialog).findByRole('alert')).textContent).toBe(
|
|
495
|
+
'Couldn’t write a preview: This month’s AI limit is used up.',
|
|
496
|
+
)
|
|
497
|
+
|
|
498
|
+
server.preview = () => ({
|
|
499
|
+
data: {
|
|
500
|
+
items: [
|
|
501
|
+
{
|
|
502
|
+
documentId: 'd0',
|
|
503
|
+
title: 'Local SEO',
|
|
504
|
+
after: 'Read about <a href="/services/seo">our SEO services</a> first.',
|
|
505
|
+
},
|
|
506
|
+
],
|
|
507
|
+
},
|
|
508
|
+
status: 200,
|
|
509
|
+
})
|
|
510
|
+
fireEvent.click(within(dialog).getByRole('button', { name: 'Try again' }))
|
|
511
|
+
const mark = await within(dialog).findByText('our SEO services')
|
|
512
|
+
expect(mark.tagName).toBe('MARK')
|
|
513
|
+
expect(within(dialog).getByText('→ /services/seo')).toBeTruthy()
|
|
514
|
+
expect(dialog.querySelector('a')).toBeNull()
|
|
515
|
+
})
|
|
516
|
+
|
|
517
|
+
it('says when the AI would change none of the 3', async () => {
|
|
518
|
+
server.preview = () => ({ data: { items: [] }, status: 200 })
|
|
519
|
+
const { dialog } = renderWizard()
|
|
520
|
+
await toStep3(dialog)
|
|
521
|
+
fireEvent.click(within(dialog).getByRole('button', { name: 'Try it' }))
|
|
522
|
+
expect(
|
|
523
|
+
await within(dialog).findByText('The AI would leave the first 3 documents as they are.'),
|
|
524
|
+
).toBeTruthy()
|
|
525
|
+
})
|
|
526
|
+
|
|
527
|
+
it('lets an admin apply automatically, which never publishes', async () => {
|
|
528
|
+
const { dialog, onQueued } = renderWizard()
|
|
529
|
+
await toStep3(dialog)
|
|
530
|
+
fireEvent.click(within(dialog).getByRole('radio', { name: /Publish right away/ }))
|
|
531
|
+
fireEvent.click(within(dialog).getByRole('radio', { name: /Apply automatically/ }))
|
|
532
|
+
expect(within(dialog).getByText('When a change is made')).toBeTruthy()
|
|
533
|
+
const publish = within(dialog).getByRole('radio', { name: /Publish right away/ })
|
|
534
|
+
expect((publish as HTMLInputElement).disabled).toBe(true)
|
|
535
|
+
expect(
|
|
536
|
+
(within(dialog).getByRole('radio', { name: /Save as draft/ }) as HTMLInputElement).checked,
|
|
537
|
+
).toBe(true)
|
|
538
|
+
const help = within(dialog).getByText(/Applying automatically keeps each document’s status/)
|
|
539
|
+
expect(publish.getAttribute('aria-describedby')).toBe(help.id)
|
|
540
|
+
|
|
541
|
+
fireEvent.click(within(dialog).getByRole('button', { name: 'Queue 42 documents' }))
|
|
542
|
+
await waitFor(() => expect(onQueued).toHaveBeenCalledTimes(1))
|
|
543
|
+
expect(server.calls.find((c) => c.endpoint === '/jobs/batch-ai')!.body).toMatchObject({
|
|
544
|
+
review: 'auto',
|
|
545
|
+
onApprove: 'draft',
|
|
546
|
+
})
|
|
547
|
+
})
|
|
548
|
+
|
|
549
|
+
it('keeps "Apply automatically" from a non-admin, with a tooltip', async () => {
|
|
550
|
+
const { dialog } = renderWizard({ role: 'EDITOR' })
|
|
551
|
+
await toStep3(dialog)
|
|
552
|
+
const auto = within(dialog).getByRole('radio', { name: /Apply automatically/ })
|
|
553
|
+
expect((auto as HTMLInputElement).disabled).toBe(true)
|
|
554
|
+
const tip = within(dialog).getByRole('tooltip', { hidden: true })
|
|
555
|
+
expect(tip.textContent).toBe('Only an admin can apply changes automatically.')
|
|
556
|
+
expect(auto.getAttribute('aria-describedby')).toBe(tip.id)
|
|
557
|
+
fireEvent.click(auto)
|
|
558
|
+
expect((auto as HTMLInputElement).checked).toBe(false)
|
|
559
|
+
})
|
|
560
|
+
|
|
561
|
+
it('offers "Apply automatically" to an owner', async () => {
|
|
562
|
+
const { dialog } = renderWizard({ role: 'OWNER' })
|
|
563
|
+
await toStep3(dialog)
|
|
564
|
+
const auto = within(dialog).getByRole('radio', { name: /Apply automatically/ })
|
|
565
|
+
expect((auto as HTMLInputElement).disabled).toBe(false)
|
|
566
|
+
expect(within(dialog).queryByRole('tooltip', { hidden: true })).toBeNull()
|
|
567
|
+
})
|
|
568
|
+
|
|
569
|
+
it('keeps the wizard open on a failed create, with the server’s reason', async () => {
|
|
570
|
+
const gate = deferred<Reply>()
|
|
571
|
+
server.create = () => gate.promise
|
|
572
|
+
const { dialog, onQueued, onClose } = renderWizard()
|
|
573
|
+
await toStep3(dialog)
|
|
574
|
+
const queue = within(dialog).getByRole('button', { name: 'Queue 42 documents' })
|
|
575
|
+
queue.focus()
|
|
576
|
+
fireEvent.click(queue)
|
|
577
|
+
expect(queue.getAttribute('aria-busy')).toBe('true')
|
|
578
|
+
// Neither Esc nor Back leaves while it's queuing.
|
|
579
|
+
fireEvent.keyDown(dialog, { key: 'Escape' })
|
|
580
|
+
fireEvent.click(within(dialog).getByRole('button', { name: 'Back' }))
|
|
581
|
+
expect(onClose).not.toHaveBeenCalled()
|
|
582
|
+
expect(current(dialog)).toBe('Review & queue')
|
|
583
|
+
|
|
584
|
+
await act(async () => gate.resolve({ error: 'No documents match this filter.', status: 400 }))
|
|
585
|
+
expect(within(dialog).getByRole('alert').textContent).toBe(
|
|
586
|
+
'Couldn’t queue the job: No documents match this filter.',
|
|
587
|
+
)
|
|
588
|
+
expect(queue.getAttribute('aria-busy')).toBeNull()
|
|
589
|
+
expect(document.activeElement).toBe(queue)
|
|
590
|
+
expect(onQueued).not.toHaveBeenCalled()
|
|
591
|
+
expect(server.calls.find((c) => c.endpoint === '/jobs/batch-ai')!.body).toEqual({
|
|
592
|
+
operation: 'meta',
|
|
593
|
+
source: 'posts',
|
|
594
|
+
statusFilter: 'published',
|
|
595
|
+
review: 'review',
|
|
596
|
+
onApprove: 'draft',
|
|
597
|
+
instruction: null,
|
|
598
|
+
})
|
|
599
|
+
|
|
600
|
+
// Back clears it.
|
|
601
|
+
fireEvent.click(within(dialog).getByRole('button', { name: 'Back' }))
|
|
602
|
+
expect(within(dialog).queryByRole('alert')).toBeNull()
|
|
603
|
+
})
|
|
604
|
+
|
|
605
|
+
it('drops a preview once what it previewed changes', async () => {
|
|
606
|
+
const { dialog } = renderWizard()
|
|
607
|
+
await toStep3(dialog)
|
|
608
|
+
fireEvent.click(within(dialog).getByRole('button', { name: 'Try it' }))
|
|
609
|
+
await within(dialog).findByText(OUTPUT[0]!)
|
|
610
|
+
fireEvent.click(within(dialog).getByRole('button', { name: 'Back' }))
|
|
611
|
+
fireEvent.change(within(dialog).getByLabelText('Status'), { target: { value: 'all' } })
|
|
612
|
+
await waitFor(() => expect(estimateCalls()).toHaveLength(2))
|
|
613
|
+
fireEvent.click(within(dialog).getByRole('button', { name: 'Continue' }))
|
|
614
|
+
await within(dialog).findByText('Before anything changes')
|
|
615
|
+
expect(within(dialog).queryByText(OUTPUT[0]!)).toBeNull()
|
|
616
|
+
expect(within(dialog).getByRole('button', { name: 'Try it' })).toBeTruthy()
|
|
617
|
+
})
|
|
618
|
+
})
|
|
619
|
+
|
|
620
|
+
describe('a picked selection (P-21)', () => {
|
|
621
|
+
it('runs the picked documents as they are: "{n} selected", drafts included', async () => {
|
|
622
|
+
server.estimate = (query) => estimate(query.get('documentIds')!.split(',').length)
|
|
623
|
+
const { dialog } = renderWizard({
|
|
624
|
+
selection: { source: 'pages', documentIds: ['p1', 'p2', 'p3'] },
|
|
625
|
+
})
|
|
626
|
+
// Alt text runs on images, so it can't run on picked pages.
|
|
627
|
+
expect(
|
|
628
|
+
(within(dialog).getByRole('radio', { name: /Generate image alt text/ }) as HTMLInputElement)
|
|
629
|
+
.disabled,
|
|
630
|
+
).toBe(true)
|
|
631
|
+
fireEvent.click(within(dialog).getByRole('button', { name: 'Continue' }))
|
|
632
|
+
await within(dialog).findByText('pages match')
|
|
633
|
+
expect(within(dialog).getByText('3 selected')).toBeTruthy()
|
|
634
|
+
expect(within(dialog).queryByText('Missing or over 160 characters')).toBeNull()
|
|
635
|
+
const content = within(dialog).getByLabelText('Content') as HTMLSelectElement
|
|
636
|
+
expect(content.disabled).toBe(true)
|
|
637
|
+
expect(content.selectedOptions[0]!.textContent).toBe('Pages')
|
|
638
|
+
expect((within(dialog).getByLabelText('Status') as HTMLSelectElement).value).toBe('all')
|
|
639
|
+
expect(Object.fromEntries(estimateCalls()[0]!)).toEqual({
|
|
640
|
+
operation: 'meta',
|
|
641
|
+
source: 'pages',
|
|
642
|
+
statusFilter: 'all',
|
|
643
|
+
documentIds: 'p1,p2,p3',
|
|
644
|
+
})
|
|
645
|
+
fireEvent.click(within(dialog).getByRole('button', { name: 'Continue' }))
|
|
646
|
+
fireEvent.click(await within(dialog).findByRole('button', { name: 'Queue 3 documents' }))
|
|
647
|
+
await waitFor(() =>
|
|
648
|
+
expect(server.calls.find((c) => c.endpoint === '/jobs/batch-ai')!.body).toMatchObject({
|
|
649
|
+
source: 'pages',
|
|
650
|
+
filter: { documentIds: ['p1', 'p2', 'p3'] },
|
|
651
|
+
}),
|
|
652
|
+
)
|
|
653
|
+
})
|
|
654
|
+
|
|
655
|
+
it('starts picked images on alt text, the only operation for them', () => {
|
|
656
|
+
const { dialog } = renderWizard({ selection: { source: 'media', documentIds: ['m1'] } })
|
|
657
|
+
const radios = within(dialog).getAllByRole('radio') as HTMLInputElement[]
|
|
658
|
+
expect(radios.filter((r) => !r.disabled).map((r) => r.value)).toEqual(['alt'])
|
|
659
|
+
expect(radios.find((r) => r.value === 'alt')!.checked).toBe(true)
|
|
660
|
+
})
|
|
661
|
+
})
|
|
662
|
+
|
|
663
|
+
describe('from the Jobs screen', () => {
|
|
664
|
+
it('queues the batch and shows it as the top row, focused', async () => {
|
|
665
|
+
server.jobs = [
|
|
666
|
+
job({ id: 'job_419', title: 'Write excerpts', status: 'succeeded', finishedAt: iso() }),
|
|
667
|
+
]
|
|
668
|
+
render(<Jobs onNavigate={() => {}} role="ADMIN" config={CONFIG} />)
|
|
669
|
+
await screen.findByRole('list', { name: 'Jobs' })
|
|
670
|
+
const opener = screen.getByRole('button', { name: 'New batch AI job' })
|
|
671
|
+
opener.focus()
|
|
672
|
+
fireEvent.click(opener)
|
|
673
|
+
const dialog = screen.getByRole('dialog')
|
|
674
|
+
await toStep3(dialog)
|
|
675
|
+
fireEvent.click(within(dialog).getByRole('button', { name: 'Queue 42 documents' }))
|
|
676
|
+
|
|
677
|
+
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull())
|
|
678
|
+
expect(toast).toHaveBeenCalledWith('“Rewrite meta descriptions” queued · 42 documents')
|
|
679
|
+
const rows = [...document.querySelectorAll<HTMLElement>('[data-job-open]')]
|
|
680
|
+
expect(rows.map((row) => row.id)).toEqual(['job-open-job_420', 'job-open-job_419'])
|
|
681
|
+
expect(rows[0]!.closest('li')!.textContent).toContain('0 / 42')
|
|
682
|
+
await waitFor(() => expect(document.activeElement).toBe(rows[0]))
|
|
683
|
+
})
|
|
684
|
+
|
|
685
|
+
it('returns focus to New batch AI job when closed', async () => {
|
|
686
|
+
server.jobs = [job({ id: 'job_419', status: 'succeeded', finishedAt: iso() })]
|
|
687
|
+
render(<Jobs onNavigate={() => {}} role="EDITOR" config={CONFIG} />)
|
|
688
|
+
await screen.findByRole('list', { name: 'Jobs' })
|
|
689
|
+
const opener = screen.getByRole('button', { name: 'New batch AI job' })
|
|
690
|
+
opener.focus()
|
|
691
|
+
fireEvent.click(opener)
|
|
692
|
+
fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Escape' })
|
|
693
|
+
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull())
|
|
694
|
+
await waitFor(() => expect(document.activeElement).toBe(opener))
|
|
695
|
+
})
|
|
696
|
+
|
|
697
|
+
it('offers New batch AI job only to roles that may queue one, as Run now', async () => {
|
|
698
|
+
server.jobs = [job({ id: 'job_419', status: 'succeeded', finishedAt: iso() })]
|
|
699
|
+
const { unmount } = render(<Jobs onNavigate={() => {}} role="VIEWER" config={CONFIG} />)
|
|
700
|
+
await screen.findByRole('list', { name: 'Jobs' })
|
|
701
|
+
expect(screen.queryByRole('button', { name: 'New batch AI job' })).toBeNull()
|
|
702
|
+
unmount()
|
|
703
|
+
render(<Jobs onNavigate={() => {}} role="AUTHOR" config={CONFIG} />)
|
|
704
|
+
await screen.findByRole('list', { name: 'Jobs' })
|
|
705
|
+
expect(screen.getByRole('button', { name: 'New batch AI job' })).toBeTruthy()
|
|
706
|
+
})
|
|
707
|
+
})
|