@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
|
@@ -34,6 +34,8 @@ interface Server {
|
|
|
34
34
|
reviewError: { status: number; message: string } | null
|
|
35
35
|
decide: (jobId: string, itemId: string, body: Record<string, unknown>) => Reply | Promise<Reply>
|
|
36
36
|
approveAll: (jobId: string) => Reply | Promise<Reply>
|
|
37
|
+
/** `POST /jobs/run`, `/jobs/:id/cancel` and `/jobs/:id/retry`. */
|
|
38
|
+
action: (endpoint: string, body: unknown) => Reply | Promise<Reply>
|
|
37
39
|
calls: Call[]
|
|
38
40
|
}
|
|
39
41
|
|
|
@@ -45,6 +47,7 @@ const server: Server = {
|
|
|
45
47
|
reviewError: null,
|
|
46
48
|
decide: () => ({ error: 'not stubbed', status: 500 }),
|
|
47
49
|
approveAll: () => ({ error: 'not stubbed', status: 500 }),
|
|
50
|
+
action: () => ({ error: 'not stubbed', status: 500 }),
|
|
48
51
|
calls: [],
|
|
49
52
|
}
|
|
50
53
|
|
|
@@ -77,6 +80,8 @@ function cmsApiStub(endpoint: string, init: RequestInit = {}): Reply | Promise<R
|
|
|
77
80
|
if (method === 'POST' && part === 'review' && itemId === 'approve-all')
|
|
78
81
|
return server.approveAll(jobId!)
|
|
79
82
|
if (method === 'POST' && part === 'review') return server.decide(jobId!, itemId!, body)
|
|
83
|
+
if (method === 'POST' && (jobId === 'run' || part === 'cancel' || part === 'retry'))
|
|
84
|
+
return server.action(endpoint, body)
|
|
80
85
|
return { error: `unexpected ${method} ${endpoint}`, status: 404 }
|
|
81
86
|
}
|
|
82
87
|
|
|
@@ -104,6 +109,10 @@ function fetchStub(url: string) {
|
|
|
104
109
|
|
|
105
110
|
const { Jobs } = await import('../../views/Jobs.js')
|
|
106
111
|
|
|
112
|
+
type Viewer = { role: string | null; userId: string | null }
|
|
113
|
+
/** Someone who may run, cancel and retry any job. */
|
|
114
|
+
const ADMIN: Viewer = { role: 'ADMIN', userId: 'u1' }
|
|
115
|
+
|
|
107
116
|
function iso(offsetMs: number): string {
|
|
108
117
|
return new Date(Date.now() + offsetMs).toISOString()
|
|
109
118
|
}
|
|
@@ -190,6 +199,7 @@ beforeEach(() => {
|
|
|
190
199
|
reviewError: null,
|
|
191
200
|
calls: [],
|
|
192
201
|
approveAll: () => ({ error: 'not stubbed', status: 500 }),
|
|
202
|
+
action: () => ({ error: 'not stubbed', status: 500 }),
|
|
193
203
|
})
|
|
194
204
|
// By default a decision goes through: the change takes the new status.
|
|
195
205
|
server.decide = (jobId, itemId, body) => {
|
|
@@ -220,8 +230,8 @@ function rowButton(id: string): HTMLElement {
|
|
|
220
230
|
return button
|
|
221
231
|
}
|
|
222
232
|
|
|
223
|
-
async function renderJobs(onNavigate: (path: string) => void = () => {}) {
|
|
224
|
-
render(<Jobs onNavigate={onNavigate} />)
|
|
233
|
+
async function renderJobs(onNavigate: (path: string) => void = () => {}, viewer: Viewer = ADMIN) {
|
|
234
|
+
render(<Jobs onNavigate={onNavigate} {...viewer} />)
|
|
225
235
|
await screen.findByRole('list', { name: 'Jobs' })
|
|
226
236
|
}
|
|
227
237
|
|
|
@@ -1045,3 +1055,499 @@ describe('Jobs drawer', () => {
|
|
|
1045
1055
|
expect(raw).toContain('"id": "job_417"')
|
|
1046
1056
|
})
|
|
1047
1057
|
})
|
|
1058
|
+
|
|
1059
|
+
/*
|
|
1060
|
+
* P-19: the footer's Cancel job, "Retry from document {n+1}" and "Retry now",
|
|
1061
|
+
* the cancel dialog over the drawer, Run now reusing a job, and who sees them.
|
|
1062
|
+
*/
|
|
1063
|
+
|
|
1064
|
+
function scan(
|
|
1065
|
+
type: 'seo-scan' | 'content-health',
|
|
1066
|
+
overrides: Partial<Job> & { done?: number; total?: number } = {},
|
|
1067
|
+
): Job {
|
|
1068
|
+
const job = batch({
|
|
1069
|
+
id: type === 'seo-scan' ? 'job_scan' : 'job_health',
|
|
1070
|
+
type,
|
|
1071
|
+
title: type === 'seo-scan' ? 'SEO scan' : 'Content health',
|
|
1072
|
+
scope: type === 'seo-scan' ? 'All indexable pages' : 'All published documents',
|
|
1073
|
+
createdBy: { kind: 'cron', schedule: type === 'seo-scan' ? 'weekly-seo' : 'nightly-health' },
|
|
1074
|
+
payload: { trigger: 'cron' },
|
|
1075
|
+
...overrides,
|
|
1076
|
+
})
|
|
1077
|
+
delete job.review
|
|
1078
|
+
return job
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
/** A batch that ran out of attempts at document 8. */
|
|
1082
|
+
function failed(overrides: Partial<Job> = {}): Job {
|
|
1083
|
+
return batch({
|
|
1084
|
+
id: 'job_415',
|
|
1085
|
+
title: 'Translate excerpts to Spanish',
|
|
1086
|
+
status: 'dead',
|
|
1087
|
+
attempts: 3,
|
|
1088
|
+
done: 7,
|
|
1089
|
+
total: 30,
|
|
1090
|
+
lockedAt: null,
|
|
1091
|
+
lastError: 'ProviderError: 429 Too Many Requests',
|
|
1092
|
+
...overrides,
|
|
1093
|
+
})
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
/** A batch waiting out a failed slice's backoff. */
|
|
1097
|
+
function waiting(overrides: Partial<Job> = {}): Job {
|
|
1098
|
+
return batch({
|
|
1099
|
+
id: 'job_418',
|
|
1100
|
+
title: 'Generate alt text',
|
|
1101
|
+
status: 'queued',
|
|
1102
|
+
attempts: 1,
|
|
1103
|
+
done: 9,
|
|
1104
|
+
total: 64,
|
|
1105
|
+
lockedAt: null,
|
|
1106
|
+
lastError: 'ImageFetchError: img_2291.webp returned 404',
|
|
1107
|
+
runAt: iso(30_000),
|
|
1108
|
+
...overrides,
|
|
1109
|
+
})
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
/** The job as the server has it after an action; the list's next read returns it. */
|
|
1113
|
+
function patchJob(id: string, patch: Partial<Job>): Job {
|
|
1114
|
+
server.jobs = server.jobs.map((job) =>
|
|
1115
|
+
job.id === id ? { ...job, ...patch, updatedAt: iso(1_000) } : job,
|
|
1116
|
+
)
|
|
1117
|
+
return server.jobs.find((job) => job.id === id)!
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
function panelOf(drawer: HTMLElement): HTMLElement {
|
|
1121
|
+
return drawer.querySelector<HTMLElement>('[role="tabpanel"]')!
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
/** The cancel dialog, over the drawer or the list. */
|
|
1125
|
+
function cancelDialog(): Promise<HTMLElement> {
|
|
1126
|
+
return screen.findByRole('dialog', { name: /^Cancel “/ })
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
async function cancelDialogGone() {
|
|
1130
|
+
await waitFor(() => expect(screen.queryByRole('dialog', { name: /^Cancel “/ })).toBeNull())
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
/** Click as a browser does: the button takes focus first. */
|
|
1134
|
+
function press(button: HTMLElement) {
|
|
1135
|
+
button.focus()
|
|
1136
|
+
fireEvent.click(button)
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
describe('Jobs drawer actions (P-19)', () => {
|
|
1140
|
+
it('opens the cancel dialog over the drawer; Keep running and Escape go back to Cancel job', async () => {
|
|
1141
|
+
server.jobs = [batch({})]
|
|
1142
|
+
await renderJobs()
|
|
1143
|
+
const drawer = await openJob('job_417')
|
|
1144
|
+
const cancel = within(drawer).getByRole('button', { name: 'Cancel job' })
|
|
1145
|
+
expect(cancel.getAttribute('aria-haspopup')).toBe('dialog')
|
|
1146
|
+
expect(within(drawer).queryByRole('button', { name: /^Retry/ })).toBeNull()
|
|
1147
|
+
|
|
1148
|
+
cancel.focus()
|
|
1149
|
+
fireEvent.click(cancel)
|
|
1150
|
+
const dialog = await cancelDialog()
|
|
1151
|
+
expect(dialog.getAttribute('aria-modal')).toBe('true')
|
|
1152
|
+
expect(dialog.textContent).toContain(
|
|
1153
|
+
'It’s in the middle of a slice. It will stop when that slice finishes, usually within 20 seconds. The 19 changes already made stay in review. Nothing is applied unless you approve it.',
|
|
1154
|
+
)
|
|
1155
|
+
// The drawer's controls are hidden behind it. (aria-hidden keeps the
|
|
1156
|
+
// drawer's live region, and so the drawer element, exposed.)
|
|
1157
|
+
expect(within(drawer).queryByRole('button', { name: 'Cancel job' })).toBeNull()
|
|
1158
|
+
expect(within(drawer).queryByRole('tab')).toBeNull()
|
|
1159
|
+
fireEvent.click(within(dialog).getByRole('button', { name: 'Keep running' }))
|
|
1160
|
+
await cancelDialogGone()
|
|
1161
|
+
await waitFor(() => expect(document.activeElement).toBe(cancel))
|
|
1162
|
+
expect(within(drawer).getByRole('button', { name: 'Cancel job' })).toBe(cancel)
|
|
1163
|
+
|
|
1164
|
+
// Escape closes the dialog on top, not the drawer under it.
|
|
1165
|
+
fireEvent.click(cancel)
|
|
1166
|
+
await cancelDialog()
|
|
1167
|
+
fireEvent.keyDown(document.activeElement ?? document, { key: 'Escape' })
|
|
1168
|
+
await cancelDialogGone()
|
|
1169
|
+
expect(screen.getByRole('dialog', { name: 'Rewrite meta descriptions' })).toBe(drawer)
|
|
1170
|
+
await waitFor(() => expect(document.activeElement).toBe(cancel))
|
|
1171
|
+
expect(posts('POST', '/cancel')).toHaveLength(0)
|
|
1172
|
+
})
|
|
1173
|
+
|
|
1174
|
+
it('uses the kit’s copy for each state', async () => {
|
|
1175
|
+
const auto = batch({}).payload as Extract<Job['payload'], { review: string }>
|
|
1176
|
+
server.jobs = [
|
|
1177
|
+
batch({ status: 'queued', lockedAt: null, payload: { ...auto, review: 'auto' } }),
|
|
1178
|
+
scan('content-health', { status: 'running', done: 50, total: 412 }),
|
|
1179
|
+
scan('seo-scan', { status: 'queued', lockedAt: null, done: 0, total: 340 }),
|
|
1180
|
+
]
|
|
1181
|
+
await renderJobs()
|
|
1182
|
+
const copy = async (id: string) => {
|
|
1183
|
+
const drawer = await openJob(id)
|
|
1184
|
+
fireEvent.click(within(drawer).getByRole('button', { name: 'Cancel job' }))
|
|
1185
|
+
const text = (await cancelDialog()).textContent ?? ''
|
|
1186
|
+
fireEvent.keyDown(document.activeElement ?? document, { key: 'Escape' })
|
|
1187
|
+
await cancelDialogGone()
|
|
1188
|
+
fireEvent.click(within(drawer).getByRole('button', { name: 'Close' }))
|
|
1189
|
+
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull())
|
|
1190
|
+
return text
|
|
1191
|
+
}
|
|
1192
|
+
expect(await copy('job_417')).toContain(
|
|
1193
|
+
'It hasn’t started its next slice, so it stops right away. The 19 documents already changed keep their changes. You can undo them from each document’s history.',
|
|
1194
|
+
)
|
|
1195
|
+
expect(await copy('job_health')).toContain(
|
|
1196
|
+
'It’s in the middle of a slice. It will stop when that slice finishes, usually within 20 seconds. Progress so far is kept. The next nightly cron run starts fresh.',
|
|
1197
|
+
)
|
|
1198
|
+
expect(await copy('job_scan')).toContain(
|
|
1199
|
+
'It hasn’t started its next slice, so it stops right away. Progress so far is kept. The next weekly cron run starts fresh.',
|
|
1200
|
+
)
|
|
1201
|
+
})
|
|
1202
|
+
|
|
1203
|
+
it('cancels a running job: Cancelling · Stops after this slice, announced, focus in the drawer', async () => {
|
|
1204
|
+
server.jobs = [batch({})]
|
|
1205
|
+
server.action = (endpoint) =>
|
|
1206
|
+
endpoint === '/jobs/job_417/cancel'
|
|
1207
|
+
? ok(patchJob('job_417', { cancelRequestedAt: iso(0) }))
|
|
1208
|
+
: { error: `unexpected ${endpoint}`, status: 500 }
|
|
1209
|
+
await renderJobs()
|
|
1210
|
+
const drawer = await openJob('job_417')
|
|
1211
|
+
press(within(drawer).getByRole('button', { name: 'Cancel job' }))
|
|
1212
|
+
press(within(await cancelDialog()).getByRole('button', { name: 'Cancel job' }))
|
|
1213
|
+
|
|
1214
|
+
await cancelDialogGone()
|
|
1215
|
+
await waitFor(() => expect(toast).toHaveBeenCalledWith('Stopping after this slice'))
|
|
1216
|
+
await waitFor(() => expect(announced()).toBe('Stopping after this slice'))
|
|
1217
|
+
expect(within(drawer).getAllByText('Cancelling').length).toBeGreaterThan(0)
|
|
1218
|
+
expect(drawer.textContent).toContain(
|
|
1219
|
+
'Stopping when the current slice finishes, usually within 20 seconds.',
|
|
1220
|
+
)
|
|
1221
|
+
expect(within(drawer).queryByRole('button', { name: 'Cancel job' })).toBeNull()
|
|
1222
|
+
// Cancel job went with the cancel, so focus lands on the panel showing the new state.
|
|
1223
|
+
await waitFor(() => expect(document.activeElement).toBe(panelOf(drawer)))
|
|
1224
|
+
expect(posts('POST', '/cancel')).toHaveLength(1)
|
|
1225
|
+
|
|
1226
|
+
fireEvent.click(within(drawer).getByRole('button', { name: 'Close' }))
|
|
1227
|
+
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull())
|
|
1228
|
+
const row = rowButton('job_417').closest('li')!
|
|
1229
|
+
expect(within(row).getByText('Cancelling')).toBeTruthy()
|
|
1230
|
+
expect(within(row).getByText('Stops after this slice')).toBeTruthy()
|
|
1231
|
+
expect(within(row).queryByRole('button', { name: /^Cancel/ })).toBeNull()
|
|
1232
|
+
})
|
|
1233
|
+
|
|
1234
|
+
it('shows a cancel’s outcome only once its dialog has closed', async () => {
|
|
1235
|
+
server.jobs = [batch({ status: 'queued', lockedAt: null })]
|
|
1236
|
+
let answer: (reply: Reply) => void = () => {}
|
|
1237
|
+
server.action = () => new Promise<Reply>((resolve) => (answer = resolve))
|
|
1238
|
+
await renderJobs()
|
|
1239
|
+
const drawer = await openJob('job_417')
|
|
1240
|
+
press(within(drawer).getByRole('button', { name: 'Cancel job' }))
|
|
1241
|
+
const dialog = await cancelDialog()
|
|
1242
|
+
press(within(dialog).getByRole('button', { name: 'Cancel job' }))
|
|
1243
|
+
await waitFor(() => expect(posts('POST', '/cancel')).toHaveLength(1))
|
|
1244
|
+
// While the dialog is open Radix hides the rest of the page from screen
|
|
1245
|
+
// readers: the toaster, and the drawer with its live region.
|
|
1246
|
+
const dialogsAtToast: number[] = []
|
|
1247
|
+
toast.mockImplementationOnce(() => {
|
|
1248
|
+
dialogsAtToast.push(document.querySelectorAll('[role="dialog"]').length)
|
|
1249
|
+
})
|
|
1250
|
+
await act(async () => {
|
|
1251
|
+
answer(ok(patchJob('job_417', { status: 'cancelled', cancelRequestedAt: iso(0) })))
|
|
1252
|
+
})
|
|
1253
|
+
await waitFor(() => expect(toast).toHaveBeenCalledWith('Job cancelled'))
|
|
1254
|
+
expect(dialogsAtToast).toEqual([1])
|
|
1255
|
+
await waitFor(() => expect(announced()).toBe('Job cancelled'))
|
|
1256
|
+
// Focus was back in the drawer before the outcome was announced.
|
|
1257
|
+
expect(document.activeElement).toBe(panelOf(drawer))
|
|
1258
|
+
})
|
|
1259
|
+
|
|
1260
|
+
it('posts one cancel for a double click, and opens one dialog for a double click on Cancel job', async () => {
|
|
1261
|
+
server.jobs = [batch({})]
|
|
1262
|
+
let answer: (reply: Reply) => void = () => {}
|
|
1263
|
+
server.action = () => new Promise<Reply>((resolve) => (answer = resolve))
|
|
1264
|
+
await renderJobs()
|
|
1265
|
+
const drawer = await openJob('job_417')
|
|
1266
|
+
const open = within(drawer).getByRole('button', { name: 'Cancel job' })
|
|
1267
|
+
fireEvent.click(open)
|
|
1268
|
+
fireEvent.click(open)
|
|
1269
|
+
const dialog = await cancelDialog()
|
|
1270
|
+
expect(screen.getAllByRole('dialog', { name: /^Cancel “/ })).toHaveLength(1)
|
|
1271
|
+
|
|
1272
|
+
const confirm = within(dialog).getByRole('button', { name: 'Cancel job' })
|
|
1273
|
+
confirm.focus()
|
|
1274
|
+
fireEvent.click(confirm)
|
|
1275
|
+
fireEvent.click(confirm)
|
|
1276
|
+
await waitFor(() => expect(confirm.getAttribute('aria-busy')).toBe('true'))
|
|
1277
|
+
fireEvent.click(confirm)
|
|
1278
|
+
expect(document.activeElement).toBe(confirm)
|
|
1279
|
+
// Keep running waits for the answer too.
|
|
1280
|
+
expect(
|
|
1281
|
+
within(dialog).getByRole('button', { name: 'Keep running' }).getAttribute('aria-disabled'),
|
|
1282
|
+
).toBe('true')
|
|
1283
|
+
await act(async () => {
|
|
1284
|
+
answer(ok(patchJob('job_417', { cancelRequestedAt: iso(0) })))
|
|
1285
|
+
})
|
|
1286
|
+
await cancelDialogGone()
|
|
1287
|
+
expect(posts('POST', '/cancel')).toHaveLength(1)
|
|
1288
|
+
})
|
|
1289
|
+
|
|
1290
|
+
it('reads the list again on a 409 from a cancel that already finished, then says so', async () => {
|
|
1291
|
+
server.jobs = [batch({ review: { pending: 4, approved: 0, rejected: 0 } })]
|
|
1292
|
+
server.action = () => {
|
|
1293
|
+
// The last slice finished just before the cancel arrived.
|
|
1294
|
+
patchJob('job_417', {
|
|
1295
|
+
status: 'succeeded',
|
|
1296
|
+
lockedAt: null,
|
|
1297
|
+
finishedAt: iso(0),
|
|
1298
|
+
progress: { done: 42, total: 42, unit: 'documents', cursor: '42', completed: 42 },
|
|
1299
|
+
})
|
|
1300
|
+
return { error: 'This job already finished.', status: 409 }
|
|
1301
|
+
}
|
|
1302
|
+
await renderJobs()
|
|
1303
|
+
const drawer = await openJob('job_417')
|
|
1304
|
+
press(within(drawer).getByRole('button', { name: 'Cancel job' }))
|
|
1305
|
+
const dialog = await cancelDialog()
|
|
1306
|
+
const listReads = vi.mocked(fetch).mock.calls.length
|
|
1307
|
+
press(within(dialog).getByRole('button', { name: 'Cancel job' }))
|
|
1308
|
+
|
|
1309
|
+
await cancelDialogGone()
|
|
1310
|
+
const message = '“Rewrite meta descriptions” wasn’t cancelled: This job already finished.'
|
|
1311
|
+
await waitFor(() => expect(toast).toHaveBeenCalledWith(message))
|
|
1312
|
+
expect(toast.error).not.toHaveBeenCalled()
|
|
1313
|
+
expect(vi.mocked(fetch).mock.calls.length).toBeGreaterThan(listReads)
|
|
1314
|
+
// The dialog closed onto the job as it is now: finished, with nothing to cancel.
|
|
1315
|
+
expect(within(drawer).getAllByText('Needs review').length).toBeGreaterThan(0)
|
|
1316
|
+
expect(within(drawer).queryByRole('button', { name: 'Cancel job' })).toBeNull()
|
|
1317
|
+
await waitFor(() => expect(announced()).toBe(message))
|
|
1318
|
+
await waitFor(() => expect(document.activeElement).toBe(panelOf(drawer)))
|
|
1319
|
+
})
|
|
1320
|
+
|
|
1321
|
+
it('says a job had already finished when the cancel comes back unchanged', async () => {
|
|
1322
|
+
server.jobs = [scan('content-health', { status: 'running', done: 50, total: 412 })]
|
|
1323
|
+
server.action = () =>
|
|
1324
|
+
ok(patchJob('job_health', { status: 'succeeded', lockedAt: null, finishedAt: iso(0) }))
|
|
1325
|
+
await renderJobs()
|
|
1326
|
+
const drawer = await openJob('job_health')
|
|
1327
|
+
fireEvent.click(within(drawer).getByRole('button', { name: 'Cancel job' }))
|
|
1328
|
+
fireEvent.click(within(await cancelDialog()).getByRole('button', { name: 'Cancel job' }))
|
|
1329
|
+
await waitFor(() =>
|
|
1330
|
+
expect(toast).toHaveBeenCalledWith('“Content health” had already finished.'),
|
|
1331
|
+
)
|
|
1332
|
+
expect(within(drawer).getAllByText('Succeeded').length).toBeGreaterThan(0)
|
|
1333
|
+
expect(within(drawer).queryByRole('button', { name: 'Cancel job' })).toBeNull()
|
|
1334
|
+
})
|
|
1335
|
+
|
|
1336
|
+
it('retries a failed job from the drawer: Retry from document 8, then focus on the panel', async () => {
|
|
1337
|
+
server.jobs = [failed()]
|
|
1338
|
+
server.action = (endpoint) =>
|
|
1339
|
+
endpoint === '/jobs/job_415/retry'
|
|
1340
|
+
? ok(patchJob('job_415', { status: 'queued', attempts: 0, lastError: null }))
|
|
1341
|
+
: { error: `unexpected ${endpoint}`, status: 500 }
|
|
1342
|
+
await renderJobs()
|
|
1343
|
+
const drawer = await openJob('job_415')
|
|
1344
|
+
expect(within(drawer).queryByRole('button', { name: 'Cancel job' })).toBeNull()
|
|
1345
|
+
expect(within(drawer).queryByRole('button', { name: 'Retry now' })).toBeNull()
|
|
1346
|
+
const retry = within(drawer).getByRole('button', { name: 'Retry from document 8' })
|
|
1347
|
+
// The kit's primary button.
|
|
1348
|
+
expect(retry.className).toContain('bg-acc')
|
|
1349
|
+
retry.focus()
|
|
1350
|
+
fireEvent.click(retry)
|
|
1351
|
+
await waitFor(() => expect(toast).toHaveBeenCalledWith('Retrying from document 8'))
|
|
1352
|
+
await waitFor(() => expect(announced()).toBe('Retrying from document 8'))
|
|
1353
|
+
expect(within(drawer).getAllByText('In progress').length).toBeGreaterThan(0)
|
|
1354
|
+
expect(within(drawer).queryByRole('button', { name: /^Retry/ })).toBeNull()
|
|
1355
|
+
// Queued again, it can be cancelled.
|
|
1356
|
+
expect(within(drawer).getByRole('button', { name: 'Cancel job' })).toBeTruthy()
|
|
1357
|
+
await waitFor(() => expect(document.activeElement).toBe(panelOf(drawer)))
|
|
1358
|
+
expect(posts('POST', '/retry')).toHaveLength(1)
|
|
1359
|
+
})
|
|
1360
|
+
|
|
1361
|
+
it('counts pages for an SEO scan: Retry from page 101', async () => {
|
|
1362
|
+
server.jobs = [
|
|
1363
|
+
scan('seo-scan', {
|
|
1364
|
+
status: 'dead',
|
|
1365
|
+
attempts: 3,
|
|
1366
|
+
done: 100,
|
|
1367
|
+
total: 340,
|
|
1368
|
+
lockedAt: null,
|
|
1369
|
+
lastError: 'TimeoutError: fetch timed out',
|
|
1370
|
+
}),
|
|
1371
|
+
]
|
|
1372
|
+
await renderJobs()
|
|
1373
|
+
const drawer = await openJob('job_scan')
|
|
1374
|
+
expect(within(drawer).getByRole('button', { name: 'Retry from page 101' })).toBeTruthy()
|
|
1375
|
+
})
|
|
1376
|
+
|
|
1377
|
+
it('posts one retry for a double click, keeping focus on the busy button', async () => {
|
|
1378
|
+
server.jobs = [failed()]
|
|
1379
|
+
let answer: (reply: Reply) => void = () => {}
|
|
1380
|
+
server.action = () => new Promise<Reply>((resolve) => (answer = resolve))
|
|
1381
|
+
await renderJobs()
|
|
1382
|
+
const drawer = await openJob('job_415')
|
|
1383
|
+
const retry = within(drawer).getByRole('button', { name: 'Retry from document 8' })
|
|
1384
|
+
retry.focus()
|
|
1385
|
+
fireEvent.click(retry)
|
|
1386
|
+
fireEvent.click(retry)
|
|
1387
|
+
await waitFor(() => expect(retry.getAttribute('aria-busy')).toBe('true'))
|
|
1388
|
+
expect(retry.hasAttribute('disabled')).toBe(false)
|
|
1389
|
+
expect(document.activeElement).toBe(retry)
|
|
1390
|
+
fireEvent.click(retry)
|
|
1391
|
+
expect(posts('POST', '/retry')).toHaveLength(1)
|
|
1392
|
+
await act(async () => {
|
|
1393
|
+
answer(ok(patchJob('job_415', { status: 'queued', attempts: 0, lastError: null })))
|
|
1394
|
+
})
|
|
1395
|
+
await waitFor(() => expect(toast).toHaveBeenCalledWith('Retrying from document 8'))
|
|
1396
|
+
expect(posts('POST', '/retry')).toHaveLength(1)
|
|
1397
|
+
})
|
|
1398
|
+
|
|
1399
|
+
it('reads the list again on a 409 from a retry, and shows the job as it is now', async () => {
|
|
1400
|
+
server.jobs = [failed()]
|
|
1401
|
+
server.action = () => {
|
|
1402
|
+
// Someone else retried it first, and the worker has picked it up.
|
|
1403
|
+
patchJob('job_415', { status: 'running', attempts: 0, lastError: null, lockedAt: iso(0) })
|
|
1404
|
+
return { error: 'Only a failed job, or one waiting to retry, can be retried', status: 409 }
|
|
1405
|
+
}
|
|
1406
|
+
await renderJobs()
|
|
1407
|
+
const drawer = await openJob('job_415')
|
|
1408
|
+
const retry = within(drawer).getByRole('button', { name: 'Retry from document 8' })
|
|
1409
|
+
retry.focus()
|
|
1410
|
+
fireEvent.click(retry)
|
|
1411
|
+
const message =
|
|
1412
|
+
'“Translate excerpts to Spanish” wasn’t retried: Only a failed job, or one waiting to retry, can be retried'
|
|
1413
|
+
await waitFor(() => expect(toast).toHaveBeenCalledWith(message))
|
|
1414
|
+
expect(toast.error).not.toHaveBeenCalled()
|
|
1415
|
+
// The answer shows with the job as it is now, read again: running.
|
|
1416
|
+
expect(within(drawer).getAllByText('Running').length).toBeGreaterThan(0)
|
|
1417
|
+
expect(within(drawer).queryByRole('button', { name: /^Retry/ })).toBeNull()
|
|
1418
|
+
await waitFor(() => expect(announced()).toBe(message))
|
|
1419
|
+
await waitFor(() => expect(document.activeElement).toBe(panelOf(drawer)))
|
|
1420
|
+
})
|
|
1421
|
+
|
|
1422
|
+
it('keeps Retry and focus on a refusal that isn’t a 409', async () => {
|
|
1423
|
+
server.jobs = [failed()]
|
|
1424
|
+
server.action = () => ({ error: 'Insufficient permissions', status: 403 })
|
|
1425
|
+
await renderJobs()
|
|
1426
|
+
const drawer = await openJob('job_415')
|
|
1427
|
+
const retry = within(drawer).getByRole('button', { name: 'Retry from document 8' })
|
|
1428
|
+
retry.focus()
|
|
1429
|
+
fireEvent.click(retry)
|
|
1430
|
+
const message = '“Translate excerpts to Spanish” wasn’t retried: Insufficient permissions'
|
|
1431
|
+
await waitFor(() => expect(toast.error).toHaveBeenCalledWith(message))
|
|
1432
|
+
await waitFor(() => expect(announced()).toBe(message))
|
|
1433
|
+
await waitFor(() => expect(retry.getAttribute('aria-busy')).toBeNull())
|
|
1434
|
+
expect(document.activeElement).toBe(retry)
|
|
1435
|
+
})
|
|
1436
|
+
|
|
1437
|
+
it('offers Retry now on a job waiting to retry, but not while cancelling or on a stale lock', async () => {
|
|
1438
|
+
server.jobs = [
|
|
1439
|
+
waiting(),
|
|
1440
|
+
waiting({ id: 'job_c', title: 'Cancelling batch', cancelRequestedAt: iso(0) }),
|
|
1441
|
+
batch({
|
|
1442
|
+
id: 'job_stale',
|
|
1443
|
+
title: 'Stuck batch',
|
|
1444
|
+
status: 'running',
|
|
1445
|
+
done: 4,
|
|
1446
|
+
attempts: 1,
|
|
1447
|
+
lockedAt: iso(-11 * MIN),
|
|
1448
|
+
}),
|
|
1449
|
+
]
|
|
1450
|
+
server.action = (endpoint) =>
|
|
1451
|
+
endpoint === '/jobs/job_418/retry'
|
|
1452
|
+
? ok(patchJob('job_418', { attempts: 0, lastError: null, runAt: iso(0) }))
|
|
1453
|
+
: { error: `unexpected ${endpoint}`, status: 500 }
|
|
1454
|
+
await renderJobs()
|
|
1455
|
+
|
|
1456
|
+
let drawer = await openJob('job_c')
|
|
1457
|
+
expect(within(drawer).getAllByText('Cancelling').length).toBeGreaterThan(0)
|
|
1458
|
+
expect(within(drawer).queryByRole('button', { name: 'Retry now' })).toBeNull()
|
|
1459
|
+
expect(within(drawer).queryByRole('button', { name: 'Cancel job' })).toBeNull()
|
|
1460
|
+
fireEvent.click(within(drawer).getByRole('button', { name: 'Close' }))
|
|
1461
|
+
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull())
|
|
1462
|
+
|
|
1463
|
+
// The server still holds a stale lock as running; the next worker run picks it up.
|
|
1464
|
+
drawer = await openJob('job_stale')
|
|
1465
|
+
expect(within(drawer).getAllByText('Retrying').length).toBeGreaterThan(0)
|
|
1466
|
+
expect(within(drawer).queryByRole('button', { name: 'Retry now' })).toBeNull()
|
|
1467
|
+
expect(within(drawer).getByRole('button', { name: 'Cancel job' })).toBeTruthy()
|
|
1468
|
+
fireEvent.click(within(drawer).getByRole('button', { name: 'Close' }))
|
|
1469
|
+
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull())
|
|
1470
|
+
|
|
1471
|
+
drawer = await openJob('job_418')
|
|
1472
|
+
expect(within(drawer).getByRole('button', { name: 'Cancel job' })).toBeTruthy()
|
|
1473
|
+
const now = within(drawer).getByRole('button', { name: 'Retry now' })
|
|
1474
|
+
// The kit's plain `.jb-btn`, not the primary retry.
|
|
1475
|
+
expect(now.className).toContain('bg-card')
|
|
1476
|
+
now.focus()
|
|
1477
|
+
fireEvent.click(now)
|
|
1478
|
+
await waitFor(() => expect(toast).toHaveBeenCalledWith('Retrying on the next worker run'))
|
|
1479
|
+
await waitFor(() => expect(announced()).toBe('Retrying on the next worker run'))
|
|
1480
|
+
expect(posts('POST', '/jobs/job_418/retry')).toHaveLength(1)
|
|
1481
|
+
expect(within(drawer).queryByRole('button', { name: 'Retry now' })).toBeNull()
|
|
1482
|
+
await waitFor(() => expect(document.activeElement).toBe(panelOf(drawer)))
|
|
1483
|
+
})
|
|
1484
|
+
|
|
1485
|
+
it('opens the running scan on Run now and announces the reuse in the drawer', async () => {
|
|
1486
|
+
const running = scan('seo-scan', { status: 'running', done: 40, total: 340 })
|
|
1487
|
+
server.jobs = [running]
|
|
1488
|
+
server.action = (endpoint) =>
|
|
1489
|
+
endpoint === '/jobs/run'
|
|
1490
|
+
? ok({ job: running, reused: true })
|
|
1491
|
+
: { error: `unexpected ${endpoint}`, status: 500 }
|
|
1492
|
+
await renderJobs()
|
|
1493
|
+
fireEvent.click(screen.getByRole('button', { name: 'Run now: SEO scan' }))
|
|
1494
|
+
const drawer = await screen.findByRole('dialog', { name: 'SEO scan' })
|
|
1495
|
+
expect(toast).toHaveBeenCalledWith('SEO scan is already running. Reusing that job.')
|
|
1496
|
+
await waitFor(() => expect(announced()).toBe('SEO scan is already running. Reusing that job.'))
|
|
1497
|
+
expect(drawer.hasAttribute('data-job-drawer')).toBe(true)
|
|
1498
|
+
expect(posts('POST', '/jobs/run')).toHaveLength(1)
|
|
1499
|
+
})
|
|
1500
|
+
|
|
1501
|
+
it('hides Run now, Cancel and Retry from a viewer', async () => {
|
|
1502
|
+
server.jobs = [batch({}), failed(), waiting()]
|
|
1503
|
+
await renderJobs(() => {}, { role: 'VIEWER', userId: 'u9' })
|
|
1504
|
+
expect(screen.queryByRole('button', { name: /^Run now/ })).toBeNull()
|
|
1505
|
+
expect(screen.queryByRole('button', { name: /^Cancel / })).toBeNull()
|
|
1506
|
+
expect(screen.queryByRole('button', { name: /^Retry/ })).toBeNull()
|
|
1507
|
+
// The failure alert keeps See why.
|
|
1508
|
+
expect(within(screen.getByRole('alert')).getByRole('button', { name: 'See why' })).toBeTruthy()
|
|
1509
|
+
|
|
1510
|
+
for (const id of ['job_417', 'job_415', 'job_418']) {
|
|
1511
|
+
const drawer = await openJob(id)
|
|
1512
|
+
expect(within(drawer).queryByRole('button', { name: 'Cancel job' })).toBeNull()
|
|
1513
|
+
expect(within(drawer).queryByRole('button', { name: /^Retry/ })).toBeNull()
|
|
1514
|
+
fireEvent.click(within(drawer).getByRole('button', { name: 'Close' }))
|
|
1515
|
+
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull())
|
|
1516
|
+
}
|
|
1517
|
+
})
|
|
1518
|
+
|
|
1519
|
+
it('shows nothing to act with when there’s no role', async () => {
|
|
1520
|
+
server.jobs = [batch({}), failed()]
|
|
1521
|
+
await renderJobs(() => {}, { role: null, userId: null })
|
|
1522
|
+
expect(screen.queryByRole('button', { name: /^Run now/ })).toBeNull()
|
|
1523
|
+
expect(screen.queryByRole('button', { name: /^Cancel / })).toBeNull()
|
|
1524
|
+
expect(screen.queryByRole('button', { name: /^Retry/ })).toBeNull()
|
|
1525
|
+
})
|
|
1526
|
+
|
|
1527
|
+
it('lets an editor retry a scan, and only a batch they queued', async () => {
|
|
1528
|
+
server.jobs = [
|
|
1529
|
+
failed({ createdBy: { id: 'u2', name: 'Dana Cole', initials: 'DC' } }),
|
|
1530
|
+
failed({
|
|
1531
|
+
id: 'job_mine',
|
|
1532
|
+
title: 'My batch',
|
|
1533
|
+
createdBy: { id: 'u7', name: 'Ed', initials: 'E' },
|
|
1534
|
+
}),
|
|
1535
|
+
scan('content-health', {
|
|
1536
|
+
status: 'dead',
|
|
1537
|
+
attempts: 3,
|
|
1538
|
+
done: 50,
|
|
1539
|
+
total: 412,
|
|
1540
|
+
lockedAt: null,
|
|
1541
|
+
lastError: 'Error: boom',
|
|
1542
|
+
}),
|
|
1543
|
+
]
|
|
1544
|
+
await renderJobs(() => {}, { role: 'EDITOR', userId: 'u7' })
|
|
1545
|
+
expect(screen.getByRole('button', { name: 'Run now: SEO scan' })).toBeTruthy()
|
|
1546
|
+
expect(screen.queryByRole('button', { name: 'Retry Translate excerpts to Spanish' })).toBeNull()
|
|
1547
|
+
expect(screen.getByRole('button', { name: 'Retry My batch' })).toBeTruthy()
|
|
1548
|
+
expect(screen.getByRole('button', { name: 'Retry Content health' })).toBeTruthy()
|
|
1549
|
+
|
|
1550
|
+
const drawer = await openJob('job_415')
|
|
1551
|
+
expect(within(drawer).queryByRole('button', { name: /^Retry/ })).toBeNull()
|
|
1552
|
+
})
|
|
1553
|
+
})
|
|
@@ -104,6 +104,9 @@ function fetchStub(url: string, init: RequestInit = {}) {
|
|
|
104
104
|
|
|
105
105
|
const { Jobs } = await import('../../views/Jobs.js')
|
|
106
106
|
|
|
107
|
+
/** Someone who may run, cancel and retry any job. */
|
|
108
|
+
const ADMIN = { role: 'ADMIN', userId: 'u1' }
|
|
109
|
+
|
|
107
110
|
/** A time on the server's clock, `offsetMs` from now. */
|
|
108
111
|
function iso(offsetMs: number): string {
|
|
109
112
|
return new Date(Date.now() + server.clockOffsetMs + offsetMs).toISOString()
|
|
@@ -301,13 +304,13 @@ async function advance(ms: number) {
|
|
|
301
304
|
}
|
|
302
305
|
|
|
303
306
|
async function renderLoaded() {
|
|
304
|
-
render(<Jobs onNavigate={() => {}} />)
|
|
307
|
+
render(<Jobs onNavigate={() => {}} {...ADMIN} />)
|
|
305
308
|
await screen.findByRole('list', { name: 'Jobs' })
|
|
306
309
|
}
|
|
307
310
|
|
|
308
311
|
/** With fake timers: render and let the first load land. */
|
|
309
312
|
async function renderOnClock() {
|
|
310
|
-
render(<Jobs onNavigate={() => {}} />)
|
|
313
|
+
render(<Jobs onNavigate={() => {}} {...ADMIN} />)
|
|
311
314
|
await advance(0)
|
|
312
315
|
expect(screen.getByRole('list', { name: 'Jobs' })).toBeTruthy()
|
|
313
316
|
}
|
|
@@ -428,7 +431,7 @@ describe('Jobs list', () => {
|
|
|
428
431
|
|
|
429
432
|
it('shows the empty state', async () => {
|
|
430
433
|
server.jobs = []
|
|
431
|
-
render(<Jobs onNavigate={() => {}} />)
|
|
434
|
+
render(<Jobs onNavigate={() => {}} {...ADMIN} />)
|
|
432
435
|
expect(await screen.findByText('No jobs here.')).toBeTruthy()
|
|
433
436
|
expect(screen.queryByRole('alert')).toBeNull()
|
|
434
437
|
expect(
|
|
@@ -574,7 +577,7 @@ describe('Jobs list refresh', () => {
|
|
|
574
577
|
// StrictMode mounts, unmounts and remounts, aborting the first requests.
|
|
575
578
|
render(
|
|
576
579
|
<StrictMode>
|
|
577
|
-
<Jobs onNavigate={() => {}} />
|
|
580
|
+
<Jobs onNavigate={() => {}} {...ADMIN} />
|
|
578
581
|
</StrictMode>,
|
|
579
582
|
)
|
|
580
583
|
expect(await screen.findByText(/^Ran 2\ds ago/)).toBeTruthy()
|
|
@@ -649,7 +652,7 @@ describe('Jobs list refresh', () => {
|
|
|
649
652
|
it('shows a load error with Try again, then focuses the list it loads', async () => {
|
|
650
653
|
useClock()
|
|
651
654
|
server.listError = { status: 500, message: 'Database unavailable' }
|
|
652
|
-
render(<Jobs onNavigate={() => {}} />)
|
|
655
|
+
render(<Jobs onNavigate={() => {}} {...ADMIN} />)
|
|
653
656
|
await advance(0)
|
|
654
657
|
const alert = screen.getByRole('alert')
|
|
655
658
|
expect(alert.textContent).toContain('Jobs couldn’t be loaded. Database unavailable')
|