diff --git a/.circleci/config.yml b/.circleci/config.yml index 9db980ff91b..81502300d7b 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -178,7 +178,7 @@ jobs: -w /tests \ -e PLAYWRIGHT_TEST_BASE_URL=http://172.17.0.1:3000/ \ mcr.microsoft.com/playwright:v1.23.1-focal \ - yarn e2e:smoke + yarn test:integration docker-compose -f ./docker/compose/docker-compose.yml down # push the image diff --git a/package.json b/package.json index cc0eeb538c7..5b5ed52ddfa 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "release:docker": "dotenv -- node ./scripts/releaseDocker.mjs", "release:changelog": "dotenv -- node ./scripts/releaseChangelog.js --repo mui-toolpad", "test:build": "lerna run build --scope @mui/toolpad-core --scope @mui/toolpad-components --stream", + "test:integration": "playwright test --config ./test/integration/playwright.config.ts", "e2e:smoke": "playwright test --config ./test/e2e-smoke/playwright.config.ts", "test": "yarn test:build && jest" }, diff --git a/packages/toolpad-app/src/toolpad/Home.tsx b/packages/toolpad-app/src/toolpad/Home.tsx index 69b8c0a0f04..20daa1bde3b 100644 --- a/packages/toolpad-app/src/toolpad/Home.tsx +++ b/packages/toolpad-app/src/toolpad/Home.tsx @@ -44,6 +44,7 @@ import ToolpadShell from './ToolpadShell'; import getReadableDuration from '../utils/readableDuration'; import EditableText from '../components/EditableText'; import type { AppMeta } from '../server/data'; +import useMenu from '../utils/useMenu'; export interface CreateAppDialogProps { open: boolean; @@ -264,64 +265,43 @@ function AppOpenButton({ app, activeDeployment }: AppOpenButtonProps) { } interface AppOptionsProps { - menuOpen: boolean; - onClick: (event: React.MouseEvent) => void; + onRename: () => void; + onDelete?: () => void; } -function AppOptions({ menuOpen, onClick }: AppOptionsProps) { - return ( - - - - ); -} +function AppOptions({ onRename, onDelete }: AppOptionsProps) { + const { buttonProps, menuProps, onMenuClose } = useMenu(); -interface AppMenuProps { - menuAnchorEl: HTMLElement | null; - menuOpen: boolean; - handleMenuClose: () => void; - handleRenameClick: () => void; - handleDeleteClick: () => void; -} + const handleRenameClick = React.useCallback(() => { + onMenuClose(); + onRename(); + }, [onMenuClose, onRename]); + + const handleDeleteClick = React.useCallback(() => { + onMenuClose(); + onDelete?.(); + }, [onDelete, onMenuClose]); -function AppMenu({ - menuAnchorEl, - menuOpen, - handleMenuClose, - handleRenameClick, - handleDeleteClick, -}: AppMenuProps) { return ( - - {/* Using an onClick on a MenuItem causes accessibility issues, see: https://github.com/mui/material-ui/pull/30145 */} - - - - - Rename - - - - - - Delete - - + + + + + + + + + + Rename + + + + + + Delete + + + ); } @@ -332,31 +312,12 @@ interface AppCardProps { } function AppCard({ app, activeDeployment, onDelete }: AppCardProps) { - const [menuAnchorEl, setMenuAnchorEl] = React.useState(null); const [editingName, setEditingName] = React.useState(false); - const menuOpen = Boolean(menuAnchorEl); - - const handleOptionsClick = (event: React.MouseEvent) => { - setMenuAnchorEl(event.currentTarget); - }; - - const handleMenuClose = React.useCallback(() => { - setMenuAnchorEl(null); - }, []); - - const handleRenameClick = React.useCallback(() => { - setMenuAnchorEl(null); + const handleRename = React.useCallback(() => { setEditingName(true); }, []); - const handleDeleteClick = React.useCallback(() => { - setMenuAnchorEl(null); - if (onDelete) { - onDelete(); - } - }, [onDelete]); - return ( } + action={} disableTypography subheader={ @@ -396,13 +357,6 @@ function AppCard({ app, activeDeployment, onDelete }: AppCardProps) { - ); } @@ -414,32 +368,12 @@ interface AppRowProps { } function AppRow({ app, activeDeployment, onDelete }: AppRowProps) { - const [menuAnchorEl, setMenuAnchorEl] = React.useState(null); - - const menuOpen = Boolean(menuAnchorEl); - const [editingName, setEditingName] = React.useState(false); - const handleOptionsClick = (event: React.MouseEvent) => { - setMenuAnchorEl(event.currentTarget); - }; - - const handleMenuClose = React.useCallback(() => { - setMenuAnchorEl(null); - }, []); - - const handleRenameClick = React.useCallback(() => { - setMenuAnchorEl(null); + const handleRename = React.useCallback(() => { setEditingName(true); }, []); - const handleDeleteClick = React.useCallback(() => { - setMenuAnchorEl(null); - if (onDelete) { - onDelete(); - } - }, [onDelete]); - return ( @@ -458,17 +392,10 @@ function AppRow({ app, activeDeployment, onDelete }: AppRowProps) { - + - ); } diff --git a/test/integration/appRename.spec.ts b/test/integration/appRename.spec.ts new file mode 100644 index 00000000000..fd6c8c2f9f2 --- /dev/null +++ b/test/integration/appRename.spec.ts @@ -0,0 +1,40 @@ +import { test, expect, Request, Page } from '@playwright/test'; +import generateId from '../utils/generateId'; +import * as locators from '../utils/locators'; + +async function createApp(page: Page, name: string) { + await page.locator('button:has-text("create new")').click(); + + await page.fill('[role="dialog"] label:has-text("name")', name); + + await page.click('[role="dialog"] button:has-text("create")'); + + await page.waitForNavigation({ url: /\/_toolpad\/app\/[^/]+\/editor\/pages\/[^/]+/ }); +} + +test('app create/rename flow', async ({ page }) => { + const appName1 = `App ${generateId()}`; + const appName2 = `App ${generateId()}`; + const appName3 = `App ${generateId()}`; + + await page.goto('/'); + await createApp(page, appName1); + + await page.goto('/'); + await createApp(page, appName2); + + await page.goto('/'); + + await page.click(`${locators.toolpadHomeAppRow(appName1)} >> [aria-label="Application menu"]`); + + await page.click('[role="menuitem"]:has-text("Rename"):visible'); + + await page.keyboard.type(appName2); + await page.keyboard.press('Enter'); + + await expect(page.locator(`text=An app named "${appName2}" already exists`)).toBeVisible(); + + await page.keyboard.type(appName3); + + await expect(page.locator(locators.toolpadHomeAppRow(appName3))).toBeVisible(); +}); diff --git a/test/e2e-smoke/playwright.config.ts b/test/integration/playwright.config.ts similarity index 84% rename from test/e2e-smoke/playwright.config.ts rename to test/integration/playwright.config.ts index 192c11796f5..a63bd969685 100644 --- a/test/e2e-smoke/playwright.config.ts +++ b/test/integration/playwright.config.ts @@ -7,6 +7,8 @@ const config: PlaywrightTestConfig = { trace: 'on-first-retry', baseURL: process.env.PLAYWRIGHT_TEST_BASE_URL || 'http://localhost:3000/', }, + globalSetup: '../playwright/global-setup', + globalTeardown: '../playwright/global-teardown', projects: [ { name: 'chromium', diff --git a/test/e2e-smoke/basic.spec.ts b/test/integration/smoke.spec.ts similarity index 78% rename from test/e2e-smoke/basic.spec.ts rename to test/integration/smoke.spec.ts index 1bdda5e1861..020dae586de 100644 --- a/test/e2e-smoke/basic.spec.ts +++ b/test/integration/smoke.spec.ts @@ -1,7 +1,9 @@ import { test, expect, Request } from '@playwright/test'; +import generateId from '../utils/generateId'; +import * as locators from '../utils/locators'; test('basic app creation flow', async ({ page }) => { - const appName = `App ${String(Math.random()).slice(2)}`; + const appName = `App ${generateId()}`; await page.goto('/'); const brand = page.locator('data-test-id=brand'); @@ -19,9 +21,7 @@ test('basic app creation flow', async ({ page }) => { await page.click('[aria-label="Home"]'); - const appRowSelector = `[role="row"] >> has="input[value='${appName}']"`; - - await page.click(`${appRowSelector} >> [aria-label="settings"]`); + await page.click(`${locators.toolpadHomeAppRow(appName)} >> [aria-label="Application menu"]`); await page.click('[role="menuitem"]:has-text("Delete"):visible'); @@ -37,7 +37,7 @@ test('basic app creation flow', async ({ page }) => { `[role="dialog"]:has-text('Are you sure you want to delete application "${appName}"') >> button:has-text("delete")`, ); - await page.waitForSelector(appRowSelector, { state: 'detached' }); + await page.waitForSelector(locators.toolpadHomeAppRow(appName), { state: 'detached' }); await page.off('request', handleRequest); diff --git a/test/playwright/global-setup.ts b/test/playwright/global-setup.ts new file mode 100644 index 00000000000..4c01465d8d0 --- /dev/null +++ b/test/playwright/global-setup.ts @@ -0,0 +1,18 @@ +import * as devDb from '../utils/devDb'; + +async function globalSetup() { + // eslint-disable-next-line no-underscore-dangle + (globalThis as any).__toolpadTestDbDump = null; + + if (!(await devDb.isRunning())) { + return; + } + + // eslint-disable-next-line no-console + console.log('Creating a backup of the dev database'); + + // eslint-disable-next-line no-underscore-dangle + (globalThis as any).__toolpadTestDbDump = await devDb.dump(); +} + +export default globalSetup; diff --git a/test/playwright/global-teardown.ts b/test/playwright/global-teardown.ts new file mode 100644 index 00000000000..2658aeafcd8 --- /dev/null +++ b/test/playwright/global-teardown.ts @@ -0,0 +1,17 @@ +import * as devDb from '../utils/devDb'; + +async function globalTeardown() { + // eslint-disable-next-line no-underscore-dangle + const pgDump = (globalThis as any).__toolpadTestDbDump; + + if (pgDump) { + // eslint-disable-next-line no-console + console.log('Restoring the dev database'); + + await devDb.restore(pgDump); + } else if (pgDump !== null) { + throw new Error(`global-setup didn't run correctly`); + } +} + +export default globalTeardown; diff --git a/test/utils/devDb.ts b/test/utils/devDb.ts new file mode 100644 index 00000000000..3ee639237d0 --- /dev/null +++ b/test/utils/devDb.ts @@ -0,0 +1,62 @@ +import * as path from 'path'; +import { pipeline } from 'stream/promises'; +import { Readable } from 'stream'; +import childProcess from 'child_process'; +import { promisify } from 'util'; + +const exec = promisify(childProcess.exec); + +const DEV_COMPOSE_FILE = path.resolve(__dirname, '../../docker-compose.dev.yml'); + +/** + * Test whether the dev database is running + */ +export async function isRunning(): Promise { + try { + const { stdout } = await exec( + `docker compose -f=${DEV_COMPOSE_FILE} ps --format=json postgres`, + ); + const [service] = JSON.parse(stdout); + return service?.State === 'running'; + } catch { + return false; + } +} + +/** + * Backup the dev database + */ +export async function dump(): Promise { + const proc = exec( + `docker compose -f=${DEV_COMPOSE_FILE} exec postgres pg_dump -U postgres --format t`, + { encoding: 'buffer' }, + ); + + if (!proc.child.stdout) { + throw new Error(`childprocess was spawned with stdio[1] !== 'pipe'`); + } + + const buffers = []; + for await (const data of proc.child.stdout) { + buffers.push(data); + } + + await proc; + + return Buffer.concat(buffers); +} + +/** + * Restore the dev database from backup + */ +export async function restore(pgDump: string): Promise { + const proc = exec( + `docker compose -f=${DEV_COMPOSE_FILE} exec postgres pg_restore -U postgres -d postgres --clean`, + ); + + if (!proc.child.stdin) { + throw new Error(`childprocess was spawned with stdio[0] !== 'pipe'`); + } + await pipeline(Readable.from([pgDump]), proc.child.stdin); + await proc; +} diff --git a/test/utils/generateId.ts b/test/utils/generateId.ts new file mode 100644 index 00000000000..3d89ff92c58 --- /dev/null +++ b/test/utils/generateId.ts @@ -0,0 +1,3 @@ +export default function generateId(): string { + return String(Math.random()).slice(2); +} diff --git a/test/utils/locators.ts b/test/utils/locators.ts new file mode 100644 index 00000000000..cf7cc0f4a86 --- /dev/null +++ b/test/utils/locators.ts @@ -0,0 +1,3 @@ +export function toolpadHomeAppRow(appName: string): string { + return `[role="row"] >> has="input[value='${appName}']"`; +}