diff --git a/package-lock.json b/package-lock.json
index 2b416446f8..4c390cc7a5 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -75,6 +75,7 @@
         "eslint-plugin-jquery": "1.5.1",
         "eslint-plugin-no-jquery": "2.7.0",
         "eslint-plugin-no-use-extend-native": "0.5.0",
+        "eslint-plugin-playwright": "1.6.2",
         "eslint-plugin-regexp": "2.6.0",
         "eslint-plugin-sonarjs": "0.25.1",
         "eslint-plugin-unicorn": "52.0.0",
@@ -6331,6 +6332,31 @@
         "node": ">=6.0.0"
       }
     },
+    "node_modules/eslint-plugin-playwright": {
+      "version": "1.6.2",
+      "resolved": "https://registry.npmjs.org/eslint-plugin-playwright/-/eslint-plugin-playwright-1.6.2.tgz",
+      "integrity": "sha512-mraN4Em3b5jLt01q7qWPyLg0Q5v3KAWfJSlEWwldyUXoa7DSPrBR4k6B6LROLqipsG8ndkwWMdjl1Ffdh15tag==",
+      "dev": true,
+      "license": "MIT",
+      "workspaces": [
+        "examples"
+      ],
+      "dependencies": {
+        "globals": "^13.23.0"
+      },
+      "engines": {
+        "node": ">=16.6.0"
+      },
+      "peerDependencies": {
+        "eslint": ">=8.40.0",
+        "eslint-plugin-jest": ">=25"
+      },
+      "peerDependenciesMeta": {
+        "eslint-plugin-jest": {
+          "optional": true
+        }
+      }
+    },
     "node_modules/eslint-plugin-prettier": {
       "version": "5.2.1",
       "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.2.1.tgz",
diff --git a/package.json b/package.json
index 2882ba9199..143f24eceb 100644
--- a/package.json
+++ b/package.json
@@ -74,6 +74,7 @@
     "eslint-plugin-jquery": "1.5.1",
     "eslint-plugin-no-jquery": "2.7.0",
     "eslint-plugin-no-use-extend-native": "0.5.0",
+    "eslint-plugin-playwright": "1.6.2",
     "eslint-plugin-regexp": "2.6.0",
     "eslint-plugin-sonarjs": "0.25.1",
     "eslint-plugin-unicorn": "52.0.0",
diff --git a/tests/e2e/.eslintrc.yaml b/tests/e2e/.eslintrc.yaml
new file mode 100644
index 0000000000..390b2de5c4
--- /dev/null
+++ b/tests/e2e/.eslintrc.yaml
@@ -0,0 +1,23 @@
+plugins:
+  - eslint-plugin-playwright
+
+extends:
+  - ../../.eslintrc.yaml
+  - plugin:playwright/recommended
+
+parserOptions:
+  sourceType: module
+  ecmaVersion: latest
+
+env:
+  browser: true
+
+rules:
+  playwright/no-conditional-in-test: [0]
+  playwright/no-networkidle: [0]
+  playwright/no-skipped-test: [2, {allowConditional: true}]
+  playwright/prefer-comparison-matcher: [2]
+  playwright/prefer-equality-matcher: [2]
+  playwright/prefer-to-contain: [2]
+  playwright/prefer-to-have-length: [2]
+  playwright/require-to-throw-message: [2]
diff --git a/tests/e2e/actions.test.e2e.js b/tests/e2e/actions.test.e2e.js
index dcbd123e0b..0a4695e4a2 100644
--- a/tests/e2e/actions.test.e2e.js
+++ b/tests/e2e/actions.test.e2e.js
@@ -8,7 +8,7 @@ test.beforeAll(async ({browser}, workerInfo) => {
 
 const workflow_trigger_notification_text = 'This workflow has a workflow_dispatch event trigger.';
 
-test('Test workflow dispatch present', async ({browser}, workerInfo) => {
+test('workflow dispatch present', async ({browser}, workerInfo) => {
   const context = await load_logged_in_context(browser, workerInfo, 'user2');
   /** @type {import('@playwright/test').Page} */
   const page = await context.newPage();
@@ -26,7 +26,7 @@ test('Test workflow dispatch present', async ({browser}, workerInfo) => {
   await expect(menu).toBeVisible();
 });
 
-test('Test workflow dispatch error: missing inputs', async ({browser}, workerInfo) => {
+test('workflow dispatch error: missing inputs', async ({browser}, workerInfo) => {
   test.skip(workerInfo.project.name === 'Mobile Safari', 'Flaky behaviour on mobile safari; see https://codeberg.org/forgejo/forgejo/pulls/3334#issuecomment-2033383');
 
   const context = await load_logged_in_context(browser, workerInfo, 'user2');
@@ -38,11 +38,8 @@ test('Test workflow dispatch error: missing inputs', async ({browser}, workerInf
 
   await page.locator('#workflow_dispatch_dropdown>button').click();
 
-  await page.waitForTimeout(1000);
-
   // Remove the required attribute so we can trigger the error message!
   await page.evaluate(() => {
-    // eslint-disable-next-line no-undef
     const elem = document.querySelector('input[name="inputs[string2]"]');
     elem?.removeAttribute('required');
   });
@@ -53,7 +50,7 @@ test('Test workflow dispatch error: missing inputs', async ({browser}, workerInf
   await expect(page.getByText('Require value for input "String w/o. default".')).toBeVisible();
 });
 
-test('Test workflow dispatch success', async ({browser}, workerInfo) => {
+test('workflow dispatch success', async ({browser}, workerInfo) => {
   test.skip(workerInfo.project.name === 'Mobile Safari', 'Flaky behaviour on mobile safari; see https://codeberg.org/forgejo/forgejo/pulls/3334#issuecomment-2033383');
 
   const context = await load_logged_in_context(browser, workerInfo, 'user2');
@@ -64,7 +61,6 @@ test('Test workflow dispatch success', async ({browser}, workerInfo) => {
   await page.waitForLoadState('networkidle');
 
   await page.locator('#workflow_dispatch_dropdown>button').click();
-  await page.waitForTimeout(1000);
 
   await page.type('input[name="inputs[string2]"]', 'abc');
   await page.locator('#workflow-dispatch-submit').click();
@@ -75,7 +71,7 @@ test('Test workflow dispatch success', async ({browser}, workerInfo) => {
   await expect(page.locator('.run-list>:first-child .run-list-meta', {hasText: 'now'})).toBeVisible();
 });
 
-test('Test workflow dispatch box not available for unauthenticated users', async ({page}) => {
+test('workflow dispatch box not available for unauthenticated users', async ({page}) => {
   await page.goto('/user2/test_workflows/actions?workflow=test-dispatch.yml&actor=0&status=0');
   await page.waitForLoadState('networkidle');
 
diff --git a/tests/e2e/commit-graph-branch-selector.test.e2e.js b/tests/e2e/commit-graph-branch-selector.test.e2e.js
index b19277c114..4994c948b4 100644
--- a/tests/e2e/commit-graph-branch-selector.test.e2e.js
+++ b/tests/e2e/commit-graph-branch-selector.test.e2e.js
@@ -19,7 +19,7 @@ test('Switch branch', async ({browser}, workerInfo) => {
 
   await page.waitForLoadState('networkidle');
 
-  await expect(page.locator('#loading-indicator')).not.toBeVisible();
+  await expect(page.locator('#loading-indicator')).toBeHidden();
   await expect(page.locator('#rel-container')).toBeVisible();
   await expect(page.locator('#rev-container')).toBeVisible();
 });
diff --git a/tests/e2e/example.test.e2e.js b/tests/e2e/example.test.e2e.js
index c185d9157b..effb9f31b9 100644
--- a/tests/e2e/example.test.e2e.js
+++ b/tests/e2e/example.test.e2e.js
@@ -13,7 +13,7 @@ test('Load Homepage', async ({page}) => {
   await expect(page.locator('.logo')).toHaveAttribute('src', '/assets/img/logo.svg');
 });
 
-test('Test Register Form', async ({page}, workerInfo) => {
+test('Register Form', async ({page}, workerInfo) => {
   const response = await page.goto('/user/sign_up');
   await expect(response?.status()).toBe(200); // Status OK
   await page.type('input[name=user_name]', `e2e-test-${workerInfo.workerIndex}`);
@@ -29,7 +29,7 @@ test('Test Register Form', async ({page}, workerInfo) => {
   save_visual(page);
 });
 
-test('Test Login Form', async ({page}, workerInfo) => {
+test('Login Form', async ({page}, workerInfo) => {
   const response = await page.goto('/user/login');
   await expect(response?.status()).toBe(200); // Status OK
 
@@ -44,7 +44,7 @@ test('Test Login Form', async ({page}, workerInfo) => {
   save_visual(page);
 });
 
-test('Test Logged In User', async ({browser}, workerInfo) => {
+test('Logged In User', async ({browser}, workerInfo) => {
   const context = await load_logged_in_context(browser, workerInfo, 'user2');
   const page = await context.newPage();
 
diff --git a/tests/e2e/explore.test.e2e.js b/tests/e2e/explore.test.e2e.js
index f486a3cb5f..1b7986242f 100644
--- a/tests/e2e/explore.test.e2e.js
+++ b/tests/e2e/explore.test.e2e.js
@@ -1,6 +1,6 @@
 // @ts-check
 // document is a global in evaluate, so it's safe to ignore here
-/* eslint no-undef: 0 */
+// eslint playwright/no-conditional-in-test: 0
 import {test, expect} from '@playwright/test';
 
 test('Explore view taborder', async ({page}) => {
diff --git a/tests/e2e/issue-sidebar.test.e2e.js b/tests/e2e/issue-sidebar.test.e2e.js
index 4bd211abe5..7f343bd3b3 100644
--- a/tests/e2e/issue-sidebar.test.e2e.js
+++ b/tests/e2e/issue-sidebar.test.e2e.js
@@ -67,7 +67,7 @@ test('Issue: Labels', async ({browser}, workerInfo) => {
   await expect(response?.status()).toBe(200);
   // preconditions
   await expect(labelList.filter({hasText: 'label1'})).toBeVisible();
-  await expect(labelList.filter({hasText: 'label2'})).not.toBeVisible();
+  await expect(labelList.filter({hasText: 'label2'})).toBeHidden();
   // add label2
   await page.locator('.select-label').click();
   // label search could be tested this way:
@@ -81,7 +81,7 @@ test('Issue: Labels', async ({browser}, workerInfo) => {
   await page.locator('.select-label .item').filter({hasText: 'label2'}).click();
   await page.locator('.select-label').click();
   await page.waitForLoadState('networkidle');
-  await expect(labelList.filter({hasText: 'label2'})).not.toBeVisible();
+  await expect(labelList.filter({hasText: 'label2'})).toBeHidden();
   await expect(labelList.filter({hasText: 'label1'})).toBeVisible();
 });
 
diff --git a/tests/e2e/markdown-editor.test.e2e.js b/tests/e2e/markdown-editor.test.e2e.js
index 144519875a..e773c70845 100644
--- a/tests/e2e/markdown-editor.test.e2e.js
+++ b/tests/e2e/markdown-editor.test.e2e.js
@@ -6,7 +6,7 @@ test.beforeAll(async ({browser}, workerInfo) => {
   await login_user(browser, workerInfo, 'user2');
 });
 
-test('Test markdown indentation', async ({browser}, workerInfo) => {
+test('markdown indentation', async ({browser}, workerInfo) => {
   const context = await load_logged_in_context(browser, workerInfo, 'user2');
 
   const initText = `* first\n* second\n* third\n* last`;
@@ -79,7 +79,7 @@ test('Test markdown indentation', async ({browser}, workerInfo) => {
   await expect(textarea).toHaveValue(initText);
 });
 
-test('Test markdown list continuation', async ({browser}, workerInfo) => {
+test('markdown list continuation', async ({browser}, workerInfo) => {
   const context = await load_logged_in_context(browser, workerInfo, 'user2');
 
   const initText = `* first\n* second\n* third\n* last`;
diff --git a/tests/e2e/markup.test.e2e.js b/tests/e2e/markup.test.e2e.js
index 7bc6d2b6ca..ff4e948d8f 100644
--- a/tests/e2e/markup.test.e2e.js
+++ b/tests/e2e/markup.test.e2e.js
@@ -1,7 +1,7 @@
 // @ts-check
 import {test, expect} from '@playwright/test';
 
-test('Test markup with #xyz-mode-only', async ({page}) => {
+test('markup with #xyz-mode-only', async ({page}) => {
   const response = await page.goto('/user2/repo1/issues/1');
   await expect(response?.status()).toBe(200);
   await page.waitForLoadState('networkidle');
@@ -9,5 +9,5 @@ test('Test markup with #xyz-mode-only', async ({page}) => {
   const comment = page.locator('.comment-body>.markup', {hasText: 'test markup light/dark-mode-only'});
   await expect(comment).toBeVisible();
   await expect(comment.locator('[src$="#gh-light-mode-only"]')).toBeVisible();
-  await expect(comment.locator('[src$="#gh-dark-mode-only"]')).not.toBeVisible();
+  await expect(comment.locator('[src$="#gh-dark-mode-only"]')).toBeHidden();
 });
diff --git a/tests/e2e/profile_actions.test.e2e.js b/tests/e2e/profile_actions.test.e2e.js
index 20155b8df4..aeccab019c 100644
--- a/tests/e2e/profile_actions.test.e2e.js
+++ b/tests/e2e/profile_actions.test.e2e.js
@@ -27,7 +27,7 @@ test('Follow actions', async ({browser}, workerInfo) => {
   await expect(page.locator('#block-user')).toBeVisible();
   await page.locator('#block-user .ok').click();
   await expect(page.locator('.block')).toContainText('Unblock');
-  await expect(page.locator('#block-user')).not.toBeVisible();
+  await expect(page.locator('#block-user')).toBeHidden();
 
   // Check that following the user yields in a error being shown.
   await followButton.click();