diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3d362178d..5275ff0d0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -72,8 +72,7 @@ jobs: - name: Test run: bun test -# NOTE: The Playwright E2E suite is intentionally NOT run in CI yet. As -# configured (`workers: 1`, fully serial, 156 tests) it takes ~45-60 min and is -# timeout-fragile against the un-optimized dev server, so it cannot gate PRs -# reliably today. Run it locally with `bun run test:e2e`. It will be reintroduced -# as its own workflow once the suite is sharded/parallelized and stabilized. +# The Playwright E2E suite runs in its own workflow (.github/workflows/e2e.yml), +# sharded four ways so each shard is a few minutes rather than one serial hour. +# It is kept separate from this workflow so a slow browser run never delays the +# build/lint/test feedback a PR needs first. diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 000000000..e67a1c88b --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,62 @@ +name: E2E + +on: + pull_request: + branches: + - main + push: + branches: + - main + +permissions: + contents: read + +concurrency: + group: e2e-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + e2e: + name: Playwright (shard ${{ matrix.shard }}/4) + runs-on: ubuntu-latest + # A shard is ~4-6 min of tests plus the per-shard setup projects; the cap is + # slack for a slow runner, not a target. + timeout-minutes: 30 + strategy: + # One red shard should not hide the state of the other three. + fail-fast: false + matrix: + shard: [1, 2, 3, 4] + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.11 + + - name: Install dependencies + run: bun install --frozen-lockfile + + # Chromium only — the suite pins one browser (see playwright.config.ts). + - name: Install Playwright browser + run: bunx playwright install --with-deps chromium + + # Each shard boots its own `e2e:serve` stack on its own disposable SQLite + # database, so the shards share no state and the setup projects + # (owner, dashboard preflight, personas) run once per shard. That command + # builds the SPA and serves it from the Bun process -- no Vite dev + # server, which hangs on these runners. + - name: Run E2E + run: bunx playwright test --shard=${{ matrix.shard }}/4 + + - name: Upload report + if: failure() + uses: actions/upload-artifact@v4 + with: + name: playwright-report-shard-${{ matrix.shard }} + path: | + .tmp/playwright-report + .tmp/playwright-results + retention-days: 7 diff --git a/docs/e2e/README.md b/docs/e2e/README.md index 21c512ca0..5e5ca0ab2 100644 --- a/docs/e2e/README.md +++ b/docs/e2e/README.md @@ -42,27 +42,29 @@ bun run test:e2e The Playwright config starts a disposable local stack by default: -- Admin UI: `http://127.0.0.1:5174` -- CMS/public site: `http://127.0.0.1:3002` +- Admin UI **and** public site: `http://127.0.0.1:3002` - Database: `.tmp/e2e-agent.db` - Uploads: `.tmp/e2e-uploads` -`scripts/e2e-dev.ts` resets only those `.tmp/e2e-*` paths, then runs the same -Vite + Bun CMS stack a developer uses — with one deliberate difference: the CMS -runs **without** `bun --watch`. A regression suite needs a stable server, and -under watch the publish pipeline writing baked HTML (and the SQLite DB churning) -can reload the server mid-test and drop in-memory state. Vite is likewise told to -ignore the runtime-written paths (`.tmp`, `uploads`, `dist` in `vite.config.ts`), -so publishing never reloads the admin app mid-test. The Vite dev proxy follows -the configured CMS `PORT`, keeping the Playwright admin UI pointed at the -disposable CMS instead of any regular dev server on port 3001. +`scripts/e2e-server.ts` resets only those `.tmp/e2e-*` paths, builds the admin +SPA, and serves it from the same Bun process that serves the published site. +That is **not** the `bun run dev` stack, on purpose: -When Vite itself runs on Bun, its Node-compatible native proxy can stop -draining multi-megabyte request bodies after socket backpressure fills both -sides. `largeBodyDevProxyPlugin` intercepts only known-length CMS API bodies of -at least 1 MiB, buffers them with the same 128 MiB ceiling as `Bun.serve`, and -forwards them with an explicit `Content-Length`. Small requests, non-CMS -traffic, and streaming AI responses remain on Vite's native proxy. +- **No Vite.** The suite exercises the bundle users actually get. It also keeps + the suite off the Vite dev server running inside Bun, which is fragile enough + to carry its own warnings in `vite.config.ts` and which hangs outright on + Linux CI runners. `vite build` is a batch step and runs there fine — it is + the long-lived dev server that does not. +- **No `--watch`.** A regression suite needs a stable server: under watch, the + publish pipeline writing baked HTML (and the SQLite DB churning) reloads the + server mid-test and drops in-memory state. +- **One origin.** Admin and the public site share a port, so no two base URLs + can drift apart. Anonymous visitor checks stay honest because + `visitPublicPage` opens a fresh browser context, and the session cookie is + scoped to `Path=/admin` so it never rides along on a public request. + +Because the command builds before it serves, the `webServer` start budget is +five minutes rather than the usual two. For debugging against a server you started yourself, set `E2E_REUSE_SERVER=1` and override `E2E_ADMIN_BASE_URL` / @@ -106,6 +108,12 @@ first retry automatically. homepage), clean-install dashboard coverage runs before shared mutations, account-global security changes stay on the account persona, fixture-owned AI defaults are removed, and publish→assert happens within a single test. +- **Template rule.** A spec that publishes a Posts template removes it before + it ends (`deleteTemplate` in `helpers/editor.ts`). The template chain keeps + one template per breadth level — highest priority, then document order — so a + template left behind decides which template every later spec's entry route + renders through. Sharding hides this by splitting specs across databases; a + full single-process run does not, and that is the run that must be green. ### Automated coverage map diff --git a/package.json b/package.json index 9fee8320d..8b0289240 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "dev:agent": "bun run dev:server", "dev:server": "bun --watch server/index.ts", "dev:vite": "bun run scripts/vite.ts", - "e2e:dev": "bun run scripts/e2e-dev.ts", + "e2e:serve": "bun run scripts/e2e-server.ts", "db:drop": "bun run scripts/db-drop.ts", "start": "bun run scripts/start.ts", "docker:up": "docker compose -f compose.prod.yml -f compose.build.yml up --build", diff --git a/playwright.config.ts b/playwright.config.ts index c63068bb0..507ffc7af 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,8 +1,11 @@ import { defineConfig } from '@playwright/test' import { OWNER_STATE_FILE } from './tests/e2e/helpers/constants' -const ADMIN_BASE_URL = process.env.E2E_ADMIN_BASE_URL ?? 'http://127.0.0.1:5174' -process.env.E2E_PUBLIC_BASE_URL ??= 'http://127.0.0.1:3002' +// One origin. The suite runs against the built SPA served by the same Bun +// process that serves the published site (`scripts/e2e-server.ts`), so the +// admin and the public site share a base URL. +const ADMIN_BASE_URL = process.env.E2E_ADMIN_BASE_URL ?? 'http://127.0.0.1:3002' +process.env.E2E_PUBLIC_BASE_URL ??= ADMIN_BASE_URL const LOCAL_TRACE = process.env.E2E_TRACE === '1' const LOCAL_VIDEO = process.env.E2E_VIDEO === '1' @@ -16,6 +19,9 @@ export default defineConfig({ expect: { timeout: 10_000, }, + // A stray `.only` would otherwise green the whole suite in CI by running + // one test. + forbidOnly: Boolean(process.env.CI), retries: process.env.CI ? 1 : 0, reporter: [ ['list'], @@ -61,10 +67,12 @@ export default defineConfig({ }, ], webServer: { - command: 'bun run e2e:dev', + command: 'bun run e2e:serve', url: ADMIN_BASE_URL, reuseExistingServer: process.env.E2E_REUSE_SERVER === '1', - timeout: 120_000, + // The command builds the SPA before it serves it, so this budget covers + // `tsc -b && vite build` on a cold runner, not just process startup. + timeout: 300_000, gracefulShutdown: { signal: 'SIGTERM', timeout: 500 }, stdout: 'pipe', stderr: 'pipe', diff --git a/scripts/e2e-dev.ts b/scripts/e2e-dev.ts deleted file mode 100644 index 770c47ecb..000000000 --- a/scripts/e2e-dev.ts +++ /dev/null @@ -1,75 +0,0 @@ -/** - * Disposable local server for automated browser E2E tests. - * - * This wrapper owns only the `.tmp/e2e-*` data used by Playwright. It resets - * that data, then runs the same Vite + Bun CMS stack a developer uses — with one - * deliberate difference: the CMS runs WITHOUT `--watch`. - * - * Why no watch: under `bun --watch`, the publish pipeline writing baked HTML into - * the uploads dir (and the SQLite DB churning) can trigger a server reload mid - * test, which drops in-memory state and tears the stack down. A regression suite - * needs a stable server, so E2E pins one. Vite is additionally told to ignore the - * runtime-written paths (see `vite.config.ts`), so publishing never reloads the - * admin app mid-test either. - */ -import { mkdir, rm } from 'node:fs/promises' -import { bunCommand, viteCommand } from './lib/bunCommand' -import { ensureDependencies } from './lib/ensureDependencies' - -const DATABASE_PATH = './.tmp/e2e-agent.db' -const UPLOADS_DIR = './.tmp/e2e-uploads' -const CMS_PORT = process.env.E2E_CMS_PORT ?? '3002' -const VITE_PORT = process.env.E2E_VITE_PORT ?? '5174' - -// Same guard as `bun run dev`: Playwright starts this stack right after a -// `git pull`, and a stale node_modules would otherwise surface as a CMS crash -// on its first import instead of a missing install. -await ensureDependencies((msg) => console.error(`[e2e-dev] ${msg}`)) - -await mkdir('./.tmp', { recursive: true }) -await rm(DATABASE_PATH, { force: true }) -await rm(`${DATABASE_PATH}-shm`, { force: true }) -await rm(`${DATABASE_PATH}-wal`, { force: true }) -await rm(UPLOADS_DIR, { force: true, recursive: true }) - -// Shared by both children: the CMS port drives the Vite dev proxy target, so the -// admin UI talks to this disposable CMS instead of any regular dev server. -const sharedEnv = { - ...process.env, - PORT: CMS_PORT, - DATABASE_URL: `sqlite:${DATABASE_PATH}`, - UPLOADS_DIR, -} - -const children: Bun.Subprocess[] = [] -let shuttingDown = false - -function stopChildren(signal: NodeJS.Signals = 'SIGTERM'): void { - shuttingDown = true - for (const child of children) { - if (child.exitCode === null) child.kill(signal) - } -} - -for (const command of [ - bunCommand('server/index.ts'), - viteCommand('--host', '127.0.0.1', '--port', VITE_PORT, '--strictPort'), -]) { - const child = Bun.spawn(command, { - env: sharedEnv, - stdin: 'inherit', - stdout: 'inherit', - stderr: 'inherit', - }) - children.push(child) - void child.exited.then((code) => { - if (shuttingDown) return - // One half of the stack died on its own — bring the other down and exit so - // Playwright sees the failure instead of half a stack. - stopChildren() - process.exit(code ?? 1) - }) -} - -process.on('SIGINT', () => stopChildren('SIGINT')) -process.on('SIGTERM', () => stopChildren('SIGTERM')) diff --git a/scripts/e2e-server.ts b/scripts/e2e-server.ts new file mode 100644 index 000000000..36ee9d1f8 --- /dev/null +++ b/scripts/e2e-server.ts @@ -0,0 +1,90 @@ +/** + * Disposable server for the Playwright suite. + * + * Builds the admin SPA, then serves it from the same Bun process that serves + * the published site — ONE origin, exactly the shape a self-hosted install + * runs in. That is deliberately not the `bun run dev` stack: + * + * - **No Vite.** The suite exercises the bundle users actually get, not a + * dev server's on-the-fly transforms. It also removes a dependency on the + * Vite dev server surviving inside Bun, which is fragile enough that the + * dev proxy already carries its own warning (see `vite.config.ts`) and + * which hangs outright on Linux CI runners. `vite build` is a batch step + * and runs fine there — it is the long-lived dev server that does not. + * - **No `--watch`.** The publish pipeline writes baked HTML into the uploads + * dir and churns the SQLite DB; under watch that reloads the server mid + * test and drops in-memory state. + * - **One origin.** Admin and the public site share a port, so nothing has to + * keep two base URLs in step. Anonymous visitor checks stay honest because + * they open a fresh browser context (`visitPublicPage`), and the admin + * session cookie is scoped to `Path=/admin` so it never rides along on a + * public request anyway. + * + * This wrapper owns only the `.tmp/e2e-*` data, which it resets on every run. + */ +import { mkdir, rm } from 'node:fs/promises' +import { bunCommand, bunRunCommand } from './lib/bunCommand' +import { ensureDependencies } from './lib/ensureDependencies' + +const DATABASE_PATH = './.tmp/e2e-agent.db' +const UPLOADS_DIR = './.tmp/e2e-uploads' +const PORT = process.env.E2E_CMS_PORT ?? '3002' + +function log(msg: string): void { + console.error(`[e2e-server] ${msg}`) +} + +// Same guard as `bun run dev`: Playwright starts this stack right after a +// `git pull`, and a stale node_modules would otherwise surface as a crash on +// the first import instead of a missing install. +await ensureDependencies((msg) => log(msg)) + +await mkdir('./.tmp', { recursive: true }) +await rm(DATABASE_PATH, { force: true }) +await rm(`${DATABASE_PATH}-shm`, { force: true }) +await rm(`${DATABASE_PATH}-wal`, { force: true }) +await rm(UPLOADS_DIR, { force: true, recursive: true }) + +// `bun run build` is `tsc -b && vite build`. Building here rather than in a +// separate CI step keeps one path: whatever a developer runs locally is what +// the runner runs. +log('building the admin SPA (bun run build)') +const build = Bun.spawnSync(bunRunCommand('build'), { stdout: 'inherit', stderr: 'inherit' }) +if (build.exitCode !== 0) { + log(`build failed (exit ${build.exitCode})`) + process.exit(build.exitCode ?? 1) +} + +const origin = `http://127.0.0.1:${PORT}` +log(`starting the CMS on ${origin}`) + +const server = Bun.spawn(bunCommand('server/index.ts'), { + env: { + ...process.env, + PORT, + HOST: '127.0.0.1', + DATABASE_URL: `sqlite:${DATABASE_PATH}`, + UPLOADS_DIR, + STATIC_DIR: './dist', + // Pin the CSRF origin to the one the suite drives, so the check compares a + // configured value instead of falling back to the inbound Host header. + PUBLIC_ORIGIN: origin, + }, + stdin: 'inherit', + stdout: 'inherit', + stderr: 'inherit', +}) + +let shuttingDown = false + +function stopServer(signal: NodeJS.Signals = 'SIGTERM'): void { + shuttingDown = true + if (server.exitCode === null) server.kill(signal) +} + +process.on('SIGINT', () => stopServer('SIGINT')) +process.on('SIGTERM', () => stopServer('SIGTERM')) + +const code = await server.exited +if (!shuttingDown) log(`server exited with code ${code}`) +process.exit(code ?? 0) diff --git a/src/__tests__/devWorkflow.test.ts b/src/__tests__/devWorkflow.test.ts index 7c2327fc0..9df82c7eb 100644 --- a/src/__tests__/devWorkflow.test.ts +++ b/src/__tests__/devWorkflow.test.ts @@ -45,7 +45,7 @@ describe('development workflow', () => { it('development launchers route local Vite binaries through Bun on Windows', () => { const devScript = readSiteFile('scripts/dev.ts') - const e2eScript = readSiteFile('scripts/e2e-dev.ts') + const e2eScript = readSiteFile('scripts/e2e-server.ts') const startScript = readSiteFile('scripts/start.ts') const viteScript = readSiteFile('scripts/vite.ts') @@ -65,9 +65,13 @@ describe('development workflow', () => { expect(devScript).not.toContain("bunRunCommand('vite'") expect(devScript).not.toContain('command: `vite') expect(devScript).not.toContain('command.split') - expect(e2eScript).toContain("viteCommand('--host', '127.0.0.1'") - expect(e2eScript).not.toContain("bunRunCommand('dev:vite'") - expect(e2eScript).not.toContain("bunRunCommand('vite'") + // The E2E stack deliberately runs NO Vite dev server: it builds the SPA + // and serves it from the Bun process, so the suite exercises the shipped + // bundle and never depends on the dev server surviving inside Bun. + expect(e2eScript).toContain("bunRunCommand('build')") + expect(e2eScript).toContain("bunCommand('server/index.ts')") + expect(e2eScript).toContain("STATIC_DIR: './dist'") + expect(e2eScript).not.toContain('viteCommand') expect(e2eScript).not.toContain("['vite'") expect(e2eScript).not.toContain("['bun'") expect(viteScript).toContain('viteCommand(...Bun.argv.slice(2))') diff --git a/tests/e2e/account.e2e.ts b/tests/e2e/account.e2e.ts index f8f216c11..321e67e0d 100644 --- a/tests/e2e/account.e2e.ts +++ b/tests/e2e/account.e2e.ts @@ -2,6 +2,7 @@ import { createHmac } from 'node:crypto' import { expect, test, type Locator, type Page } from '@playwright/test' import { ACCOUNT_PERSONA, + ADMIN_BASE_URL, ANONYMOUS_STATE, OWNER, completeStepUp as runStepUp, @@ -36,7 +37,6 @@ const TEXT_AVATAR = Buffer.from('not an image', 'utf8') const OVERSIZED_AVATAR = Buffer.alloc(5 * 1024 * 1024 + 1) const BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567' -const ADMIN_BASE_URL = process.env.E2E_ADMIN_BASE_URL ?? 'http://127.0.0.1:5174' function decodeBase32(secret: string): Buffer { let bits = '' diff --git a/tests/e2e/content-seo-meta.e2e.ts b/tests/e2e/content-seo-meta.e2e.ts index f533802b2..99ff0a801 100644 --- a/tests/e2e/content-seo-meta.e2e.ts +++ b/tests/e2e/content-seo-meta.e2e.ts @@ -3,6 +3,7 @@ import { ANONYMOUS_STATE, OWNER, PUBLIC_BASE_URL, + deleteTemplate, insertModuleViaPicker, insertNotchModule, login, @@ -120,5 +121,12 @@ test.describe('entry SEO meta', () => { await context.close() } }) + + // Priority 300 outranks every other Posts template the suite publishes; + // left behind, it would render every later spec's entry route. + await test.step('remove the template so later specs resolve their own', async () => { + await openSiteEditor(page) + await deleteTemplate(page, `SEO Meta Template ${suffix}`) + }) }) }) diff --git a/tests/e2e/content.e2e.ts b/tests/e2e/content.e2e.ts index 2dd5285d7..3dbdfcbf5 100644 --- a/tests/e2e/content.e2e.ts +++ b/tests/e2e/content.e2e.ts @@ -4,6 +4,7 @@ import { OWNER, canvasFrame, completeStepUp, + deleteTemplate, insertModuleViaPicker, insertNotchModule, login, @@ -145,6 +146,11 @@ test.describe('content', () => { liveFrame.getByLabel('Post body (live preview)'), ).toBeVisible() }) + + await test.step('remove the template so later specs resolve their own', async () => { + await openSiteEditor(page) + await deleteTemplate(page, `E2E Posts Template ${suffix}`) + }) }) }) @@ -287,6 +293,11 @@ test.describe('content', () => { }, }) }) + + await test.step('remove the template so later specs resolve their own', async () => { + await openSiteEditor(page) + await deleteTemplate(page, `E2E Posts Template ${suffix}`) + }) }) }) @@ -436,6 +447,11 @@ test.describe('content', () => { hiddenText: [tokenText, `{currentEntry.${fieldId}}`, templateName], }) }) + + await test.step('remove the template so later specs resolve their own', async () => { + await openSiteEditor(page) + await deleteTemplate(page, templateName) + }) }) }) }) diff --git a/tests/e2e/helpers/constants.ts b/tests/e2e/helpers/constants.ts index acafa923d..f7a794414 100644 --- a/tests/e2e/helpers/constants.ts +++ b/tests/e2e/helpers/constants.ts @@ -1,11 +1,12 @@ /** * Shared constants for the automated Playwright E2E suite. * - * The Playwright `webServer` (`scripts/e2e-dev.ts`) resets the disposable - * `.tmp/e2e-*` database once per run and then serves a single shared stack: - * one admin origin, one public origin, one SQLite database. Every spec runs - * serially against that shared state (`workers: 1`), so these constants are the - * single source of truth for the suite identities and origins. + * The Playwright `webServer` (`scripts/e2e-server.ts`) resets the disposable + * `.tmp/e2e-*` database once per run, builds the admin SPA, and serves it from + * the same Bun process that serves the published site: one origin, one SQLite + * database. Every spec runs serially against that shared state (`workers: 1`), + * so these constants are the single source of truth for the suite identities + * and origins. */ /** First-run owner created by the `setup` project and reused by ordinary specs. */ @@ -15,9 +16,19 @@ export const OWNER = { siteName: 'Automated E2E Site', } as const -/** Public (visitor-facing) origin. Different port → always a fresh context. */ -export const PUBLIC_BASE_URL = - process.env.E2E_PUBLIC_BASE_URL ?? 'http://127.0.0.1:3002' +/** Admin origin. Also the public origin — the stack serves both from one port. */ +export const ADMIN_BASE_URL = + process.env.E2E_ADMIN_BASE_URL ?? 'http://127.0.0.1:3002' + +/** + * Public (visitor-facing) origin — the same origin as the admin. + * + * What keeps a visitor check honest is the fresh browser context + * `visitPublicPage` opens, not a separate port: the admin session cookie is + * scoped to `Path=/admin`, so it never accompanies a public request even on a + * shared origin. + */ +export const PUBLIC_BASE_URL = process.env.E2E_PUBLIC_BASE_URL ?? ADMIN_BASE_URL /** * Saved owner authentication state. The `setup` project writes this after diff --git a/tests/e2e/helpers/editor.ts b/tests/e2e/helpers/editor.ts index 16356f3f7..e868bdbd6 100644 --- a/tests/e2e/helpers/editor.ts +++ b/tests/e2e/helpers/editor.ts @@ -166,6 +166,30 @@ export async function createPage( ).toBeVisible() } +/** + * Delete a template from the Site Explorer by its title, confirming the + * prompt. Assumes the Site editor is open. + * + * Every spec that publishes a Posts template MUST call this before it ends. + * The suite shares one database, and the template chain keeps at most one + * template per breadth level (highest priority, then document order), so a + * template left behind decides which template every later spec's entry route + * renders through. One leaked priority-300 template is enough to make an + * unrelated spec's public-route assertion fail depending on run order. + */ +export async function deleteTemplate(page: Page, name: string): Promise { + await openSitePanel(page) + const item = page.getByRole('treeitem', { name: `Open template ${name}` }) + await expect(item).toBeVisible() + await item.click({ button: 'right' }) + await page.getByRole('menuitem', { name: 'Delete' }).click() + // Templates are pages, so the explorer's page prompt is the one that opens. + const dialog = page.getByRole('alertdialog', { name: 'Delete page?' }) + await expect(dialog).toBeVisible() + await dialog.getByRole('button', { name: 'Delete page' }).click() + await expect(item).toHaveCount(0) +} + /** * Publish the current draft. Publishing is a sensitive action that may require * a fresh-password step-up; this satisfies the prompt with the owner password diff --git a/tests/e2e/visual-builder.e2e.ts b/tests/e2e/visual-builder.e2e.ts index 1713c3399..14be72e19 100644 --- a/tests/e2e/visual-builder.e2e.ts +++ b/tests/e2e/visual-builder.e2e.ts @@ -5,6 +5,7 @@ import { canvasFrameForBreakpoint, completeStepUp, createPage, + deleteTemplate, insertModuleViaPicker, insertNotchModule, login, @@ -562,6 +563,11 @@ test.describe('visual builder', () => { visibleText: ['Template headline:', postTitle, bodyText], hiddenText: ['Example Post Title', '{currentEntry.title}', templateName], }) + + await test.step('remove the template so later specs resolve their own', async () => { + await openSiteEditor(page) + await deleteTemplate(page, templateName) + }) }) test('saves, inserts, renames, deletes, and publishes a layout (SITE-019)', async ({