Repository: piccolo-orm/piccolo_admin (language: python)

Issue:
Cypress - restart Python server before each test
Our process for running Cypress tests at the moment is:

```
# Launch the Python server
python -m piccolo_admin.example

# Run the Vue App
cd admin_ui
npm run serve

# Run Cypress
npm run cypress:run
```

Ideally, between each test we would restart the Python web server, so we start from a blank slate each time. Otherwise some tests may modify the database which effects later tests.

- [ ] Work out if it's possible
- [ ] If so, implement it



Discussion / hints:
@dantownsend I played around with this a bit but couldn't find a way to do it. I tried `cy.exec()` (though it's considered an anti-pattern) but it didn't work. In the end, I don't think it matters that much, because when we do tests locally, I don't think it's a problem to start the server when the tests are done, and when the tests are done in GH actions with each commit server is restarted automatically (if I understand correctly) .
Thanks for giving it a go.

One way it's sometimes done is having a REST API endpoint, and Cypress calls that. So `example.py` might have a `/reset` endpoint, which resets the database.

I've been playing around with Playwright. It's an alternative to Cypress, and tests can be written in Python, which would make things like this easier, as we can directly call other Python code from within our tests:

https://playwright.dev/

We could run a test, and then check the results are correct in the database.

I'm not saying we should switch right now, but it's an option if we run into difficult use cases.

Playwright looks great and seems to have all the features as Cypress but code can be written in Python which is great.

> One way it's sometimes done is having a REST API endpoint, and Cypress calls that. So `example.py` might have a `/reset` endpoint, which resets the database.

I like that idea although I don't know how to restart server from endpoint.


I don't think we could restart the server, but the `reset` endpoint would delete the data from the database:

```python
await Movie.delete(force=True)
...
```

And call this again:

https://github.com/piccolo-orm/piccolo_admin/blob/13693161f237025a4f715abd4b4bbeda3bda026b/piccolo_admin/example.py#L350

If I had a time machine, I would probably go back and use Playwright instead of Cypress. I still really like Cypress, but being able to use Python directly in our tests gives us way more options.
Playwright looks interesting. I'll try to rewrite the Cypress tests (we don't have a lot of tests) and see how it works.
Yeah, have a play around and see what you think.
Playwright is great and easy to use. I didn't use tests in parallel (tests run much faster than in Cypress, but if we want to speed things up, parallel tests are [easy to implement](https://playwright.dev/python/docs/test-runners#parallelism-running-multiple-tests-at-once)) or async. Rewriting tests is actually easy thanks to [Playwright codegen](https://playwright.dev/python/docs/codegen#running-codegen). I had to slightly modify each generated test, but it went smoothly. I was able to write fixture for reset the database like this 
```python
import pytest
from piccolo_admin.example import Director
from piccolo_admin.example_data import DIRECTORS
from playwright.sync_api import Playwright, sync_playwright


@pytest.fixture
def reset_database() -> None:
    Director.delete(force=True).run_sync()
    Director.insert(*[Director(**d) for d in DIRECTORS]).run_sync()


def test_login_logout(playwright: Playwright, reset_database) -> None:
```
and use that on every test to start with fresh `director` table (in fact, it may not be needed on every test, only on tests that change data in the database after crud operations). Tests would be used by starting `./scripts/dev-server` in one terminal and in second terminal we can start tests with `./scripts/run-e2e-test.sh`. For GH actions I didn't find anything for Python so I'll leave it up to you. If you want I can make a PR so you can see what it's all about.
Yeah, I really like Playwright too. I also like Cypress, but being able to write tests in Python is very useful.

What you've done there with the fixture looks good to me.

As for GitHub Actions, I'm hoping it's quite straight forward. We `npm install playwright`, run `example.py`, and then launch the tests using pytest.

Fix patch:
diff --git a/.github/workflows/cypress.yaml b/.github/workflows/cypress.yaml
deleted file mode 100644
index 75f5350c..00000000
--- a/.github/workflows/cypress.yaml
+++ /dev/null
@@ -1,49 +0,0 @@
-name: "Cypress tests"
-
-on:
-  push:
-    branches: ["master"]
-  pull_request:
-    branches: ["master"]
-
-jobs:
-  server-run:
-    name: "Setup local server and run Cypress tests"
-    runs-on: "ubuntu-latest"
-    timeout-minutes: 5
-    steps:
-      - name: "Checkout repository"
-        uses: "actions/checkout@v2"
-      - name: "Setup Python"
-        uses: "actions/setup-python@v2"
-        with:
-          python-version: 3.7
-      - name: "Setup Node"
-        uses: actions/setup-node@v3
-        with:
-          node-version: 16
-      - name: "Install all dependencies"
-        run: |
-          python -m pip install --upgrade pip
-          pip install -r requirements/requirements.txt
-          pip install -r requirements/test-requirements.txt
-          cd admin_ui && npm install
-      - name: Build front end assets
-        run: cd admin_ui && npm run build
-      - name: "Run Cypress tests"
-        uses: "cypress-io/github-action@v4"
-        with:
-          browser: chrome
-          working-directory: ./admin_ui
-          start: python -m piccolo_admin.example
-          config: baseUrl=http://localhost:8000
-        env:
-          PYTHONPATH: ..
-      - name: "Upload Cypress Screenshots and Videos"
-        uses: actions/upload-artifact@v3
-        with:
-          name: cypress-artifacts
-          path: |
-            /home/runner/work/piccolo_admin/piccolo_admin/admin_ui/cypress/videos/
-            /home/runner/work/piccolo_admin/piccolo_admin/admin_ui/cypress/screenshots/
-        if: always()
diff --git a/.github/workflows/playwright.yaml b/.github/workflows/playwright.yaml
new file mode 100644
index 00000000..8d078ab0
--- /dev/null
+++ b/.github/workflows/playwright.yaml
@@ -0,0 +1,41 @@
+name: "Playwright tests"
+
+on:
+  push:
+    branches: ["master"]
+  pull_request:
+    branches: ["master"]
+
+jobs:
+  playwright:
+    name: 'Playwright Tests'
+    runs-on: ubuntu-latest
+    steps:
+      - name: "Checkout repository"
+        uses: "actions/checkout@v2"
+      - name: "Setup Python"
+        uses: "actions/setup-python@v2"
+        with:
+          python-version: 3.8
+      - name: "Setup Node"
+        uses: actions/setup-node@v3
+        with:
+          node-version: 16
+      - name: "Install all dependencies"
+        run: |
+          python -m pip install --upgrade pip
+          pip install -r requirements/requirements.txt
+          pip install -r requirements/test-requirements.txt
+          cd admin_ui && npm install
+      - name: Build front end assets
+        run: cd admin_ui && npm run build
+      - name: Ensure Chromium is installed
+        run: python -m playwright install chromium --with-deps
+      - name: Start Playwright tests
+        run: ./scripts/run-e2e-test.sh
+      - uses: actions/upload-artifact@v3
+        with:
+          name: playwright-artifacts
+          path: videos/
+        if: always()
+        
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index c197814f..96b7fa54 100644
--- a/.gitignore
+++ b/.gitignore
@@ -20,3 +20,4 @@ docs/ebug
 piccolo_admin/example_media/*
 !piccolo_admin/example_media/.gitkeep
 piccolo_admin/.env
+videos
diff --git a/admin_ui/.gitignore b/admin_ui/.gitignore
index 1547a40d..a0dddc6f 100644
--- a/admin_ui/.gitignore
+++ b/admin_ui/.gitignore
@@ -19,6 +19,3 @@ yarn-error.log*
 *.njsproj
 *.sln
 *.sw?
-
-# Cypress
-cypress/videos
diff --git a/admin_ui/cypress.config.ts b/admin_ui/cypress.config.ts
deleted file mode 100644
index 5a0813c0..00000000
--- a/admin_ui/cypress.config.ts
+++ /dev/null
@@ -1,12 +0,0 @@
-import { defineConfig } from 'cypress'
-
-export default defineConfig({
-  e2e: {
-    // We've imported your old cypress plugins here.
-    // You may want to clean this up later by importing these.
-    setupNodeEvents(on, config) {
-      return require('./cypress/plugins/index.js')(on, config)
-    },
-    baseUrl: 'http://localhost:3000',
-  },
-})
diff --git a/admin_ui/cypress/README.md b/admin_ui/cypress/README.md
deleted file mode 100644
index 77c7aa20..00000000
--- a/admin_ui/cypress/README.md
+++ /dev/null
@@ -1,9 +0,0 @@
-# Cypress
-
-Cypress is a tool for running end to end tests. To run them, use the following command within the `admin_ui` folder:
-
-```bash
-npm run cypress:open
-```
-
-See the main [Piccolo Admin docs](https://piccolo-admin.readthedocs.io/en/latest/contributing/index.html) for more information.
diff --git a/admin_ui/cypress/fixtures/example.json b/admin_ui/cypress/fixtures/example.json
deleted file mode 100644
index 02e42543..00000000
--- a/admin_ui/cypress/fixtures/example.json
+++ /dev/null
@@ -1,5 +0,0 @@
-{
-  "name": "Using fixtures to represent data",
-  "email": "hello@cypress.io",
-  "body": "Fixtures are a great way to mock data for responses to routes"
-}
diff --git a/admin_ui/cypress/plugins/index.js b/admin_ui/cypress/plugins/index.js
deleted file mode 100644
index 59b2bab6..00000000
--- a/admin_ui/cypress/plugins/index.js
+++ /dev/null
@@ -1,22 +0,0 @@
-/// <reference types="cypress" />
-// ***********************************************************
-// This example plugins/index.js can be used to load plugins
-//
-// You can change the location of this file or turn off loading
-// the plugins file with the 'pluginsFile' configuration option.
-//
-// You can read more here:
-// https://on.cypress.io/plugins-guide
-// ***********************************************************
-
-// This function is called when a project is opened or re-opened (e.g. due to
-// the project's config changing)
-
-/**
- * @type {Cypress.PluginConfig}
- */
-// eslint-disable-next-line no-unused-vars
-module.exports = (on, config) => {
-  // `on` is used to hook into various events Cypress emits
-  // `config` is the resolved Cypress config
-}
diff --git a/admin_ui/cypress/support/commands.js b/admin_ui/cypress/support/commands.js
deleted file mode 100644
index 119ab03f..00000000
--- a/admin_ui/cypress/support/commands.js
+++ /dev/null
@@ -1,25 +0,0 @@
-// ***********************************************
-// This example commands.js shows you how to
-// create various custom commands and overwrite
-// existing commands.
-//
-// For more comprehensive examples of custom
-// commands please read more here:
-// https://on.cypress.io/custom-commands
-// ***********************************************
-//
-//
-// -- This is a parent command --
-// Cypress.Commands.add('login', (email, password) => { ... })
-//
-//
-// -- This is a child command --
-// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... })
-//
-//
-// -- This is a dual command --
-// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... })
-//
-//
-// -- This will overwrite an existing command --
-// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
diff --git a/admin_ui/package.json b/admin_ui/package.json
index 341ee3d3..e8a357f5 100644
--- a/admin_ui/package.json
+++ b/admin_ui/package.json
@@ -9,8 +9,6 @@
         "build": "vue-cli-service build",
         "lint": "vue-cli-service lint",
         "build-storybook": "build-storybook",
-        "cypress:open": "cypress open",
-        "cypress:run": "cypress run",
         "storybook": "start-storybook -p 6006"
     },
     "main": "babel.config.js",
@@ -51,7 +49,6 @@
         "babel-loader": "^8.2.2",
         "cache-loader": "^4.1.0",
         "css-loader": "^5.2.7",
-        "cypress": "^10.7.0",
         "less": "^3.13.1",
         "less-loader": "^6.0.0",
         "react-dom": "^17.0.2",
@@ -60,4 +57,4 @@
         "vue-template-compiler": "^2.6.12"
     },
     "license": "ISC"
-}
+}
\ No newline at end of file
diff --git a/docs/source/contributing/index.rst b/docs/source/contributing/index.rst
index e7117769..c60cdb58 100644
--- a/docs/source/contributing/index.rst
+++ b/docs/source/contributing/index.rst
@@ -109,36 +109,21 @@ tool for viewing UI components in isolation. To launch it:
 
 -------------------------------------------------------------------------------
 
-Cypress
--------
+Playwright
+----------
 
-Cypress is a tool for running end to end tests. It enables us to check that the
+Playwright is a tool for running end to end tests. It enables us to check that the
 entire application is working as expected.
 
-Before launching it, make sure that the Piccolo Admin front end code and
-backend code are running (see above).
-
 Run all tests
 ~~~~~~~~~~~~~
 
-From within the ``admin_ui`` folder, use the following command to run all of
-the Cypress tests:
-
-.. code-block:: bash
-
-    npm run cypress:run
-
-Run tests interactively
-~~~~~~~~~~~~~~~~~~~~~~~
-
-Instead, you can run this command to launch the Cypress app:
+From within the ``root`` folder, use the following command to run all of
+the Playwright tests with dev server in parallel:
 
 .. code-block:: bash
 
-    npm run cypress:open
-
-The app runs within Chrome. It allows you to run the Cypress tests, and to
-see them working in real time (clicking around Piccolo Admin).
+    ./scripts/run-e2e-test.sh
 
 -------------------------------------------------------------------------------
 
diff --git a/piccolo_admin/endpoints.py b/piccolo_admin/endpoints.py
index 67f47ff2..49218781 100644
--- a/piccolo_admin/endpoints.py
+++ b/piccolo_admin/endpoints.py
@@ -36,8 +36,8 @@
 from piccolo_api.session_auth.middleware import SessionsAuthBackend
 from piccolo_api.session_auth.tables import SessionsBase
 from pydantic import BaseModel, Field, ValidationError
-from starlette.exceptions import ExceptionMiddleware, HTTPException
 from starlette.middleware.authentication import AuthenticationMiddleware
+from starlette.middleware.exceptions import ExceptionMiddleware, HTTPException
 from starlette.requests import Request
 from starlette.responses import HTMLResponse, JSONResponse
 from starlette.staticfiles import StaticFiles


Test patch:
diff --git a/admin_ui/cypress/e2e/BulkUpdate.cy.ts b/admin_ui/cypress/e2e/BulkUpdate.cy.ts
deleted file mode 100644
index aa394317..00000000
--- a/admin_ui/cypress/e2e/BulkUpdate.cy.ts
+++ /dev/null
@@ -1,67 +0,0 @@
-/// <reference types="Cypress" />
-
-context('Startup', () => {
-    beforeEach(() => {
-        cy.visit('/#/login?nextURL=%2F')
-    })
-
-    it('Bulk update data', () => {
-        // Fill the username
-        cy.get('[name="username"]')
-            .type('piccolo')
-            .should('have.value', 'piccolo')
-
-        // Fill the password
-        cy.get('[name="password"]')
-            .type('piccolo123')
-            .should('have.value', 'piccolo123')
-
-        // Locate and submit the form
-        cy.get('form').submit();
-
-        // Verify the app redirected you to the admin page
-        cy.location('pathname', { timeout: 10000 })
-            .should('eq', '/');
-
-        // Verify the page title is "Piccolo Admin"
-        cy.title()
-            .should('eq', 'Piccolo Admin');
-
-        cy.contains('director').click();
-
-        cy.get('table')
-            .contains('tr td span', ' Male ')
-            .should('be.visible');
-
-        // Update record in bulk
-        cy.get('input[type="checkbox"]')
-            .check({ force: true })
-            .should('be.checked');
-
-        cy.contains('Update 8 rows')
-            .click();
-
-        cy.wait(1000);
-
-        // Choose from selects
-        cy.get('select[name=property]')
-            .select('gender');
-
-        cy.get('select[name=gender]')
-            .select('f');
-
-        // Locate and submit the form
-        cy.get('form').submit();
-
-        cy.wait(1000);
-
-        // Values are updated
-        cy.get('table')
-            .contains('tr td span', ' Female ')
-            .should('be.visible');
-
-        cy.contains('Successfully updated rows');
-
-    });
-
-});
\ No newline at end of file
diff --git a/admin_ui/cypress/e2e/CustomForm.cy.ts b/admin_ui/cypress/e2e/CustomForm.cy.ts
deleted file mode 100644
index 2d4086cf..00000000
--- a/admin_ui/cypress/e2e/CustomForm.cy.ts
+++ /dev/null
@@ -1,80 +0,0 @@
-/// <reference types="Cypress" />
-
-// The custom form sends emails - if you want to see these, set up a
-// Local SMTP Server:
-// python -m smtpd -c DebuggingServer -n localhost:1025
-
-context('Startup', () => {
-    beforeEach(() => {
-        cy.visit('/#/login?nextURL=%2F')
-    })
-
-    it('Fill custom forms', () => {
-        // Fill the username
-        cy.get('[name="username"]')
-            .type('piccolo')
-            .should('have.value', 'piccolo')
-
-        // Fill the password
-        cy.get('[name="password"]')
-            .type('piccolo123')
-            .should('have.value', 'piccolo123')
-
-        // Locate and submit the form
-        cy.get('form').submit()
-
-        // Verify the app redirected to the admin page
-        cy.location('pathname', { timeout: 2000 }).should('eq', '/')
-
-        // Verify the page title is "Piccolo Admin"
-        cy.title().should('eq', 'Piccolo Admin')
-
-        cy.wait(2000)
-
-        cy.contains('Business email form').click()
-
-        cy.wait(2000)
-
-        // Fill custom form
-        cy.get('[name="email"]')
-            .type('director@director.com')
-            .should('have.value', 'director@director.com')
-
-        cy.get('[name="title"]')
-            .clear()
-            .type('Hello')
-            .should('have.value', 'Hello')
-
-        cy.get('[name="content"]')
-            .type('Hello from Piccolo Admin')
-            .should('have.value', 'Hello from Piccolo Admin')
-
-        // Locate and submit the form
-        cy.get('form').submit()
-
-        cy.wait(2000)
-
-        cy.contains('Back').click()
-
-        cy.contains('Booking form').click()
-
-        cy.wait(2000)
-
-        // Fill custom form
-        cy.get('[name="email"]')
-            .type('customer@customer.com')
-            .should('have.value', 'customer@customer.com')
-
-        cy.get('[name="name"]')
-            .type('Bob')
-            .should('have.value', 'Bob')
-
-        cy.get('[name="notes"]')
-            .clear()
-            .type('Star Wars please')
-            .should('have.value', 'Star Wars please')
-
-        // Locate and submit the form
-        cy.get('form').submit()
-    })
-})
diff --git a/admin_ui/cypress/e2e/FileUpload.cy.ts b/admin_ui/cypress/e2e/FileUpload.cy.ts
deleted file mode 100644
index 1ccfe866..00000000
--- a/admin_ui/cypress/e2e/FileUpload.cy.ts
+++ /dev/null
@@ -1,68 +0,0 @@
-/// <reference types="Cypress" />
-
-context('Startup', () => {
-    beforeEach(() => {
-        cy.visit('/#/login?nextURL=%2F')
-    })
-
-    it('File upload', () => {
-        // Fill the username
-        cy.get('[name="username"]')
-            .type('piccolo')
-            .should('have.value', 'piccolo')
-
-        // Fill the password
-        cy.get('[name="password"]')
-            .type('piccolo123')
-            .should('have.value', 'piccolo123')
-
-        // Locate and submit the form
-        cy.get('form').submit();
-
-        // Verify the app redirected you to the admin page
-        cy.location('pathname', { timeout: 10000 })
-            .should('eq', '/');
-
-        // Verify the page title is "Piccolo Admin"
-        cy.title()
-            .should('eq', 'Piccolo Admin');
-
-        cy.contains('director').click();
-
-        // Update record
-        cy.wait(2000);
-
-        cy.get('a[href*="#/director/8"]')
-            .click();
-
-        cy.contains('h1', 'Edit director');
-
-        // Select file to upload
-        cy.get('input[type=file]').selectFile({
-            contents: 'cypress/fixtures/piccolo.jpg',
-            mimeType: 'image/jpeg'
-        })
-
-        cy.wait(3000);
-
-        // Locate and submit the form
-        cy.get('form').submit();
-
-        // Go back to directors page
-        cy.contains('a', 'Back').click();
-
-        cy.wait(2000);
-
-        // View the uploaded file
-        cy.contains('table tr td span a', '.jpg')
-            .click();
-
-        cy.wait(3000);
-
-        // Close preview of uploaded file
-        cy.get('div#media_viewer div.top_bar p.close a')
-            .click();
-
-    });
-
-});
\ No newline at end of file
diff --git a/admin_ui/cypress/e2e/LoginAndLogout.cy.ts b/admin_ui/cypress/e2e/LoginAndLogout.cy.ts
deleted file mode 100644
index d001ff6a..00000000
--- a/admin_ui/cypress/e2e/LoginAndLogout.cy.ts
+++ /dev/null
@@ -1,55 +0,0 @@
-/// <reference types="Cypress" />
-
-context('Startup', () => {
-    beforeEach(() => {
-        cy.visit('/#/login?nextURL=%2F');
-    });
-
-    it('Fill login form and redirect to admin page and then logout', () => {
-
-        cy.on("uncaught:exception", (err, runnable) => {
-            // returning false here prevents Cypress from
-            // failing the test
-            return false
-        })
-
-        // Fill the username
-        cy.get('[name="username"]')
-            .type('piccolo')
-            .should('have.value', 'piccolo');
-
-        // Fill the password
-        cy.get('[name="password"]')
-            .type('piccolo123')
-            .should('have.value', 'piccolo123');
-
-        // Locate and submit the form
-        cy.get('form')
-            .submit();
-
-        // Verify the app redirected to the admin page
-        cy.location('pathname', { timeout: 2000 })
-            .should('eq', '/');
-
-        // Verify the page title is "Piccolo Admin"
-        cy.title()
-            .should('eq', 'Piccolo Admin');
-
-        cy.wait(2000);
-
-        cy.contains('piccolo')
-            .click();
-
-        // Logout and redirected to the login page
-        cy.contains('Log out')
-            .click();
-
-        // Verify the app redirected to the login page
-        cy.location('pathname', { timeout: 2000 }).
-            should('eq', '/');
-
-        cy.wait(3000);
-
-    });
-
-});
\ No newline at end of file
diff --git a/admin_ui/cypress/e2e/RowListingCrud.cy.ts b/admin_ui/cypress/e2e/RowListingCrud.cy.ts
deleted file mode 100644
index f7311b5c..00000000
--- a/admin_ui/cypress/e2e/RowListingCrud.cy.ts
+++ /dev/null
@@ -1,119 +0,0 @@
-/// <reference types="Cypress" />
-
-context('Startup', () => {
-    beforeEach(() => {
-        cy.visit('/#/login?nextURL=%2F');
-    });
-
-    it('Open director table and made basic crud operations', () => {
-
-        // Fill the username
-        cy.get('[name="username"]')
-            .type('piccolo')
-            .should('have.value', 'piccolo');
-
-        // Fill the password
-        cy.get('[name="password"]')
-            .type('piccolo123')
-            .should('have.value', 'piccolo123');
-
-        // Locate and submit the form
-        cy.get('form').submit();
-
-        // Verify the app redirected you to the admin page
-        cy.location('pathname', { timeout: 10000 })
-            .should('eq', '/');
-
-        // Verify the page title is "Piccolo Admin"
-        cy.title()
-            .should('eq', 'Piccolo Admin');
-
-        cy.contains('director').click();
-
-        // Update record
-
-        cy.wait(2000);
-
-        cy.get('a[href*="#/director/8"]')
-            .click()
-
-        cy.contains('h1', 'Edit director');
-
-        // Clear name field
-        cy.get('[name="name"]')
-            .focus()
-            .clear();
-
-        // Fill field with new value
-        cy.get('[name="name"]')
-            .type('Ronald William Howard')
-            .should('have.value', 'Ronald William Howard');
-
-        cy.get('form')
-            .submit();
-
-        // Back to directors main page
-        cy.contains('Back')
-            .click();
-
-        // Create record
-
-        cy.wait(2000);
-
-        cy.get('a[href*="#/director/add"]')
-            .click();
-
-        cy.contains('h1', 'Add director');
-
-        // Fill field with new name value
-        cy.get('[name="name"]')
-            .type('Emerald Fennell')
-            .should('have.value', 'Emerald Fennell');
-
-        // Fill field with new years nominated value
-        cy.get('a[href*="#"]')
-            .contains('Add ')
-            .click();
-
-        cy.get('[id="choice"]')
-            .type('2020')
-            .should('have.value', '2020');
-
-        // Fill field with new gender value
-        cy.get('select')
-            .select('f');
-
-        cy.get('button')
-            .contains('Create')
-            .click();
-
-        cy.contains('Back')
-            .click();
-
-        cy.wait(3000);
-
-        // Delete single selected row
-        cy.get('input[type="checkbox"]').check('5')
-            .check({ force: true })
-            .should('be.checked')
-
-        cy.wait(2000);
-
-        cy.contains('Delete 1 rows')
-            .click();
-
-        cy.wait(3000);
-
-        // Delete all selected rows
-        cy.get('input[type="checkbox"]')
-            .check({ force: true })
-            .should('be.checked')
-
-        cy.contains('Delete 8 rows')
-            .click();
-
-        cy.wait(1000);
-
-    });
-
-});
\ No newline at end of file
diff --git a/admin_ui/cypress/e2e/RowListingFilter.cy.ts b/admin_ui/cypress/e2e/RowListingFilter.cy.ts
deleted file mode 100644
index cde48c3b..00000000
--- a/admin_ui/cypress/e2e/RowListingFilter.cy.ts
+++ /dev/null
@@ -1,104 +0,0 @@
-/// <reference types="Cypress" />
-
-context('Startup', () => {
-    beforeEach(() => {
-        cy.visit('/#/login?nextURL=%2F');
-    });
-
-    it('Open director table and made basic filtering and sorting', () => {
-
-        // Fill the username
-        cy.get('[name="username"]')
-            .type('piccolo')
-            .should('have.value', 'piccolo');
-
-        // Fill the password
-        cy.get('[name="password"]')
-            .type('piccolo123')
-            .should('have.value', 'piccolo123');
-
-        // Locate and submit the form
-        cy.get('form')
-            .submit();
-
-        // Verify the app redirected you to the admin page
-        cy.location('pathname', { timeout: 10000 })
-            .should('eq', '/');
-
-        // Verify the page title is "Piccolo Admin"
-        cy.title()
-            .should('eq', 'Piccolo Admin');
-
-        // Click Director link
-        cy.contains('director')
-            .click();
-
-        cy.contains('Show filters')
-            .click();
-
-        // Clear name field
-        cy.get('[name="name"]')
-            .focus()
-            .clear();
-
-        // Fill the search value
-        cy.get('[name="name"]')
-            .type('Howard')
-            .should('have.value', 'Howard');
-
-        // Clear filters
-        cy.contains('Clear filters')
-            .click();
-
-        cy.wait(2000);
-
-        // Again fill the search value
-        cy.get('[name="name"]')
-            .type('Howard')
-            .should('have.value', 'Howard');
-
-        // Locate and submit the form
-        cy.get('form')
-            .submit();
-
-        // Close filter sidebar
-        cy.contains('Close')
-            .click();
-
-        cy.wait(2000);
-
-        cy.contains('Show filters')
-            .click();
-
-        // Clear filters to get all rows
-        cy.contains('Clear filters')
-            .click();
-
-        cy.contains('Close')
-            .click();
-
-        cy.wait(2000);
-
-        // Sort ascending
-        cy.contains('Sort')
-            .click();
-
-        cy.get('[name="ordering"]').select('ascending');
-
-        cy.get('button').contains('Sort')
-            .click();
-
-        cy.wait(3000);
-
-        // Sort descending
-        cy.contains('Sort')
-            .click();
-
-        cy.get('[name="ordering"]').select('descending');
-
-        cy.get('button').contains('Sort')
-            .click();
-
-    });
-
-});
\ No newline at end of file
diff --git a/admin_ui/cypress/support/e2e.js b/admin_ui/cypress/support/e2e.js
deleted file mode 100644
index d68db96d..00000000
--- a/admin_ui/cypress/support/e2e.js
+++ /dev/null
@@ -1,20 +0,0 @@
-// ***********************************************************
-// This example support/index.js is processed and
-// loaded automatically before your test files.
-//
-// This is a great place to put global configuration and
-// behavior that modifies Cypress.
-//
-// You can change the location of this file or turn off
-// automatically serving support files with the
-// 'supportFile' configuration option.
-//
-// You can read more here:
-// https://on.cypress.io/configuration
-// ***********************************************************
-
-// Import commands.js using ES2015 syntax:
-import './commands'
-
-// Alternatively you can use CommonJS syntax:
-// require('./commands')
diff --git a/e2e/__init__.py b/e2e/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/e2e/test_playwright.py b/e2e/test_playwright.py
new file mode 100644
index 00000000..ed96ac86
--- /dev/null
+++ b/e2e/test_playwright.py
@@ -0,0 +1,365 @@
+import pytest
+import time
+from subprocess import Popen, PIPE
+from http.client import HTTPConnection
+
+from playwright.sync_api import Playwright, sync_playwright
+
+
+@pytest.fixture
+def dev_server():
+    """
+    Running dev server and Playwright test in parallel.
+    More info https://til.simonwillison.net/pytest/playwright-pytest
+    """
+    process = Popen(
+        ["python", "-m", "piccolo_admin.example"],
+        stdout=PIPE,
+    )
+    retries = 5
+    while retries > 0:
+        conn = HTTPConnection("localhost:8000")
+        try:
+            conn.request("HEAD", "/")
+            response = conn.getresponse()
+            if response is not None:
+                yield process
+                break
+        except ConnectionRefusedError:
+            time.sleep(1)
+            retries -= 1
+
+    if not retries:
+        raise RuntimeError("Failed to start http server")
+    else:
+        process.terminate()
+        process.wait()
+
+
+def test_login_logout(playwright: Playwright, dev_server) -> None:
+    browser = playwright.chromium.launch()
+    context = browser.new_context(record_video_dir="videos/")
+    # Open new page
+    page = context.new_page()
+    # Go to http://localhost:8000/#/login?nextURL=%2F
+    page.goto("http://localhost:8000/#/login?nextURL=%2F")
+    # Click input[name="username"]
+    page.locator('input[name="username"]').click()
+    # Fill input[name="username"]
+    page.locator('input[name="username"]').fill("piccolo")
+    # Press Tab
+    page.locator('input[name="username"]').press("Tab")
+    # Fill input[name="password"]
+    page.locator('input[name="password"]').fill("piccolo123")
+    # Press Enter
+    page.locator('input[name="password"]').press("Enter")
+    page.wait_for_url("http://localhost:8000/#/")
+    # Click #user
+    page.locator("#user").click()
+    # Click text=Log out
+    page.once("dialog", lambda dialog: dialog.dismiss())
+    page.locator("text=Log out").click()
+    # ---------------------
+    context.close()
+    browser.close()
+
+
+def test_row_listing_filter(playwright: Playwright, dev_server) -> None:
+    browser = playwright.chromium.launch()
+    context = browser.new_context(record_video_dir="videos/")
+    # Open new page
+    page = context.new_page()
+    # Go to http://localhost:8000/#/login?nextURL=%2F
+    page.goto("http://localhost:8000/#/login?nextURL=%2F")
+    # Click input[name="username"]
+    page.locator('input[name="username"]').click()
+    # Fill input[name="username"]
+    page.locator('input[name="username"]').fill("piccolo")
+    # Press Tab
+    page.locator('input[name="username"]').press("Tab")
+    # Fill input[name="password"]
+    page.locator('input[name="password"]').fill("piccolo123")
+    # Press Enter
+    page.locator('input[name="password"]').press("Enter")
+    page.wait_for_url("http://localhost:8000/#/")
+    # Click text=director
+    page.locator("text=director").click()
+    page.wait_for_url("http://localhost:8000/#/director")
+    # Click text=Show filters
+    page.locator("text=Show filters").click()
+    # Click input[name="name"]
+    page.locator('input[name="name"]').click()
+    # Fill input[name="name"]
+    page.locator('input[name="name"]').fill("Howard")
+    # Press Enter
+    page.locator('input[name="name"]').press("Enter")
+    # Click text=Clear filters
+    page.locator("text=Clear filters").click()
+    # Click input[name="name"]
+    page.locator('input[name="name"]').click()
+    # Fill input[name="name"]
+    page.locator('input[name="name"]').fill("Ron")
+    # Click text=Apply
+    page.locator("text=Apply").click()
+    # Click text=Clear filters
+    page.locator("text=Clear filters").click()
+    # Click text=Close
+    page.locator("text=Close").click()
+    # Click span:has-text("Sort")
+    page.locator("span", has_text="Sort").click()
+    # Select ascending
+    page.locator('select[name="ordering"]').select_option("ascending")
+    # Click button:has-text("Sort")
+    page.locator("button", has_text="Sort").click()
+    page.wait_for_timeout(2000)
+    page.locator("#user").click()
+    # Click text=Log out
+    page.once("dialog", lambda dialog: dialog.accept())
+    page.locator("text=Log out").click()
+    page.wait_for_url("http://localhost:8000/#/login?nextURL=%2F")
+    # ---------------------
+    context.close()
+    browser.close()
+
+
+def test_custom_form(playwright: Playwright, dev_server) -> None:
+    browser = playwright.chromium.launch()
+    context = browser.new_context(record_video_dir="videos/")
+    # Open new page
+    page = context.new_page()
+    # Go to http://localhost:8000/#/login?nextURL=%2F
+    page.goto("http://localhost:8000/#/login?nextURL=%2F")
+    # Click input[name="username"]
+    page.locator('input[name="username"]').click()
+    # Fill input[name="username"]
+    page.locator('input[name="username"]').fill("piccolo")
+    # Press Tab
+    page.locator('input[name="username"]').press("Tab")
+    # Fill input[name="password"]
+    page.locator('input[name="password"]').fill("piccolo123")
+    # Press Enter
+    page.locator('input[name="password"]').press("Enter")
+    page.wait_for_url("http://localhost:8000/#/")
+    # Click text=Business email form
+    page.locator("text=Business email form").click()
+    page.wait_for_url("http://localhost:8000/#/forms/business-email-form")
+    # Click input[name="email"]
+    page.locator('input[name="email"]').click()
+    # Fill input[name="email"]
+    page.locator('input[name="email"]').fill("director@director.com")
+    # Click input[name="title"]
+    page.locator('input[name="title"]').click()
+    # Click text=Piccolo Admin piccolo Back Business email form Send an email to a business assoc
+    page.locator(
+        "text=Piccolo Admin piccolo Back Business email form Send an email to a business assoc"
+    ).click()
+    # Click input[name="title"]
+    page.locator('input[name="title"]').click()
+    # Fill input[name="title"]
+    page.locator('input[name="title"]').fill("Hello")
+    # Click input[name="content"]
+    page.locator('input[name="content"]').click()
+    # Fill input[name="content"]
+    page.locator('input[name="content"]').fill("Hello from Piccolo Admin")
+    # Click button:has-text("Submit")
+    page.locator('button:has-text("Submit")').click()
+    # Click text=Back >> nth=0
+    page.locator("text=Back").first.click()
+    page.wait_for_url("http://localhost:8000/#/")
+    # Click text=Booking form
+    page.locator("text=Booking form").click()
+    page.wait_for_url("http://localhost:8000/#/forms/booking-form")
+    # Click input[name="email"]
+    page.locator('input[name="email"]').click()
+    # Fill input[name="email"]
+    page.locator('input[name="email"]').fill("customer@customer.com")
+    # Click input[name="name"]
+    page.locator('input[name="name"]').click()
+    # Fill input[name="name"]
+    page.locator('input[name="name"]').fill("Bob")
+    # Click input[name="notes"]
+    page.locator('input[name="notes"]').click()
+    # Fill input[name="notes"]
+    page.locator('input[name="notes"]').fill("Star Wars please")
+    # Click button:has-text("Submit")
+    page.locator('button:has-text("Submit")').click()
+    # Click text=Back >> nth=0
+    page.locator("text=Back").first.click()
+    page.wait_for_timeout(2000)
+    page.locator("#user").click()
+    # Click text=Log out
+    page.once("dialog", lambda dialog: dialog.accept())
+    page.locator("text=Log out").click()
+    page.wait_for_url("http://localhost:8000/#/login?nextURL=%2F")
+    # ---------------------
+    context.close()
+    browser.close()
+
+
+def test_file_upload(playwright: Playwright, dev_server) -> None:
+    browser = playwright.chromium.launch()
+    context = browser.new_context(record_video_dir="videos/")
+    # Open new page
+    page = context.new_page()
+    # Go to http://localhost:8000/#/login?nextURL=%2F
+    page.goto("http://localhost:8000/#/login?nextURL=%2F")
+    # Click input[name="username"]
+    page.locator('input[name="username"]').click()
+    # Fill input[name="username"]
+    page.locator('input[name="username"]').fill("piccolo")
+    # Press Tab
+    page.locator('input[name="username"]').press("Tab")
+    # Fill input[name="password"]
+    page.locator('input[name="password"]').fill("piccolo123")
+    # Press Enter
+    page.locator('input[name="password"]').press("Enter")
+    page.wait_for_url("http://localhost:8000/#/")
+    # Click text=director
+    page.locator("text=director").click()
+    page.wait_for_url("http://localhost:8000/#/director")
+    # Click a:has-text("8")
+    page.locator('a:has-text("8")').click()
+    page.wait_for_url("http://localhost:8000/#/director/8")
+    # Upload piccolo.jpg
+    page.locator('input[type="file"]').set_input_files(
+        "./e2e/upload/piccolo.jpg"
+    )
+    # Click text=Save
+    page.locator("text=Save").click()
+    # Click text=Back
+    page.locator("text=Back").click()
+    page.wait_for_url("http://localhost:8000/#/director")
+    # Click image link
+    page.locator("text=piccolo-").click()
+    # Close image preview
+    page.locator("div#media_viewer div.top_bar p.close").click()
+    # ---------------------
+    context.close()
+    browser.close()
+
+
+def test_bulk_update(playwright: Playwright, dev_server) -> None:
+    browser = playwright.chromium.launch()
+    context = browser.new_context(record_video_dir="videos/")
+    # Open new page
+    page = context.new_page()
+    # Go to http://localhost:8000/#/login?nextURL=%2F
+    page.goto("http://localhost:8000/#/login?nextURL=%2F")
+    # Click input[name="username"]
+    page.locator('input[name="username"]').click()
+    # Fill input[name="username"]
+    page.locator('input[name="username"]').fill("piccolo")
+    # Press Tab
+    page.locator('input[name="username"]').press("Tab")
+    # Fill input[name="password"]
+    page.locator('input[name="password"]').fill("piccolo123")
+    # Press Enter
+    page.locator('input[name="password"]').press("Enter")
+    page.wait_for_url("http://localhost:8000/#/")
+    # Click text=director
+    page.locator("text=director").click()
+    page.wait_for_url("http://localhost:8000/#/director")
+    # Check text=id Name Gender Photo >> input[type="checkbox"]
+    page.locator('text=id Name Gender Photo >> input[type="checkbox"]').check()
+    # Click a:has-text("Update 8 rows")
+    page.locator('a:has-text("Update 8 rows")').click()
+    # Select gender
+    page.locator('select[name="property"]').select_option("gender")
+    # Select f
+    page.locator('select[name="gender"]').select_option("f")
+    # Click button:has-text("Update")
+    page.locator('button:has-text("Update")').click()
+    # Click #user
+    page.wait_for_timeout(2000)
+    page.locator("#user").click()
+    # Click text=Log out
+    page.once("dialog", lambda dialog: dialog.accept())
+    page.locator("text=Log out").click()
+    page.wait_for_url("http://localhost:8000/#/login?nextURL=%2F")
+    # ---------------------
+    context.close()
+    browser.close()
+
+
+def test_table_crud(playwright: Playwright, dev_server) -> None:
+    browser = playwright.chromium.launch()
+    context = browser.new_context(record_video_dir="videos/")
+    # Open new page
+    page = context.new_page()
+    # Go to http://localhost:8000/#/login?nextURL=%2F
+    page.goto("http://localhost:8000/#/login?nextURL=%2F")
+    # Click input[name="username"]
+    page.locator('input[name="username"]').click()
+    # Fill input[name="username"]
+    page.locator('input[name="username"]').fill("piccolo")
+    # Press Tab
+    page.locator('input[name="username"]').press("Tab")
+    # Fill input[name="password"]
+    page.locator('input[name="password"]').fill("piccolo123")
+    # Press Enter
+    page.locator('input[name="password"]').press("Enter")
+    page.wait_for_url("http://localhost:8000/#/")
+    # Click text=director
+    page.locator("text=director").click()
+    page.wait_for_url("http://localhost:8000/#/director")
+    # Click a:has-text("8")
+    page.locator('a:has-text("8")').click()
+    page.wait_for_url("http://localhost:8000/#/director/8")
+    # Click text=Piccolo Admin piccolo Back Edit director Name Years Nominated Which years this d
+    page.locator(
+        "text=Piccolo Admin piccolo Back Edit director Name Years Nominated Which years this d"
+    ).click()
+    # Fill input[type="text"] >> nth=2
+    page.locator('input[type="text"]').nth(2).fill("Ronald William Howard2009")
+    # Press z with modifiers
+    page.locator('input[type="text"]').nth(2).press("Control+z")
+    # Click input[name="name"]
+    page.locator('input[name="name"]').click()
+    # Fill input[name="name"]
+    page.locator('input[name="name"]').fill("Ronald William Howard")
+    # Click text=Save
+    page.locator("text=Save").click()
+    # Click text=Back
+    page.locator("text=Back").click()
+    page.wait_for_url("http://localhost:8000/#/director")
+    # Click text=Add Row
+    page.locator("text=Add Row").click()
+    page.wait_for_url("http://localhost:8000/#/director/add")
+    # Click input[name="name"]
+    page.locator('input[name="name"]').click()
+    # Fill input[name="name"]
+    page.locator('input[name="name"]').fill("Emerald Fennell")
+    # Click text=AddAdd
+    page.locator("text=AddAdd").click()
+    # Click text=RemoveAddAdd >> input[type="text"]
+    page.locator('text=RemoveAddAdd >> input[type="text"]').click()
+    # Fill text=RemoveAddAdd >> input[type="text"]
+    page.locator('text=RemoveAddAdd >> input[type="text"]').fill("2020")
+    # Select f
+    page.locator('select[name="gender"]').select_option("f")
+    # Click text=Create
+    page.locator("text=Create").click()
+    # Click text=Back
+    page.locator("text=Back").click()
+    page.wait_for_timeout(3000)
+    page.wait_for_url("http://localhost:8000/#/director")
+    # Check text=5Irvin Kershner Male >> input[type="checkbox"]
+    page.locator('text=5Irvin Kershner Male >> input[type="checkbox"]').check()
+    # Click text=Delete 1 rows
+    page.once("dialog", lambda dialog: dialog.accept())
+    page.locator("text=Delete 1 rows").click()
+    page.wait_for_timeout(3000)
+    # Check text=id Name Gender Photo >> input[type="checkbox"]
+    page.locator('text=id Name Gender Photo >> input[type="checkbox"]').check()
+    # Click text=Delete 8 rows
+    page.once("dialog", lambda dialog: dialog.accept())
+    page.locator("text=Delete 8 rows").click()
+    # wait for rows deletion
+    page.wait_for_timeout(2000)
+    page.locator("#user").click()
+    # Click text=Log out
+    page.once("dialog", lambda dialog: dialog.dismiss())
+    page.locator("text=Log out").click()
+    # ---------------------
+    context.close()
+    browser.close()
diff --git a/admin_ui/cypress/fixtures/piccolo.jpg b/e2e/upload/piccolo.jpg
similarity index 100%
rename from admin_ui/cypress/fixtures/piccolo.jpg
rename to e2e/upload/piccolo.jpg
diff --git a/requirements/test-requirements.txt b/requirements/test-requirements.txt
index a0d3906d..741d9f19 100644
--- a/requirements/test-requirements.txt
+++ b/requirements/test-requirements.txt
@@ -3,3 +3,4 @@ pytest-cov==3.0.0
 flake8==4.0.1
 piccolo[postgres,sqlite]>=0.30.0
 requests==2.27.1
+pytest-playwright==0.3.0
diff --git a/scripts/run-e2e-test.sh b/scripts/run-e2e-test.sh
new file mode 100755
index 00000000..2b6a8e1d
--- /dev/null
+++ b/scripts/run-e2e-test.sh
@@ -0,0 +1,4 @@
+#!/bin/bash
+# Run end-to-end tests
+
+python -m pytest e2e
diff --git a/scripts/run-tests.sh b/scripts/run-tests.sh
index ef9e8fc3..18fbb66d 100755
--- a/scripts/run-tests.sh
+++ b/scripts/run-tests.sh
@@ -9,4 +9,4 @@ mkdir -p piccolo_admin/dist/css
 mkdir -p piccolo_admin/dist/js
 touch piccolo_admin/dist/index.html
 
-python -m pytest --cov=piccolo_admin --cov-report xml --cov-report html --cov-fail-under 85 -s $@
+python -m pytest --ignore=e2e --cov=piccolo_admin --cov-report xml --cov-report html --cov-fail-under 85 -s $@