diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 6a59f2f..f7d9430 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -48,3 +48,33 @@ jobs:
             - name: Check formatting of everything (prettier)
               run: |
                   npm run fmt:check
+
+    e2e-test:
+        name: E2E Tests (Playwright)
+        timeout-minutes: 60
+        # run only if triggered by push on a branch or by a PR event for a PR which is not a draft
+        if: ${{ !github.event.pull_request || github.event.pull_request.draft == false }}
+        runs-on: ubuntu-24.04
+        steps:
+            - uses: actions/checkout@v4
+
+            - name: Install Node.js
+              uses: actions/setup-node@v4
+              with:
+                  node-version-file: ".nvmrc"
+                  cache: npm
+
+            - name: Install dependencies
+              run: |
+                  npm ci
+
+            - name: Run Playwright tests
+              run: |
+                  npm run test:e2e
+
+            - uses: actions/upload-artifact@v4
+              if: ${{ !cancelled() }}
+              with:
+                  name: playwright-report
+                  path: playwright-report/
+                  retention-days: 30
diff --git a/.gitignore b/.gitignore
index dfaa43c..f7f3594 100644
--- a/.gitignore
+++ b/.gitignore
@@ -24,3 +24,9 @@ npm-debug.log*
 
 # typescript
 *.tsbuildinfo
+
+# Playwright
+/test-results/
+/playwright-report/
+/blob-report/
+/playwright/.cache/
diff --git a/README.md b/README.md
index 5be3ce6..5dc13b2 100644
--- a/README.md
+++ b/README.md
@@ -13,6 +13,7 @@ To install and set up the project, follow these steps:
 1. Ensure you have Node.js v20 installed. You can download it from the [official Node.js website](https://nodejs.org/).
 2. Clone the repository to your local machine.
 3. Install the project dependencies using npm - `npm install`.
+4. Setup Docker to run E2E tests via Playwright ([Docker Desktop](https://www.docker.com/products/docker-desktop/) or [Docker Engine](https://docs.docker.com/engine/install/)).
 
 This will install all the necessary packages and dependencies required to run the project.
 
@@ -28,6 +29,7 @@ Open [http://localhost:5173](http://localhost:5173) with your browser to see the
 
 -   `npm run start`: Starts the development server.
 -   `npm run build`: Builds the app for production.
+-   `npm run test:e2e:update`: Runs all End-to-End tests and updates the screenshots/snapshots.
 
 ## Configuration
 
diff --git a/e2e-tests/code-editing-and-ast-interaction.spec.ts b/e2e-tests/code-editing-and-ast-interaction.spec.ts
new file mode 100644
index 0000000..b16609f
--- /dev/null
+++ b/e2e-tests/code-editing-and-ast-interaction.spec.ts
@@ -0,0 +1,59 @@
+/**
+ * Tests for code editing functionality and AST tool interaction.
+ */
+import test, { expect } from "@playwright/test";
+
+/**
+ * This test verifies that:
+ * - Users can edit code in the editor
+ * - The AST updates in response to code changes
+ * - ESQuery selectors correctly highlight matching code and AST nodes
+ * - AST node expansion functionality works properly
+ */
+test(`should change code, then highlight code and AST nodes matching ESQuery selector`, async ({
+	page,
+}) => {
+	await page.goto("/");
+
+	// focus code editor textbox
+	await page
+		.getByRole("region", { name: "Code Editor Panel" })
+		.getByRole("textbox")
+		.nth(1)
+		.click();
+
+	// delete the default code
+	await page.keyboard.press("Control+KeyA");
+	await page.keyboard.press("Backspace");
+
+	// add new code
+	await page.keyboard.type("console.log('Hello, World!');");
+
+	// add an ESQuery selector
+	await page.getByRole("textbox", { name: "ESQuery Selector" }).click();
+	await page.keyboard.type("CallExpression");
+
+	// wait for the debounced update of the AST to happen
+	await expect(
+		page
+			.getByRole("listitem")
+			.filter({ hasText: "end" })
+			.filter({ hasText: "29" }),
+	).toBeVisible();
+
+	// expand AST nodes for ExpressionStatement and CallExpression
+	await page
+		.getByRole("region", { name: "Program" })
+		.getByRole("listitem")
+		.filter({ hasText: "Array" })
+		.getByRole("button", { name: "Toggle Property" })
+		.click();
+	await page.getByRole("button", { name: "ExpressionStatement" }).click();
+	await page
+		.getByRole("region", { name: "ExpressionStatement" })
+		.getByLabel("Toggle Property")
+		.click();
+
+	// screenshot
+	await expect(page).toHaveScreenshot();
+});
diff --git a/e2e-tests/code-editing-and-ast-interaction.spec.ts-snapshots/should-change-code-then-highlight-code-and-AST-nodes-matching-ESQuery-selector-1-chromium-docker.png b/e2e-tests/code-editing-and-ast-interaction.spec.ts-snapshots/should-change-code-then-highlight-code-and-AST-nodes-matching-ESQuery-selector-1-chromium-docker.png
new file mode 100644
index 0000000..1a24950
Binary files /dev/null and b/e2e-tests/code-editing-and-ast-interaction.spec.ts-snapshots/should-change-code-then-highlight-code-and-AST-nodes-matching-ESQuery-selector-1-chromium-docker.png differ
diff --git a/e2e-tests/code-editing-and-ast-interaction.spec.ts-snapshots/should-change-code-then-highlight-code-and-AST-nodes-matching-ESQuery-selector-1-firefox-docker.png b/e2e-tests/code-editing-and-ast-interaction.spec.ts-snapshots/should-change-code-then-highlight-code-and-AST-nodes-matching-ESQuery-selector-1-firefox-docker.png
new file mode 100644
index 0000000..bed35bc
Binary files /dev/null and b/e2e-tests/code-editing-and-ast-interaction.spec.ts-snapshots/should-change-code-then-highlight-code-and-AST-nodes-matching-ESQuery-selector-1-firefox-docker.png differ
diff --git a/e2e-tests/code-editing-and-ast-interaction.spec.ts-snapshots/should-change-code-then-highlight-code-and-AST-nodes-matching-ESQuery-selector-1-webkit-docker.png b/e2e-tests/code-editing-and-ast-interaction.spec.ts-snapshots/should-change-code-then-highlight-code-and-AST-nodes-matching-ESQuery-selector-1-webkit-docker.png
new file mode 100644
index 0000000..aa0fc84
Binary files /dev/null and b/e2e-tests/code-editing-and-ast-interaction.spec.ts-snapshots/should-change-code-then-highlight-code-and-AST-nodes-matching-ESQuery-selector-1-webkit-docker.png differ
diff --git a/e2e-tests/light-dark-theme.spec.ts b/e2e-tests/light-dark-theme.spec.ts
new file mode 100644
index 0000000..0bcbac5
--- /dev/null
+++ b/e2e-tests/light-dark-theme.spec.ts
@@ -0,0 +1,24 @@
+/**
+ * Tests for theme switching functionality.
+ */
+
+import { test, expect } from "@playwright/test";
+
+/**
+ * This test verifies that:
+ * - The application shows light theme by default
+ * - Users can toggle between light and dark themes
+ * - Theme changes are visually reflected in the UI
+ */
+test("should show light theme by default and switch to dark theme", async ({
+	page,
+}) => {
+	await page.goto("/");
+
+	await expect(page).toHaveScreenshot("light-theme.png");
+
+	await page.getByRole("button", { name: "Toggle theme" }).click();
+	await page.getByRole("menuitem", { name: "Dark" }).click();
+
+	await expect(page).toHaveScreenshot("dark-theme.png");
+});
diff --git a/e2e-tests/light-dark-theme.spec.ts-snapshots/dark-theme-chromium-docker.png b/e2e-tests/light-dark-theme.spec.ts-snapshots/dark-theme-chromium-docker.png
new file mode 100644
index 0000000..6802434
Binary files /dev/null and b/e2e-tests/light-dark-theme.spec.ts-snapshots/dark-theme-chromium-docker.png differ
diff --git a/e2e-tests/light-dark-theme.spec.ts-snapshots/dark-theme-firefox-docker.png b/e2e-tests/light-dark-theme.spec.ts-snapshots/dark-theme-firefox-docker.png
new file mode 100644
index 0000000..4c2d2b0
Binary files /dev/null and b/e2e-tests/light-dark-theme.spec.ts-snapshots/dark-theme-firefox-docker.png differ
diff --git a/e2e-tests/light-dark-theme.spec.ts-snapshots/dark-theme-webkit-docker.png b/e2e-tests/light-dark-theme.spec.ts-snapshots/dark-theme-webkit-docker.png
new file mode 100644
index 0000000..40a276e
Binary files /dev/null and b/e2e-tests/light-dark-theme.spec.ts-snapshots/dark-theme-webkit-docker.png differ
diff --git a/e2e-tests/light-dark-theme.spec.ts-snapshots/light-theme-chromium-docker.png b/e2e-tests/light-dark-theme.spec.ts-snapshots/light-theme-chromium-docker.png
new file mode 100644
index 0000000..069d8e6
Binary files /dev/null and b/e2e-tests/light-dark-theme.spec.ts-snapshots/light-theme-chromium-docker.png differ
diff --git a/e2e-tests/light-dark-theme.spec.ts-snapshots/light-theme-firefox-docker.png b/e2e-tests/light-dark-theme.spec.ts-snapshots/light-theme-firefox-docker.png
new file mode 100644
index 0000000..b0a3b74
Binary files /dev/null and b/e2e-tests/light-dark-theme.spec.ts-snapshots/light-theme-firefox-docker.png differ
diff --git a/e2e-tests/light-dark-theme.spec.ts-snapshots/light-theme-webkit-docker.png b/e2e-tests/light-dark-theme.spec.ts-snapshots/light-theme-webkit-docker.png
new file mode 100644
index 0000000..fcf9930
Binary files /dev/null and b/e2e-tests/light-dark-theme.spec.ts-snapshots/light-theme-webkit-docker.png differ
diff --git a/e2e-tests/options.spec.ts b/e2e-tests/options.spec.ts
new file mode 100644
index 0000000..3501981
--- /dev/null
+++ b/e2e-tests/options.spec.ts
@@ -0,0 +1,33 @@
+/**
+ * Tests for language selection and options panel functionality.
+ */
+import { test, expect } from "@playwright/test";
+
+/**
+ * This test verifies that:
+ * - Users can open the language options popover
+ * - Users can switch between supported languages (JavaScript, JSON, Markdown, CSS)
+ * - For each language the entire page is correctly rendered
+ */
+test("should switch language and show options for each", async ({ page }) => {
+	await page.goto("/");
+
+	await page.getByRole("button", { name: "Language Options" }).click();
+
+	await expect(page).toHaveScreenshot("page-javascript.png");
+
+	await page.getByRole("combobox", { name: "Language" }).click();
+	await page.getByRole("option", { name: "JSON JSON" }).click();
+
+	await expect(page).toHaveScreenshot("page-json.png");
+
+	await page.getByRole("combobox", { name: "Language" }).click();
+	await page.getByRole("option", { name: "Markdown Markdown" }).click();
+
+	await expect(page).toHaveScreenshot("page-markdown.png");
+
+	await page.getByRole("combobox", { name: "Language" }).click();
+	await page.getByRole("option", { name: "CSS CSS" }).click();
+
+	await expect(page).toHaveScreenshot("page-css.png");
+});
diff --git a/e2e-tests/options.spec.ts-snapshots/page-css-chromium-docker.png b/e2e-tests/options.spec.ts-snapshots/page-css-chromium-docker.png
new file mode 100644
index 0000000..fc6e455
Binary files /dev/null and b/e2e-tests/options.spec.ts-snapshots/page-css-chromium-docker.png differ
diff --git a/e2e-tests/options.spec.ts-snapshots/page-css-firefox-docker.png b/e2e-tests/options.spec.ts-snapshots/page-css-firefox-docker.png
new file mode 100644
index 0000000..968b661
Binary files /dev/null and b/e2e-tests/options.spec.ts-snapshots/page-css-firefox-docker.png differ
diff --git a/e2e-tests/options.spec.ts-snapshots/page-css-webkit-docker.png b/e2e-tests/options.spec.ts-snapshots/page-css-webkit-docker.png
new file mode 100644
index 0000000..16c7165
Binary files /dev/null and b/e2e-tests/options.spec.ts-snapshots/page-css-webkit-docker.png differ
diff --git a/e2e-tests/options.spec.ts-snapshots/page-javascript-chromium-docker.png b/e2e-tests/options.spec.ts-snapshots/page-javascript-chromium-docker.png
new file mode 100644
index 0000000..f60cc37
Binary files /dev/null and b/e2e-tests/options.spec.ts-snapshots/page-javascript-chromium-docker.png differ
diff --git a/e2e-tests/options.spec.ts-snapshots/page-javascript-firefox-docker.png b/e2e-tests/options.spec.ts-snapshots/page-javascript-firefox-docker.png
new file mode 100644
index 0000000..3291958
Binary files /dev/null and b/e2e-tests/options.spec.ts-snapshots/page-javascript-firefox-docker.png differ
diff --git a/e2e-tests/options.spec.ts-snapshots/page-javascript-webkit-docker.png b/e2e-tests/options.spec.ts-snapshots/page-javascript-webkit-docker.png
new file mode 100644
index 0000000..6cfa077
Binary files /dev/null and b/e2e-tests/options.spec.ts-snapshots/page-javascript-webkit-docker.png differ
diff --git a/e2e-tests/options.spec.ts-snapshots/page-json-chromium-docker.png b/e2e-tests/options.spec.ts-snapshots/page-json-chromium-docker.png
new file mode 100644
index 0000000..8c36ef7
Binary files /dev/null and b/e2e-tests/options.spec.ts-snapshots/page-json-chromium-docker.png differ
diff --git a/e2e-tests/options.spec.ts-snapshots/page-json-firefox-docker.png b/e2e-tests/options.spec.ts-snapshots/page-json-firefox-docker.png
new file mode 100644
index 0000000..3973c21
Binary files /dev/null and b/e2e-tests/options.spec.ts-snapshots/page-json-firefox-docker.png differ
diff --git a/e2e-tests/options.spec.ts-snapshots/page-json-webkit-docker.png b/e2e-tests/options.spec.ts-snapshots/page-json-webkit-docker.png
new file mode 100644
index 0000000..7d3454c
Binary files /dev/null and b/e2e-tests/options.spec.ts-snapshots/page-json-webkit-docker.png differ
diff --git a/e2e-tests/options.spec.ts-snapshots/page-markdown-chromium-docker.png b/e2e-tests/options.spec.ts-snapshots/page-markdown-chromium-docker.png
new file mode 100644
index 0000000..8e6c3ff
Binary files /dev/null and b/e2e-tests/options.spec.ts-snapshots/page-markdown-chromium-docker.png differ
diff --git a/e2e-tests/options.spec.ts-snapshots/page-markdown-firefox-docker.png b/e2e-tests/options.spec.ts-snapshots/page-markdown-firefox-docker.png
new file mode 100644
index 0000000..b7d4824
Binary files /dev/null and b/e2e-tests/options.spec.ts-snapshots/page-markdown-firefox-docker.png differ
diff --git a/e2e-tests/options.spec.ts-snapshots/page-markdown-webkit-docker.png b/e2e-tests/options.spec.ts-snapshots/page-markdown-webkit-docker.png
new file mode 100644
index 0000000..9c5ae38
Binary files /dev/null and b/e2e-tests/options.spec.ts-snapshots/page-markdown-webkit-docker.png differ
diff --git a/e2e-tests/tools.spec.ts b/e2e-tests/tools.spec.ts
new file mode 100644
index 0000000..23a4019
--- /dev/null
+++ b/e2e-tests/tools.spec.ts
@@ -0,0 +1,33 @@
+/**
+ * Tests for the Code Analysis Tools Panel.
+ */
+import { test, expect } from "@playwright/test";
+
+/**
+ * This test verifies that:
+ * - Users can switch between different code analysis tools (AST, Scope, Code Path)
+ * - Each tool displays correctly
+ * - Tool-specific interactions work as expected (e.g. scope selection)
+ */
+test("should switch to each tool and show it", async ({ page }) => {
+	await page.goto("/");
+
+	await expect(
+		page.getByRole("region", { name: "Code Analysis Tools Panel" }),
+	).toHaveScreenshot("tools-ast.png");
+
+	await page.getByRole("button", { name: "Scope" }).click();
+	await page.getByRole("button", { name: "global" }).click();
+	// move mouse away to avoid accordion hover state
+	await page.mouse.move(0, 0);
+
+	await expect(
+		page.getByRole("region", { name: "Code Analysis Tools Panel" }),
+	).toHaveScreenshot("tools-scope.png");
+
+	await page.getByRole("button", { name: "Code Path" }).click();
+
+	await expect(
+		page.getByRole("region", { name: "Code Analysis Tools Panel" }),
+	).toHaveScreenshot("tools-code-path.png");
+});
diff --git a/e2e-tests/tools.spec.ts-snapshots/tools-ast-chromium-docker.png b/e2e-tests/tools.spec.ts-snapshots/tools-ast-chromium-docker.png
new file mode 100644
index 0000000..f0a694c
Binary files /dev/null and b/e2e-tests/tools.spec.ts-snapshots/tools-ast-chromium-docker.png differ
diff --git a/e2e-tests/tools.spec.ts-snapshots/tools-ast-firefox-docker.png b/e2e-tests/tools.spec.ts-snapshots/tools-ast-firefox-docker.png
new file mode 100644
index 0000000..ea6a6f2
Binary files /dev/null and b/e2e-tests/tools.spec.ts-snapshots/tools-ast-firefox-docker.png differ
diff --git a/e2e-tests/tools.spec.ts-snapshots/tools-ast-webkit-docker.png b/e2e-tests/tools.spec.ts-snapshots/tools-ast-webkit-docker.png
new file mode 100644
index 0000000..5c4a214
Binary files /dev/null and b/e2e-tests/tools.spec.ts-snapshots/tools-ast-webkit-docker.png differ
diff --git a/e2e-tests/tools.spec.ts-snapshots/tools-code-path-chromium-docker.png b/e2e-tests/tools.spec.ts-snapshots/tools-code-path-chromium-docker.png
new file mode 100644
index 0000000..cc51d38
Binary files /dev/null and b/e2e-tests/tools.spec.ts-snapshots/tools-code-path-chromium-docker.png differ
diff --git a/e2e-tests/tools.spec.ts-snapshots/tools-code-path-firefox-docker.png b/e2e-tests/tools.spec.ts-snapshots/tools-code-path-firefox-docker.png
new file mode 100644
index 0000000..28965bf
Binary files /dev/null and b/e2e-tests/tools.spec.ts-snapshots/tools-code-path-firefox-docker.png differ
diff --git a/e2e-tests/tools.spec.ts-snapshots/tools-code-path-webkit-docker.png b/e2e-tests/tools.spec.ts-snapshots/tools-code-path-webkit-docker.png
new file mode 100644
index 0000000..f1dbb40
Binary files /dev/null and b/e2e-tests/tools.spec.ts-snapshots/tools-code-path-webkit-docker.png differ
diff --git a/e2e-tests/tools.spec.ts-snapshots/tools-scope-chromium-docker.png b/e2e-tests/tools.spec.ts-snapshots/tools-scope-chromium-docker.png
new file mode 100644
index 0000000..b29ae03
Binary files /dev/null and b/e2e-tests/tools.spec.ts-snapshots/tools-scope-chromium-docker.png differ
diff --git a/e2e-tests/tools.spec.ts-snapshots/tools-scope-firefox-docker.png b/e2e-tests/tools.spec.ts-snapshots/tools-scope-firefox-docker.png
new file mode 100644
index 0000000..36a9909
Binary files /dev/null and b/e2e-tests/tools.spec.ts-snapshots/tools-scope-firefox-docker.png differ
diff --git a/e2e-tests/tools.spec.ts-snapshots/tools-scope-webkit-docker.png b/e2e-tests/tools.spec.ts-snapshots/tools-scope-webkit-docker.png
new file mode 100644
index 0000000..e5ed90d
Binary files /dev/null and b/e2e-tests/tools.spec.ts-snapshots/tools-scope-webkit-docker.png differ
diff --git a/package-lock.json b/package-lock.json
index b1e2840..241cf18 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -53,6 +53,7 @@
         "zustand": "^4.5.4"
       },
       "devDependencies": {
+        "@playwright/test": "1.51.1",
         "@types/eslint-scope": "^3.7.7",
         "@types/espree": "^10.0.0",
         "@types/esquery": "^1.5.4",
@@ -60,6 +61,7 @@
         "@types/react": "^18.3.3",
         "@types/react-dom": "^18.3.0",
         "@vitejs/plugin-react": "^4.4.1",
+        "cross-env": "^7.0.3",
         "eslint": "^9.25.1",
         "got": "^14.4.3",
         "lint-staged": "^15.2.9",
@@ -1628,6 +1630,22 @@
         "node": ">=14"
       }
     },
+    "node_modules/@playwright/test": {
+      "version": "1.51.1",
+      "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.51.1.tgz",
+      "integrity": "sha512-nM+kEaTSAoVlXmMPH10017vn3FSiFqr/bh4fKg9vmAdMfd9SDqRZNvPSiAHADc/itWak+qPvMPZQOPwCBW7k7Q==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "dependencies": {
+        "playwright": "1.51.1"
+      },
+      "bin": {
+        "playwright": "cli.js"
+      },
+      "engines": {
+        "node": ">=18"
+      }
+    },
     "node_modules/@radix-ui/number": {
       "version": "1.1.0",
       "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.0.tgz",
@@ -4171,6 +4189,25 @@
       "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
       "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g=="
     },
+    "node_modules/cross-env": {
+      "version": "7.0.3",
+      "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz",
+      "integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "cross-spawn": "^7.0.1"
+      },
+      "bin": {
+        "cross-env": "src/bin/cross-env.js",
+        "cross-env-shell": "src/bin/cross-env-shell.js"
+      },
+      "engines": {
+        "node": ">=10.14",
+        "npm": ">=6",
+        "yarn": ">=1"
+      }
+    },
     "node_modules/cross-spawn": {
       "version": "7.0.6",
       "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -8310,6 +8347,53 @@
         "node": ">= 6"
       }
     },
+    "node_modules/playwright": {
+      "version": "1.51.1",
+      "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.51.1.tgz",
+      "integrity": "sha512-kkx+MB2KQRkyxjYPc3a0wLZZoDczmppyGJIvQ43l+aZihkaVvmu/21kiyaHeHjiFxjxNNFnUncKmcGIyOojsaw==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "dependencies": {
+        "playwright-core": "1.51.1"
+      },
+      "bin": {
+        "playwright": "cli.js"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "optionalDependencies": {
+        "fsevents": "2.3.2"
+      }
+    },
+    "node_modules/playwright-core": {
+      "version": "1.51.1",
+      "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.51.1.tgz",
+      "integrity": "sha512-/crRMj8+j/Nq5s8QcvegseuyeZPxpQCZb6HNk3Sos3BlZyAknRjoyJPFWkpNn8v0+P3WiwqFF8P+zQo4eqiNuw==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "bin": {
+        "playwright-core": "cli.js"
+      },
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/playwright/node_modules/fsevents": {
+      "version": "2.3.2",
+      "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
+      "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+      "dev": true,
+      "hasInstallScript": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+      }
+    },
     "node_modules/possible-typed-array-names": {
       "version": "1.0.0",
       "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz",
diff --git a/package.json b/package.json
index e7caab9..3d28720 100644
--- a/package.json
+++ b/package.json
@@ -18,7 +18,9 @@
     "lint:js": "eslint --max-warnings 0 .",
     "lint:fix:js": "eslint --max-warnings 0 . --fix",
     "fmt": "prettier --write .",
-    "fmt:check": "prettier --check ."
+    "fmt:check": "prettier --check .",
+    "test:e2e": "docker pull mcr.microsoft.com/playwright:v1.51.1-noble && cross-env PW_TEST_CONNECT_WS_ENDPOINT=ws://127.0.0.1:36719/ playwright test",
+    "test:e2e:update": "npm run test:e2e -- --update-snapshots"
   },
   "lint-staged": {
     "**/*.{ts,tsx,jsx,js}": [
@@ -79,6 +81,7 @@
     "zustand": "^4.5.4"
   },
   "devDependencies": {
+    "@playwright/test": "1.51.1",
     "@types/eslint-scope": "^3.7.7",
     "@types/espree": "^10.0.0",
     "@types/esquery": "^1.5.4",
@@ -86,6 +89,7 @@
     "@types/react": "^18.3.3",
     "@types/react-dom": "^18.3.0",
     "@vitejs/plugin-react": "^4.4.1",
+    "cross-env": "^7.0.3",
     "eslint": "^9.25.1",
     "got": "^14.4.3",
     "lint-staged": "^15.2.9",
diff --git a/playwright.config.ts b/playwright.config.ts
new file mode 100644
index 0000000..65eaa07
--- /dev/null
+++ b/playwright.config.ts
@@ -0,0 +1,93 @@
+import os from "node:os";
+
+import { devices, PlaywrightTestConfig } from "@playwright/test";
+
+const countOfCpus = os.cpus().length;
+let workers;
+if (countOfCpus !== 0) {
+	if (countOfCpus <= 4) {
+		workers = countOfCpus; // utilize all logical processors
+	} else {
+		// if the number of CPUs is greater than 4, we set it to 4 to limit RAM usage
+		workers = 4;
+	}
+}
+
+const isInCi = process.env.CI === "true";
+
+const config: PlaywrightTestConfig = {
+	testDir: "./e2e-tests",
+	fullyParallel: true,
+	// fail a Playwright run in CI if some test.only is in the source code
+	forbidOnly: isInCi,
+	retries: isInCi ? 1 : 0,
+	workers,
+	reporter: isInCi ? [["html"], ["github"]] : "html",
+
+	use: {
+		baseURL: `http://localhost:5173`,
+
+		// ensure consistent timezone and locale
+		timezoneId: "America/Los_Angeles",
+		locale: "en-US",
+
+		// always capture trace and video (seems to not have significant performance impact)
+		trace: "on",
+		video: "on",
+	},
+
+	expect: {
+		toHaveScreenshot: {
+			// screenshots are often not exactly the same for various reasons - allow 5% of pixel difference
+			maxDiffPixelRatio: 0.05,
+			pathTemplate: `{testDir}/{testFilePath}-snapshots/{arg}-{projectName}-docker{ext}`,
+		},
+	},
+
+	projects: [
+		{
+			name: "chromium",
+			use: {
+				...devices["Desktop Chrome"],
+				// opt into "New Headless" chromium (https://playwright.dev/docs/browsers#chromium-new-headless-mode, https://developer.chrome.com/docs/chromium/headless)
+				channel: "chromium",
+			},
+		},
+		{
+			name: "firefox",
+			use: { ...devices["Desktop Firefox"] },
+		},
+		{
+			name: "webkit",
+			use: { ...devices["Desktop Safari"] },
+		},
+	],
+
+	webServer: [
+		{
+			// start the Playwright server in a docker container
+			command: createDockerRunCommand(36719),
+			url: `http://127.0.0.1:36719/`,
+			stdout: "pipe",
+			stderr: "pipe",
+			timeout: 30_000,
+			gracefulShutdown: {
+				signal: "SIGTERM",
+				timeout: 10_000,
+			},
+			reuseExistingServer: !isInCi,
+		},
+		{
+			command: "npm run start",
+			url: "http://localhost:5173",
+			reuseExistingServer: !isInCi,
+		},
+	],
+};
+
+export default config;
+
+function createDockerRunCommand(port: number) {
+	const dockerRunCommand = `docker run --rm --init --workdir /home/pwuser --user pwuser --network host mcr.microsoft.com/playwright:v1.51.1-noble /bin/sh -c "npx -y playwright@1.51.1 run-server --port ${port} --host 0.0.0.0"`;
+	return dockerRunCommand;
+}
diff --git a/src/App.tsx b/src/App.tsx
index 5776a26..863fb46 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -27,7 +27,12 @@ function App() {
 								direction="horizontal"
 								className="border-t h-full"
 							>
-								<Panel defaultSize={50} minSize={25}>
+								<Panel
+									defaultSize={50}
+									minSize={25}
+									role="region"
+									aria-label="Code Editor Panel"
+								>
 									<EsquerySelectorInput />
 									<Editor
 										value={code[language]}
@@ -47,7 +52,12 @@ function App() {
 									/>
 								</Panel>
 								<PanelResizeHandle className="w-2 bg-gutter dark:bg-gray-600 bg-gray-200 bg-no-repeat bg-center" />
-								<Panel defaultSize={50} minSize={25}>
+								<Panel
+									defaultSize={50}
+									minSize={25}
+									role="region"
+									aria-label="Code Analysis Tools Panel"
+								>
 									<div className="bg-muted overflow-auto h-full relative flex flex-col">
 										<div className="flex sm:items-center flex-col sm:flex-row justify-between p-4 gap-2 z-10">
 											<ToolSelector />
diff --git a/src/components/LabeledSelect.tsx b/src/components/LabeledSelect.tsx
index 1e08e0b..73f3f73 100644
--- a/src/components/LabeledSelect.tsx
+++ b/src/components/LabeledSelect.tsx
@@ -42,7 +42,7 @@ const LabeledSelect = (props: PanelProps) => {
 		<div className="space-y-1.5">
 			<Label htmlFor={id}>{label}</Label>
 			<Select value={value} onValueChange={onValueChange}>
-				<SelectTrigger className="w-full" disabled={isDisabled}>
+				<SelectTrigger id={id} className="w-full" disabled={isDisabled}>
 					<SelectValue placeholder={placeholder} />
 				</SelectTrigger>
 				<SelectContent>
diff --git a/src/components/ast/css-ast-tree-item.tsx b/src/components/ast/css-ast-tree-item.tsx
index c3ec8d1..bfee72e 100644
--- a/src/components/ast/css-ast-tree-item.tsx
+++ b/src/components/ast/css-ast-tree-item.tsx
@@ -37,7 +37,7 @@ export const CssAstTreeItem: FC<CssAstTreeItemProperties> = ({
 				{data.type}
 			</AccordionTrigger>
 			<AccordionContent className="p-4 border-t">
-				<div className="space-y-1">
+				<ul className="space-y-1">
 					{Object.entries(data).map(item => (
 						<TreeEntry
 							key={item[0]}
@@ -45,7 +45,7 @@ export const CssAstTreeItem: FC<CssAstTreeItemProperties> = ({
 							esqueryMatchedNodes={esqueryMatchedNodes}
 						/>
 					))}
-				</div>
+				</ul>
 			</AccordionContent>
 		</AccordionItem>
 	);
diff --git a/src/components/ast/javascript-ast-tree-item.tsx b/src/components/ast/javascript-ast-tree-item.tsx
index c26e74c..88914d5 100644
--- a/src/components/ast/javascript-ast-tree-item.tsx
+++ b/src/components/ast/javascript-ast-tree-item.tsx
@@ -35,7 +35,7 @@ export const JavascriptAstTreeItem: FC<JavascriptAstTreeItemProperties> = ({
 				{data.type}
 			</AccordionTrigger>
 			<AccordionContent className="p-4 border-t">
-				<div className="space-y-1">
+				<ul className="space-y-1">
 					{Object.entries(data).map(item => (
 						<TreeEntry
 							key={item[0]}
@@ -43,7 +43,7 @@ export const JavascriptAstTreeItem: FC<JavascriptAstTreeItemProperties> = ({
 							esqueryMatchedNodes={esqueryMatchedNodes}
 						/>
 					))}
-				</div>
+				</ul>
 			</AccordionContent>
 		</AccordionItem>
 	);
diff --git a/src/components/ast/json-ast-tree-item.tsx b/src/components/ast/json-ast-tree-item.tsx
index 7d8ac0b..2724e4c 100644
--- a/src/components/ast/json-ast-tree-item.tsx
+++ b/src/components/ast/json-ast-tree-item.tsx
@@ -37,7 +37,7 @@ export const JsonAstTreeItem: FC<JsonAstTreeItemProperties> = ({
 				{data.type}
 			</AccordionTrigger>
 			<AccordionContent className="p-4 border-t">
-				<div className="space-y-1">
+				<ul className="space-y-1">
 					{Object.entries(data).map(item => (
 						<TreeEntry
 							key={item[0]}
@@ -45,7 +45,7 @@ export const JsonAstTreeItem: FC<JsonAstTreeItemProperties> = ({
 							esqueryMatchedNodes={esqueryMatchedNodes}
 						/>
 					))}
-				</div>
+				</ul>
 			</AccordionContent>
 		</AccordionItem>
 	);
diff --git a/src/components/ast/markdown-ast-tree-item.tsx b/src/components/ast/markdown-ast-tree-item.tsx
index 25d091a..b188c6c 100644
--- a/src/components/ast/markdown-ast-tree-item.tsx
+++ b/src/components/ast/markdown-ast-tree-item.tsx
@@ -37,7 +37,7 @@ export const MarkdownAstTreeItem: FC<MarkdownAstTreeItemProperties> = ({
 				{data.type}
 			</AccordionTrigger>
 			<AccordionContent className="p-4 border-t">
-				<div className="space-y-1">
+				<ul className="space-y-1">
 					{Object.entries(data).map(item => (
 						<TreeEntry
 							key={item[0]}
@@ -45,7 +45,7 @@ export const MarkdownAstTreeItem: FC<MarkdownAstTreeItemProperties> = ({
 							esqueryMatchedNodes={esqueryMatchedNodes}
 						/>
 					))}
-				</div>
+				</ul>
 			</AccordionContent>
 		</AccordionItem>
 	);
diff --git a/src/components/editor.tsx b/src/components/editor.tsx
index 350271c..476761e 100644
--- a/src/components/editor.tsx
+++ b/src/components/editor.tsx
@@ -165,6 +165,7 @@ export const Editor: FC<EditorProperties> = ({
 				</div>
 			)}
 			<CodeMirror
+				aria-label="Code Editor"
 				className="h-full overflow-auto scrollbar-thumb scrollbar-track text-sm"
 				value={value}
 				extensions={editorExtensions}
diff --git a/src/components/options.tsx b/src/components/options.tsx
index 216e0c6..f51f1a2 100644
--- a/src/components/options.tsx
+++ b/src/components/options.tsx
@@ -222,7 +222,11 @@ export const Options: FC = () => {
 	return (
 		<Popover>
 			<PopoverTrigger asChild>
-				<Button variant="outline" className="flex items-center gap-1.5">
+				<Button
+					aria-label="Language Options"
+					variant="outline"
+					className="flex items-center gap-1.5"
+				>
 					<img
 						src={currentLanguage.icon}
 						alt={currentLanguage.label}
diff --git a/src/components/scope/scope-item.tsx b/src/components/scope/scope-item.tsx
index 4b477c7..d612f3f 100644
--- a/src/components/scope/scope-item.tsx
+++ b/src/components/scope/scope-item.tsx
@@ -59,7 +59,7 @@ export const ScopeItem: FC<ScopeItemProperties> = ({
 					{`${Math.max(index, 0)}. ${key}`}
 				</AccordionTrigger>
 				<AccordionContent className="p-4 border-t">
-					<div className="space-y-1">
+					<ul className="space-y-1">
 						{properties.map((item, index) => (
 							<TreeEntry
 								key={item[0]}
@@ -68,7 +68,7 @@ export const ScopeItem: FC<ScopeItemProperties> = ({
 								esqueryMatchedNodes={esqueryMatchedNodes}
 							/>
 						))}
-					</div>
+					</ul>
 				</AccordionContent>
 			</AccordionItem>
 		);
diff --git a/src/components/tree-entry.tsx b/src/components/tree-entry.tsx
index 01991a7..68bcc8d 100644
--- a/src/components/tree-entry.tsx
+++ b/src/components/tree-entry.tsx
@@ -109,11 +109,11 @@ export const TreeEntry: FC<TreeEntryProperties> = ({
 
 	return (
 		<>
-			<div className="flex items-center gap-3">
+			<li className="flex items-center gap-3">
 				{isToggleable ? (
 					<button
 						onClick={toggleOpen}
-						aria-label="Toggle"
+						aria-label="Toggle Property"
 						type="button"
 					>
 						<Icon size={16} className="text-muted-foreground" />
@@ -143,7 +143,7 @@ export const TreeEntry: FC<TreeEntryProperties> = ({
 						{part}
 					</span>
 				))}
-			</div>
+			</li>
 			{open ? (
 				<SanitizeValue
 					path={path}
diff --git a/src/components/ui/popover.tsx b/src/components/ui/popover.tsx
index 3efc1d6..f874177 100644
--- a/src/components/ui/popover.tsx
+++ b/src/components/ui/popover.tsx
@@ -7,7 +7,13 @@ import { cn } from "@/lib/utils";
 
 const Popover = PopoverPrimitive.Root;
 
-const PopoverTrigger = PopoverPrimitive.Trigger;
+const PopoverTrigger = React.forwardRef<
+	React.ElementRef<typeof PopoverPrimitive.Trigger>,
+	React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Trigger>
+>(({ ...props }, ref) => (
+	<PopoverPrimitive.Trigger ref={ref} aria-haspopup="dialog" {...props} />
+));
+PopoverTrigger.displayName = PopoverPrimitive.Trigger.displayName;
 
 const PopoverContent = React.forwardRef<
 	React.ElementRef<typeof PopoverPrimitive.Content>,
@@ -18,6 +24,7 @@ const PopoverContent = React.forwardRef<
 			ref={ref}
 			align={align}
 			sideOffset={sideOffset}
+			role="dialog"
 			className={cn(
 				"z-50 w-72 rounded-md border border-card bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
 				className,
diff --git a/vite.config.ts b/vite.config.ts
index 12d2773..d489a45 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -21,4 +21,8 @@ export default defineConfig({
 	define: {
 		"process.env.NODE_DEBUG": JSON.stringify(process.env.NODE_DEBUG),
 	},
+	server: {
+		// accept connections from everywhere because Playwright browsers run from within a Docker container which has some random IP
+		allowedHosts: true,
+	},
 });