Skip to content

Commit d9f19e1

Browse files
authored
Merge pull request #5 from trieb-work/feat-update-e2e
Feat update e2e
2 parents 0c9a6e0 + ab78496 commit d9f19e1

11 files changed

Lines changed: 362 additions & 39 deletions

File tree

.changeset/deep-cups-walk.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@trieb.work/payload-plugin-backup-mongodb': patch
3+
---
4+
5+
update e2e UI

.github/workflows/e2e.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,11 +73,16 @@ jobs:
7373
- name: Build dev Next app
7474
env:
7575
BLOB_READ_WRITE_TOKEN: ${{ secrets.BLOB_READ_WRITE_TOKEN }}
76+
CRON_SECRET: ${{ secrets.CRON_SECRET }}
7677
run: pnpm run build:dev
7778

7879
- name: Run Playwright E2E
7980
env:
81+
# Both needed for the env-gated roundtrip specs
82+
# (tests/e2e/backup-create-restore.spec.ts, tests/e2e/cron-trigger.spec.ts).
83+
# When either secret is missing, the relevant tests self-skip.
8084
BLOB_READ_WRITE_TOKEN: ${{ secrets.BLOB_READ_WRITE_TOKEN }}
85+
CRON_SECRET: ${{ secrets.CRON_SECRET }}
8186
run: pnpm run test:e2e
8287

8388
- name: Upload Playwright report

.gitignore

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,6 @@ dev/**/*.d.cts
4444
!dev/next-env.d.ts
4545
tests/**/*.d.ts
4646
src/**/*.d.ts
47-
!src/payload-config.d.ts
4847

4948
.env
5049

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -309,8 +309,8 @@ Versioning uses [Changesets](https://github.com/changesets/changesets), like [Pa
309309
- Additional storage adapters (S3, R2, filesystem, etc.).
310310
- Scheduler-agnostic display when not using Vercel (`vercel.json` is currently used for the schedule summary where available).
311311
- Streaming restore for very large databases.
312-
- More E2E coverage around a demo project.
313312
- Configurable `backups/` prefix or bucket layout.
313+
- add support for tenant plugin and partial backups per tenant
314314

315315
---
316316

src/components/BackupDashboard/index.tsx

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
11
import type { I18n } from '@payloadcms/translations'
2-
3-
import configPromise from '@payload-config'
4-
import { getPayload } from 'payload'
2+
import type { Payload } from 'payload'
53

64
import type { BackupPluginOptions } from '../../types'
75

@@ -18,6 +16,12 @@ import { BackupListCollapsible, BackupSettingsModal, ManualBackupDialog } from '
1816

1917
interface BackupDashboardProps {
2018
i18n: I18n
19+
/**
20+
* Payload instance injected by Payload's admin `RenderServerComponent` as a server prop
21+
* for `afterDashboard` components. Avoids importing `@payload-config` from the plugin,
22+
* which does not resolve when the plugin runs from `node_modules`.
23+
*/
24+
payload?: Payload
2125
/** Optional on admin server props; omitted does not mean logged out (see `defaultIsHidden`). */
2226
user?: null | Record<string, unknown>
2327
}
@@ -43,7 +47,7 @@ function defaultIsHidden(
4347
return !isUserAllowedByEnvRoles(user)
4448
}
4549

46-
export const BackupDashboard: React.FC<BackupDashboardProps> = async ({ i18n, user }) => {
50+
export const BackupDashboard: React.FC<BackupDashboardProps> = async ({ i18n, payload, user }) => {
4751
if (defaultIsHidden(user)) {
4852
return null
4953
}
@@ -69,7 +73,24 @@ export const BackupDashboard: React.FC<BackupDashboardProps> = async ({ i18n, us
6973
)
7074
}
7175

72-
const payload = await getPayload({ config: configPromise })
76+
if (!payload) {
77+
// Should not happen in a normal Payload admin render; guard for safety.
78+
return (
79+
<>
80+
<style dangerouslySetInnerHTML={{ __html: backupDashboardInlineCss }} />
81+
<div className="backup-dashboard">
82+
<h2>
83+
Backups <span className="experimental">(experimental)</span>
84+
</h2>
85+
<p className="backup-dashboard__setup-hint" role="status">
86+
Backup dashboard could not initialise: Payload instance was not provided by the admin
87+
server props.
88+
</p>
89+
</div>
90+
</>
91+
)
92+
}
93+
7394
const backupBlobToken = await resolveBackupListToken(payload)
7495
const hasBlobToken = backupBlobToken.trim().length > 0
7596

src/payload-config.d.ts

Lines changed: 0 additions & 10 deletions
This file was deleted.

tests/README.md

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -102,19 +102,32 @@ Newly covered P1 (must-have) user flows:
102102
- [x] **P1** — Backup settings modal opens, renders the schedule + retention + token sections with a mocked settings payload, and closes via Cancel (`tests/e2e/backup-settings-modal.spec.ts`).
103103
- [x] **P1** — Backup list Collapsible expands on click, opens the Filters dialog (label + date + media + source controls), and Done + Clear filters both work (`tests/e2e/backup-list-filters.spec.ts`).
104104

105-
Deferred (needs a real or mocked blob endpoint):
105+
Env-gated roundtrip coverage (run automatically when `BLOB_READ_WRITE_TOKEN` is set):
106+
107+
- [x] **P2** — Full manual backup create → restore roundtrip through the UI: open the
108+
Create manual Backup dialog, label it, wait for the `Done` status pill, assert the
109+
row lands in the backup list, open its Restore dialog, deselect auth-session
110+
collections, wait for `Done`, then clean up by clicking Delete and confirming. Skips
111+
automatically when no blob token is configured. _(new — `tests/e2e/backup-create-restore.spec.ts`)_
112+
- [x] **P2** — Cron API roundtrip: `GET /api/backup-mongodb/cron/run` with the
113+
`CRON_SECRET` bearer, poll `/cron/list` until the fresh `cron-` blob shows up,
114+
then open the admin dashboard and assert the new entry is rendered as a
115+
`Cron backup` row. Cleans up via `POST /admin/delete` to keep the bucket tidy.
116+
Skips when either `BLOB_READ_WRITE_TOKEN` or `CRON_SECRET` is missing.
117+
_(new — `tests/e2e/cron-trigger.spec.ts`)_
118+
119+
Still deferred (needs a real or mocked blob endpoint):
106120

107-
- [ ] **P2** — Manual backup happy path (submit → progress pill → "Backup created"); requires a mocked `@vercel/blob` layer in the dev server.
108121
- [ ] **P2** — Settings modal: re-validate a freshly typed token and see the 422 error for a rejected one (needs a fake blob store responding with known access levels).
109122
- [ ] **P2** — Backup list filters actually hide rows once seeded data exists (host / db / media / source radio groups).
110-
- [ ] **P2** — Restore preview + restore end-to-end (select archive → preview groups → restore → redirect to `/admin`).
111-
- [ ] **P2** — Backup item actions: download + delete confirmations wired to the mocked blob.
123+
- [ ] **P2** — Backup item actions: download flow (redirect to signed URL) wired to the mocked blob — the delete path is now covered by the roundtrip spec above.
112124
- [ ] **P3** — Visual-regression / snapshot coverage for the `BackupDashboard` inline CSS.
113-
- [ ] **P3** — Full cron backup against a mocked blob server (requires a local fake `@vercel/blob` shim).
114125

115126
> Note: the in-memory dev app intentionally runs without a `BLOB_READ_WRITE_TOKEN`, so any
116-
> test that exercises real blob I/O against the dev app must either mock the network layer
117-
> or set up a token in CI. Until then, the P2 blob-dependent flows above stay deferred.
127+
> test that exercises real blob I/O must set up a token either locally (via
128+
> `dev/.env.local`) or via the optional `BLOB_READ_WRITE_TOKEN` secret in CI. Tests that
129+
> need it `test.skip` themselves when the env is missing so the suite stays green in
130+
> either configuration.
118131
119132
---
120133

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import { expect, test } from '@playwright/test'
2+
3+
import { expandBackupList, openBackupDashboard, requireBlobToken, waitForTaskDone } from './helpers'
4+
5+
const MANUAL_DIALOG = 'dialog.backup-confirm-dialog--manual[open]'
6+
// One `<dialog>` is rendered per backup row — target the one that was just opened.
7+
const RESTORE_DIALOG = 'dialog.backup-confirm-dialog--restore[open]'
8+
// The delete confirm dialog has no `--variant` suffix. Use `:not()` to exclude the
9+
// variant dialogs and require `[open]` to pick the one associated with our row.
10+
const DELETE_DIALOG = 'dialog.backup-confirm-dialog:not([class*="--"])[open]'
11+
12+
// Skip this whole file when no real Vercel Blob store is configured — the roundtrip needs
13+
// a writable backend. CI only runs the happy path when the optional secret is present.
14+
test.describe('Backup create + restore roundtrip (dev app)', () => {
15+
test.beforeEach(() => {
16+
test.skip(
17+
!requireBlobToken(),
18+
'Needs BLOB_READ_WRITE_TOKEN to exercise the real @vercel/blob backend',
19+
)
20+
})
21+
22+
// The roundtrip touches the real blob store and includes dialog polling, so give it
23+
// plenty of time: create (mongo dump + upload) + restore (download + upsert).
24+
test.setTimeout(300_000)
25+
26+
test('create a manual backup via the UI and restore it from the backup list', async ({
27+
page,
28+
}) => {
29+
const label = `e2e-roundtrip-${Date.now()}`
30+
31+
await openBackupDashboard(page)
32+
33+
// ---- CREATE -----------------------------------------------------------------
34+
await page.getByRole('button', { name: /Create manual Backup/i }).click()
35+
const manualDialog = page.locator(MANUAL_DIALOG)
36+
await expect(manualDialog).toBeVisible()
37+
38+
// Wait for the backup preview to load (the Start backup button is disabled until
39+
// the phase transitions to `ready` or `error`).
40+
await expect(
41+
manualDialog.locator('.restore-preview__sticky-heading', {
42+
hasText: /Collection selection/i,
43+
}),
44+
).toBeVisible({ timeout: 30_000 })
45+
46+
await manualDialog.getByLabel('Optional backup label').fill(label)
47+
48+
// Keep every collection checked by default — in dev the `media` collection is empty
49+
// so `includeMedia` is implicitly `false` (mediaBlobCandidates=0) and the archive
50+
// stays as a small json.
51+
const startButton = manualDialog.getByRole('button', { name: /Start backup/i })
52+
await expect(startButton).toBeEnabled({ timeout: 30_000 })
53+
await startButton.click()
54+
55+
await waitForTaskDone(manualDialog, { timeout: 120_000 })
56+
// After success the dialog auto-closes within ~1s (router.refresh() + close).
57+
await expect(manualDialog).toBeHidden({ timeout: 10_000 })
58+
59+
// The list is server-rendered and refreshes after create. Expand it and locate
60+
// the row by its unique label pill.
61+
await expandBackupList(page)
62+
const createdRow = page
63+
.locator('.backup-item')
64+
.filter({ has: page.locator('.backup-item__pill--label', { hasText: label }) })
65+
await expect(createdRow).toHaveCount(1, { timeout: 30_000 })
66+
await expect(createdRow.locator('.backup-item__pill--manual')).toBeVisible()
67+
68+
// ---- RESTORE ----------------------------------------------------------------
69+
await createdRow.getByRole('button', { name: /^Restore$/ }).click()
70+
const restoreDialog = page.locator(RESTORE_DIALOG)
71+
await expect(restoreDialog).toBeVisible()
72+
73+
// Preview transitions from `loading` to `ready` once the blob is downloaded +
74+
// analysed. On a cold cache this can take 10–20s.
75+
await expect(
76+
restoreDialog.locator('.restore-preview__sticky-heading', {
77+
hasText: /Collection selection/i,
78+
}),
79+
).toBeVisible({ timeout: 60_000 })
80+
81+
// Deselect the auth-session collections so the restore keeps our session intact.
82+
// The checkbox `aria-label` is `Restore collection ${displayTitle}` where
83+
// `displayTitle` is `"<Label> (<slug>)"`, so we match by the slug suffix to stay
84+
// resilient against localisation differences.
85+
for (const slug of ['users', 'roles', 'payload-preferences']) {
86+
const cb = restoreDialog.locator(
87+
`input[type="checkbox"][aria-label*="(${slug})" i][aria-label^="Restore collection" i]`,
88+
)
89+
if ((await cb.count()) > 0 && (await cb.first().isChecked())) {
90+
await cb.first().click()
91+
await expect(cb.first()).not.toBeChecked()
92+
}
93+
}
94+
95+
await restoreDialog.getByRole('button', { name: /Yes, restore/i }).click()
96+
await waitForTaskDone(restoreDialog, { timeout: 180_000 })
97+
await expect(restoreDialog).toBeHidden({ timeout: 10_000 })
98+
99+
// ---- CLEANUP: delete the just-created backup via the UI ---------------------
100+
// Exercises the delete flow AND keeps the blob store tidy for subsequent runs.
101+
await expandBackupList(page)
102+
await expect(createdRow).toHaveCount(1, { timeout: 15_000 })
103+
await createdRow.getByRole('button', { name: /^Delete$/ }).click()
104+
105+
const deleteDialog = page.locator(DELETE_DIALOG)
106+
await expect(deleteDialog).toBeVisible()
107+
await deleteDialog.getByRole('button', { name: /Yes, delete/i }).click()
108+
await waitForTaskDone(deleteDialog, { timeout: 60_000 })
109+
110+
// `TaskActionButton` with `refreshOnComplete` triggers a router.refresh; wait for
111+
// the row to disappear from the list so the test leaves a clean slate.
112+
await expect(createdRow).toHaveCount(0, { timeout: 30_000 })
113+
})
114+
})

tests/e2e/backup-list-filters.spec.ts

Lines changed: 1 addition & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,9 @@
1-
import type { Page } from '@playwright/test'
2-
31
import { expect, test } from '@playwright/test'
42

5-
import { openBackupDashboard } from './helpers'
3+
import { expandBackupList, openBackupDashboard } from './helpers'
64

75
const FILTERS_DIALOG = 'dialog.backup-confirm-dialog--backup-list-filters'
86

9-
/**
10-
* Click the Collapsible's chevron button inside the Backup dashboard. Payload's
11-
* `Collapsible` only wires `toggleCollapsible` onto `.collapsible__toggle`, not the
12-
* whole header, so clicking the title text would not be enough.
13-
*/
14-
async function expandBackupList(page: Page): Promise<void> {
15-
const toggle = page.locator('.backup-dashboard .collapsible__toggle').first()
16-
await toggle.click()
17-
await expect(page.locator('.backup-dashboard .collapsible--collapsed')).toHaveCount(0)
18-
}
19-
207
test.describe('Backup list filters (dev app)', () => {
218
test('Collapsible expands on click', async ({ page }) => {
229
await openBackupDashboard(page)

0 commit comments

Comments
 (0)