From 3372e7d4bbe269b90968436d945a9ece3b1c1340 Mon Sep 17 00:00:00 2001
From: Brieuc Dubois <git@bhasher.com>
Date: Fri, 21 Feb 2025 08:26:32 +0100
Subject: [PATCH] First steps for e2e testing

---
 frontend/.gitignore                           |   6 +
 frontend/flake.lock                           |  61 +++
 frontend/flake.nix                            |  33 ++
 frontend/package.json                         |   3 +
 frontend/playwright.config.ts                 |  33 ++
 frontend/pnpm-lock.yaml                       | 107 +++--
 frontend/tests-examples/demo-todo-app.spec.ts | 424 ++++++++++++++++++
 frontend/tests/example.spec.ts                |  18 +
 8 files changed, 660 insertions(+), 25 deletions(-)
 create mode 100644 frontend/flake.lock
 create mode 100644 frontend/flake.nix
 create mode 100644 frontend/playwright.config.ts
 create mode 100644 frontend/tests-examples/demo-todo-app.spec.ts
 create mode 100644 frontend/tests/example.spec.ts

diff --git a/frontend/.gitignore b/frontend/.gitignore
index e91699ff..4084d88b 100644
--- a/frontend/.gitignore
+++ b/frontend/.gitignore
@@ -6,3 +6,9 @@ node_modules
 !.env.example
 vite.config.js.timestamp-*
 vite.config.ts.timestamp-*
+
+# Playwright
+/test-results/
+/playwright-report/
+/blob-report/
+/playwright/.cache/
diff --git a/frontend/flake.lock b/frontend/flake.lock
new file mode 100644
index 00000000..4a7c4f06
--- /dev/null
+++ b/frontend/flake.lock
@@ -0,0 +1,61 @@
+{
+	"nodes": {
+		"flake-utils": {
+			"inputs": {
+				"systems": "systems"
+			},
+			"locked": {
+				"lastModified": 1731533236,
+				"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
+				"owner": "numtide",
+				"repo": "flake-utils",
+				"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
+				"type": "github"
+			},
+			"original": {
+				"owner": "numtide",
+				"repo": "flake-utils",
+				"type": "github"
+			}
+		},
+		"nixpkgs": {
+			"locked": {
+				"lastModified": 1739214665,
+				"narHash": "sha256-26L8VAu3/1YRxS8MHgBOyOM8xALdo6N0I04PgorE7UM=",
+				"owner": "NixOS",
+				"repo": "nixpkgs",
+				"rev": "64e75cd44acf21c7933d61d7721e812eac1b5a0a",
+				"type": "github"
+			},
+			"original": {
+				"owner": "NixOS",
+				"ref": "nixos-unstable",
+				"repo": "nixpkgs",
+				"type": "github"
+			}
+		},
+		"root": {
+			"inputs": {
+				"flake-utils": "flake-utils",
+				"nixpkgs": "nixpkgs"
+			}
+		},
+		"systems": {
+			"locked": {
+				"lastModified": 1681028828,
+				"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
+				"owner": "nix-systems",
+				"repo": "default",
+				"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
+				"type": "github"
+			},
+			"original": {
+				"owner": "nix-systems",
+				"repo": "default",
+				"type": "github"
+			}
+		}
+	},
+	"root": "root",
+	"version": 7
+}
diff --git a/frontend/flake.nix b/frontend/flake.nix
new file mode 100644
index 00000000..19ce3eea
--- /dev/null
+++ b/frontend/flake.nix
@@ -0,0 +1,33 @@
+{
+  description = "Testing environment";
+
+  inputs = {
+    nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
+    flake-utils.url = "github:numtide/flake-utils";
+  };
+
+  outputs =
+    {
+      self,
+      nixpkgs,
+      flake-utils,
+    }:
+    flake-utils.lib.eachDefaultSystem (
+      system:
+      let
+        pkgs = nixpkgs.legacyPackages.${system};
+      in
+      {
+        devShells.default = pkgs.mkShell {
+          nativeBuildInputs = with pkgs; [
+            playwright-driver.browsers
+          ];
+
+          shellHook = ''
+            export PLAYWRIGHT_BROWSERS_PATH=${pkgs.playwright-driver.browsers}
+            export PLAYWRIGHT_SKIP_VALIDATE_HOST_REQUIREMENTS=true
+          '';
+        };
+      }
+    );
+}
diff --git a/frontend/package.json b/frontend/package.json
index 238e77e3..3241b1cb 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -13,6 +13,7 @@
 		"tsc": "tsc"
 	},
 	"devDependencies": {
+		"@playwright/test": "^1.50.1",
 		"@rollup/plugin-commonjs": "^28.0.1",
 		"@rollup/plugin-json": "^6.1.0",
 		"@sveltejs/adapter-auto": "^3.3.1",
@@ -24,6 +25,7 @@
 		"@tsconfig/svelte": "^5.0.4",
 		"@types/eslint": "^9.6.1",
 		"@types/js-cookie": "^3.0.6",
+		"@types/node": "^22.13.1",
 		"@types/sanitize-html": "^2.13.0",
 		"@typescript-eslint/eslint-plugin": "^8.14.0",
 		"@typescript-eslint/parser": "^8.14.0",
@@ -37,6 +39,7 @@
 		"eslint-plugin-svelte": "^2.46.0",
 		"jwt-decode": "^4.0.0",
 		"less": "^4.2.0",
+		"playwright": "^1.50.1",
 		"postcss": "^8.4.49",
 		"prettier": "^3.3.3",
 		"prettier-plugin-svelte": "^3.2.8",
diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts
new file mode 100644
index 00000000..dbee53ec
--- /dev/null
+++ b/frontend/playwright.config.ts
@@ -0,0 +1,33 @@
+import { defineConfig, devices } from '@playwright/test';
+
+export default defineConfig({
+	testDir: './tests',
+	fullyParallel: true,
+	forbidOnly: !!process.env.CI,
+	retries: 2,
+	workers: 1,
+	reporter: 'html',
+	use: {
+		baseURL: 'http://127.0.0.1:5173',
+		trace: 'on-first-retry'
+	},
+
+	projects: [
+		{
+			name: 'chromium',
+			use: { ...devices['Desktop Chrome'] }
+		},
+		{
+			name: 'firefox',
+			use: { ...devices['Desktop Firefox'] }
+		}
+	],
+
+	webServer: {
+		command:
+			'cd ../backend/app && source .env/bin/activate && JWT_SECRET_KEY=bonjour JWT_REFRESH_SECRET_KEY=bonjour ALLOWED_ORIGINS=http://127.0.0.1:5173 uvicorn main:app --no-reload',
+		url: 'http://127.0.0.1:8000/docs',
+		reuseExistingServer: !process.env.CI,
+		timeout: 120000
+	}
+});
diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml
index fa2d4e6f..0bb0f36a 100644
--- a/frontend/pnpm-lock.yaml
+++ b/frontend/pnpm-lock.yaml
@@ -42,6 +42,9 @@ importers:
         specifier: ^5.8.3
         version: 5.8.3
     devDependencies:
+      '@playwright/test':
+        specifier: ^1.50.1
+        version: 1.50.1
       '@rollup/plugin-commonjs':
         specifier: ^28.0.1
         version: 28.0.1(rollup@4.28.1)
@@ -50,19 +53,19 @@ importers:
         version: 6.1.0(rollup@4.28.1)
       '@sveltejs/adapter-auto':
         specifier: ^3.3.1
-        version: 3.3.1(@sveltejs/kit@2.9.0(@sveltejs/vite-plugin-svelte@4.0.2(svelte@5.8.1)(vite@5.4.11(less@4.2.1)))(svelte@5.8.1)(vite@5.4.11(less@4.2.1)))
+        version: 3.3.1(@sveltejs/kit@2.9.0(@sveltejs/vite-plugin-svelte@4.0.2(svelte@5.8.1)(vite@5.4.11(@types/node@22.13.1)(less@4.2.1)))(svelte@5.8.1)(vite@5.4.11(@types/node@22.13.1)(less@4.2.1)))
       '@sveltejs/adapter-node':
         specifier: ^5.2.9
-        version: 5.2.9(@sveltejs/kit@2.9.0(@sveltejs/vite-plugin-svelte@4.0.2(svelte@5.8.1)(vite@5.4.11(less@4.2.1)))(svelte@5.8.1)(vite@5.4.11(less@4.2.1)))
+        version: 5.2.9(@sveltejs/kit@2.9.0(@sveltejs/vite-plugin-svelte@4.0.2(svelte@5.8.1)(vite@5.4.11(@types/node@22.13.1)(less@4.2.1)))(svelte@5.8.1)(vite@5.4.11(@types/node@22.13.1)(less@4.2.1)))
       '@sveltejs/adapter-static':
         specifier: ^3.0.6
-        version: 3.0.6(@sveltejs/kit@2.9.0(@sveltejs/vite-plugin-svelte@4.0.2(svelte@5.8.1)(vite@5.4.11(less@4.2.1)))(svelte@5.8.1)(vite@5.4.11(less@4.2.1)))
+        version: 3.0.6(@sveltejs/kit@2.9.0(@sveltejs/vite-plugin-svelte@4.0.2(svelte@5.8.1)(vite@5.4.11(@types/node@22.13.1)(less@4.2.1)))(svelte@5.8.1)(vite@5.4.11(@types/node@22.13.1)(less@4.2.1)))
       '@sveltejs/kit':
         specifier: ^2.8.0
-        version: 2.9.0(@sveltejs/vite-plugin-svelte@4.0.2(svelte@5.8.1)(vite@5.4.11(less@4.2.1)))(svelte@5.8.1)(vite@5.4.11(less@4.2.1))
+        version: 2.9.0(@sveltejs/vite-plugin-svelte@4.0.2(svelte@5.8.1)(vite@5.4.11(@types/node@22.13.1)(less@4.2.1)))(svelte@5.8.1)(vite@5.4.11(@types/node@22.13.1)(less@4.2.1))
       '@sveltejs/vite-plugin-svelte':
         specifier: ^4.0.0
-        version: 4.0.2(svelte@5.8.1)(vite@5.4.11(less@4.2.1))
+        version: 4.0.2(svelte@5.8.1)(vite@5.4.11(@types/node@22.13.1)(less@4.2.1))
       '@tailwindcss/forms':
         specifier: ^0.5.9
         version: 0.5.9(tailwindcss@3.4.16)
@@ -75,6 +78,9 @@ importers:
       '@types/js-cookie':
         specifier: ^3.0.6
         version: 3.0.6
+      '@types/node':
+        specifier: ^22.13.1
+        version: 22.13.1
       '@types/sanitize-html':
         specifier: ^2.13.0
         version: 2.13.0
@@ -114,6 +120,9 @@ importers:
       less:
         specifier: ^4.2.0
         version: 4.2.1
+      playwright:
+        specifier: ^1.50.1
+        version: 1.50.1
       postcss:
         specifier: ^8.4.49
         version: 8.4.49
@@ -149,7 +158,7 @@ importers:
         version: 5.7.2
       vite:
         specifier: ^5.4.11
-        version: 5.4.11(less@4.2.1)
+        version: 5.4.11(@types/node@22.13.1)(less@4.2.1)
 
 packages:
 
@@ -553,6 +562,11 @@ packages:
     resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
     engines: {node: '>=14'}
 
+  '@playwright/test@1.50.1':
+    resolution: {integrity: sha512-Jii3aBg+CEDpgnuDxEp/h7BimHcUTDlpEtce89xEumlJ5ef2hqepZ+PWp1DDpYC/VO9fmWVI1IlEaoI5fK9FXQ==}
+    engines: {node: '>=18'}
+    hasBin: true
+
   '@polka/url@1.0.0-next.28':
     resolution: {integrity: sha512-8LduaNlMZGwdZ6qWrKlfa+2M4gahzFkprZiAt2TF8uS0qQgBizKXpXURqvTJ4WtmupWxaLqjRb2UCTe72mu+Aw==}
 
@@ -766,6 +780,9 @@ packages:
   '@types/json-schema@7.0.15':
     resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
 
+  '@types/node@22.13.1':
+    resolution: {integrity: sha512-jK8uzQlrvXqEU91UxiK5J7pKHyzgnI1Qnl0QDHIgVGuolJhRb9EEl28Cj9b3rGR8B2lhFCtvIm5os8lFnO/1Ew==}
+
   '@types/resolve@1.20.2':
     resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==}
 
@@ -1277,6 +1294,11 @@ packages:
   fraction.js@4.3.7:
     resolution: {integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==}
 
+  fsevents@2.3.2:
+    resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
+    engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
+    os: [darwin]
+
   fsevents@2.3.3:
     resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
     engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@@ -1645,6 +1667,16 @@ packages:
     resolution: {integrity: sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==}
     engines: {node: '>= 6'}
 
+  playwright-core@1.50.1:
+    resolution: {integrity: sha512-ra9fsNWayuYumt+NiM069M6OkcRb1FZSK8bgi66AtpFoWkg2+y0bJSNmkFrWhMbEBbVKC/EruAHH3g0zmtwGmQ==}
+    engines: {node: '>=18'}
+    hasBin: true
+
+  playwright@1.50.1:
+    resolution: {integrity: sha512-G8rwsOQJ63XG6BbKj2w5rHeavFjy5zynBA9zsJMMtBoe/Uf757oG12NXz6e6OirF7RCrTVAKFXbLmn1RbL7Qaw==}
+    engines: {node: '>=18'}
+    hasBin: true
+
   postcss-import@15.1.0:
     resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==}
     engines: {node: '>=14.0.0'}
@@ -1989,6 +2021,9 @@ packages:
     engines: {node: '>=14.17'}
     hasBin: true
 
+  undici-types@6.20.0:
+    resolution: {integrity: sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==}
+
   update-browserslist-db@1.1.1:
     resolution: {integrity: sha512-R8UzCaa9Az+38REPiJ1tXlImTJXlVfgHZsglwBD/k6nj76ctsH1E3q4doGrukiLQd3sGQYu56r5+lo5r94l29A==}
     hasBin: true
@@ -2351,6 +2386,10 @@ snapshots:
   '@pkgjs/parseargs@0.11.0':
     optional: true
 
+  '@playwright/test@1.50.1':
+    dependencies:
+      playwright: 1.50.1
+
   '@polka/url@1.0.0-next.28': {}
 
   '@rollup/plugin-commonjs@28.0.1(rollup@4.28.1)':
@@ -2448,26 +2487,26 @@ snapshots:
 
   '@steeze-ui/heroicons@2.4.2': {}
 
-  '@sveltejs/adapter-auto@3.3.1(@sveltejs/kit@2.9.0(@sveltejs/vite-plugin-svelte@4.0.2(svelte@5.8.1)(vite@5.4.11(less@4.2.1)))(svelte@5.8.1)(vite@5.4.11(less@4.2.1)))':
+  '@sveltejs/adapter-auto@3.3.1(@sveltejs/kit@2.9.0(@sveltejs/vite-plugin-svelte@4.0.2(svelte@5.8.1)(vite@5.4.11(@types/node@22.13.1)(less@4.2.1)))(svelte@5.8.1)(vite@5.4.11(@types/node@22.13.1)(less@4.2.1)))':
     dependencies:
-      '@sveltejs/kit': 2.9.0(@sveltejs/vite-plugin-svelte@4.0.2(svelte@5.8.1)(vite@5.4.11(less@4.2.1)))(svelte@5.8.1)(vite@5.4.11(less@4.2.1))
+      '@sveltejs/kit': 2.9.0(@sveltejs/vite-plugin-svelte@4.0.2(svelte@5.8.1)(vite@5.4.11(@types/node@22.13.1)(less@4.2.1)))(svelte@5.8.1)(vite@5.4.11(@types/node@22.13.1)(less@4.2.1))
       import-meta-resolve: 4.1.0
 
-  '@sveltejs/adapter-node@5.2.9(@sveltejs/kit@2.9.0(@sveltejs/vite-plugin-svelte@4.0.2(svelte@5.8.1)(vite@5.4.11(less@4.2.1)))(svelte@5.8.1)(vite@5.4.11(less@4.2.1)))':
+  '@sveltejs/adapter-node@5.2.9(@sveltejs/kit@2.9.0(@sveltejs/vite-plugin-svelte@4.0.2(svelte@5.8.1)(vite@5.4.11(@types/node@22.13.1)(less@4.2.1)))(svelte@5.8.1)(vite@5.4.11(@types/node@22.13.1)(less@4.2.1)))':
     dependencies:
       '@rollup/plugin-commonjs': 28.0.1(rollup@4.28.1)
       '@rollup/plugin-json': 6.1.0(rollup@4.28.1)
       '@rollup/plugin-node-resolve': 15.3.0(rollup@4.28.1)
-      '@sveltejs/kit': 2.9.0(@sveltejs/vite-plugin-svelte@4.0.2(svelte@5.8.1)(vite@5.4.11(less@4.2.1)))(svelte@5.8.1)(vite@5.4.11(less@4.2.1))
+      '@sveltejs/kit': 2.9.0(@sveltejs/vite-plugin-svelte@4.0.2(svelte@5.8.1)(vite@5.4.11(@types/node@22.13.1)(less@4.2.1)))(svelte@5.8.1)(vite@5.4.11(@types/node@22.13.1)(less@4.2.1))
       rollup: 4.28.1
 
-  '@sveltejs/adapter-static@3.0.6(@sveltejs/kit@2.9.0(@sveltejs/vite-plugin-svelte@4.0.2(svelte@5.8.1)(vite@5.4.11(less@4.2.1)))(svelte@5.8.1)(vite@5.4.11(less@4.2.1)))':
+  '@sveltejs/adapter-static@3.0.6(@sveltejs/kit@2.9.0(@sveltejs/vite-plugin-svelte@4.0.2(svelte@5.8.1)(vite@5.4.11(@types/node@22.13.1)(less@4.2.1)))(svelte@5.8.1)(vite@5.4.11(@types/node@22.13.1)(less@4.2.1)))':
     dependencies:
-      '@sveltejs/kit': 2.9.0(@sveltejs/vite-plugin-svelte@4.0.2(svelte@5.8.1)(vite@5.4.11(less@4.2.1)))(svelte@5.8.1)(vite@5.4.11(less@4.2.1))
+      '@sveltejs/kit': 2.9.0(@sveltejs/vite-plugin-svelte@4.0.2(svelte@5.8.1)(vite@5.4.11(@types/node@22.13.1)(less@4.2.1)))(svelte@5.8.1)(vite@5.4.11(@types/node@22.13.1)(less@4.2.1))
 
-  '@sveltejs/kit@2.9.0(@sveltejs/vite-plugin-svelte@4.0.2(svelte@5.8.1)(vite@5.4.11(less@4.2.1)))(svelte@5.8.1)(vite@5.4.11(less@4.2.1))':
+  '@sveltejs/kit@2.9.0(@sveltejs/vite-plugin-svelte@4.0.2(svelte@5.8.1)(vite@5.4.11(@types/node@22.13.1)(less@4.2.1)))(svelte@5.8.1)(vite@5.4.11(@types/node@22.13.1)(less@4.2.1))':
     dependencies:
-      '@sveltejs/vite-plugin-svelte': 4.0.2(svelte@5.8.1)(vite@5.4.11(less@4.2.1))
+      '@sveltejs/vite-plugin-svelte': 4.0.2(svelte@5.8.1)(vite@5.4.11(@types/node@22.13.1)(less@4.2.1))
       '@types/cookie': 0.6.0
       cookie: 0.6.0
       devalue: 5.1.1
@@ -2481,27 +2520,27 @@ snapshots:
       sirv: 3.0.0
       svelte: 5.8.1
       tiny-glob: 0.2.9
-      vite: 5.4.11(less@4.2.1)
+      vite: 5.4.11(@types/node@22.13.1)(less@4.2.1)
 
-  '@sveltejs/vite-plugin-svelte-inspector@3.0.1(@sveltejs/vite-plugin-svelte@4.0.2(svelte@5.8.1)(vite@5.4.11(less@4.2.1)))(svelte@5.8.1)(vite@5.4.11(less@4.2.1))':
+  '@sveltejs/vite-plugin-svelte-inspector@3.0.1(@sveltejs/vite-plugin-svelte@4.0.2(svelte@5.8.1)(vite@5.4.11(@types/node@22.13.1)(less@4.2.1)))(svelte@5.8.1)(vite@5.4.11(@types/node@22.13.1)(less@4.2.1))':
     dependencies:
-      '@sveltejs/vite-plugin-svelte': 4.0.2(svelte@5.8.1)(vite@5.4.11(less@4.2.1))
+      '@sveltejs/vite-plugin-svelte': 4.0.2(svelte@5.8.1)(vite@5.4.11(@types/node@22.13.1)(less@4.2.1))
       debug: 4.4.0
       svelte: 5.8.1
-      vite: 5.4.11(less@4.2.1)
+      vite: 5.4.11(@types/node@22.13.1)(less@4.2.1)
     transitivePeerDependencies:
       - supports-color
 
-  '@sveltejs/vite-plugin-svelte@4.0.2(svelte@5.8.1)(vite@5.4.11(less@4.2.1))':
+  '@sveltejs/vite-plugin-svelte@4.0.2(svelte@5.8.1)(vite@5.4.11(@types/node@22.13.1)(less@4.2.1))':
     dependencies:
-      '@sveltejs/vite-plugin-svelte-inspector': 3.0.1(@sveltejs/vite-plugin-svelte@4.0.2(svelte@5.8.1)(vite@5.4.11(less@4.2.1)))(svelte@5.8.1)(vite@5.4.11(less@4.2.1))
+      '@sveltejs/vite-plugin-svelte-inspector': 3.0.1(@sveltejs/vite-plugin-svelte@4.0.2(svelte@5.8.1)(vite@5.4.11(@types/node@22.13.1)(less@4.2.1)))(svelte@5.8.1)(vite@5.4.11(@types/node@22.13.1)(less@4.2.1))
       debug: 4.4.0
       deepmerge: 4.3.1
       kleur: 4.1.5
       magic-string: 0.30.14
       svelte: 5.8.1
-      vite: 5.4.11(less@4.2.1)
-      vitefu: 1.0.4(vite@5.4.11(less@4.2.1))
+      vite: 5.4.11(@types/node@22.13.1)(less@4.2.1)
+      vitefu: 1.0.4(vite@5.4.11(@types/node@22.13.1)(less@4.2.1))
     transitivePeerDependencies:
       - supports-color
 
@@ -2537,6 +2576,10 @@ snapshots:
 
   '@types/json-schema@7.0.15': {}
 
+  '@types/node@22.13.1':
+    dependencies:
+      undici-types: 6.20.0
+
   '@types/resolve@1.20.2': {}
 
   '@types/sanitize-html@2.13.0':
@@ -3134,6 +3177,9 @@ snapshots:
 
   fraction.js@4.3.7: {}
 
+  fsevents@2.3.2:
+    optional: true
+
   fsevents@2.3.3:
     optional: true
 
@@ -3455,6 +3501,14 @@ snapshots:
 
   pirates@4.0.6: {}
 
+  playwright-core@1.50.1: {}
+
+  playwright@1.50.1:
+    dependencies:
+      playwright-core: 1.50.1
+    optionalDependencies:
+      fsevents: 2.3.2
+
   postcss-import@15.1.0(postcss@8.4.49):
     dependencies:
       postcss: 8.4.49
@@ -3808,6 +3862,8 @@ snapshots:
 
   typescript@5.7.2: {}
 
+  undici-types@6.20.0: {}
+
   update-browserslist-db@1.1.1(browserslist@4.24.2):
     dependencies:
       browserslist: 4.24.2
@@ -3820,18 +3876,19 @@ snapshots:
 
   util-deprecate@1.0.2: {}
 
-  vite@5.4.11(less@4.2.1):
+  vite@5.4.11(@types/node@22.13.1)(less@4.2.1):
     dependencies:
       esbuild: 0.21.5
       postcss: 8.4.49
       rollup: 4.28.1
     optionalDependencies:
+      '@types/node': 22.13.1
       fsevents: 2.3.3
       less: 4.2.1
 
-  vitefu@1.0.4(vite@5.4.11(less@4.2.1)):
+  vitefu@1.0.4(vite@5.4.11(@types/node@22.13.1)(less@4.2.1)):
     optionalDependencies:
-      vite: 5.4.11(less@4.2.1)
+      vite: 5.4.11(@types/node@22.13.1)(less@4.2.1)
 
   which@2.0.2:
     dependencies:
diff --git a/frontend/tests-examples/demo-todo-app.spec.ts b/frontend/tests-examples/demo-todo-app.spec.ts
new file mode 100644
index 00000000..b105e788
--- /dev/null
+++ b/frontend/tests-examples/demo-todo-app.spec.ts
@@ -0,0 +1,424 @@
+import { test, expect, type Page } from '@playwright/test';
+
+test.beforeEach(async ({ page }) => {
+	await page.goto('https://demo.playwright.dev/todomvc');
+});
+
+const TODO_ITEMS = ['buy some cheese', 'feed the cat', 'book a doctors appointment'] as const;
+
+test.describe('New Todo', () => {
+	test('should allow me to add todo items', async ({ page }) => {
+		// create a new todo locator
+		const newTodo = page.getByPlaceholder('What needs to be done?');
+
+		// Create 1st todo.
+		await newTodo.fill(TODO_ITEMS[0]);
+		await newTodo.press('Enter');
+
+		// Make sure the list only has one todo item.
+		await expect(page.getByTestId('todo-title')).toHaveText([TODO_ITEMS[0]]);
+
+		// Create 2nd todo.
+		await newTodo.fill(TODO_ITEMS[1]);
+		await newTodo.press('Enter');
+
+		// Make sure the list now has two todo items.
+		await expect(page.getByTestId('todo-title')).toHaveText([TODO_ITEMS[0], TODO_ITEMS[1]]);
+
+		await checkNumberOfTodosInLocalStorage(page, 2);
+	});
+
+	test('should clear text input field when an item is added', async ({ page }) => {
+		// create a new todo locator
+		const newTodo = page.getByPlaceholder('What needs to be done?');
+
+		// Create one todo item.
+		await newTodo.fill(TODO_ITEMS[0]);
+		await newTodo.press('Enter');
+
+		// Check that input is empty.
+		await expect(newTodo).toBeEmpty();
+		await checkNumberOfTodosInLocalStorage(page, 1);
+	});
+
+	test('should append new items to the bottom of the list', async ({ page }) => {
+		// Create 3 items.
+		await createDefaultTodos(page);
+
+		// create a todo count locator
+		const todoCount = page.getByTestId('todo-count');
+
+		// Check test using different methods.
+		await expect(page.getByText('3 items left')).toBeVisible();
+		await expect(todoCount).toHaveText('3 items left');
+		await expect(todoCount).toContainText('3');
+		await expect(todoCount).toHaveText(/3/);
+
+		// Check all items in one call.
+		await expect(page.getByTestId('todo-title')).toHaveText(TODO_ITEMS);
+		await checkNumberOfTodosInLocalStorage(page, 3);
+	});
+});
+
+test.describe('Mark all as completed', () => {
+	test.beforeEach(async ({ page }) => {
+		await createDefaultTodos(page);
+		await checkNumberOfTodosInLocalStorage(page, 3);
+	});
+
+	test.afterEach(async ({ page }) => {
+		await checkNumberOfTodosInLocalStorage(page, 3);
+	});
+
+	test('should allow me to mark all items as completed', async ({ page }) => {
+		// Complete all todos.
+		await page.getByLabel('Mark all as complete').check();
+
+		// Ensure all todos have 'completed' class.
+		await expect(page.getByTestId('todo-item')).toHaveClass([
+			'completed',
+			'completed',
+			'completed'
+		]);
+		await checkNumberOfCompletedTodosInLocalStorage(page, 3);
+	});
+
+	test('should allow me to clear the complete state of all items', async ({ page }) => {
+		const toggleAll = page.getByLabel('Mark all as complete');
+		// Check and then immediately uncheck.
+		await toggleAll.check();
+		await toggleAll.uncheck();
+
+		// Should be no completed classes.
+		await expect(page.getByTestId('todo-item')).toHaveClass(['', '', '']);
+	});
+
+	test('complete all checkbox should update state when items are completed / cleared', async ({
+		page
+	}) => {
+		const toggleAll = page.getByLabel('Mark all as complete');
+		await toggleAll.check();
+		await expect(toggleAll).toBeChecked();
+		await checkNumberOfCompletedTodosInLocalStorage(page, 3);
+
+		// Uncheck first todo.
+		const firstTodo = page.getByTestId('todo-item').nth(0);
+		await firstTodo.getByRole('checkbox').uncheck();
+
+		// Reuse toggleAll locator and make sure its not checked.
+		await expect(toggleAll).not.toBeChecked();
+
+		await firstTodo.getByRole('checkbox').check();
+		await checkNumberOfCompletedTodosInLocalStorage(page, 3);
+
+		// Assert the toggle all is checked again.
+		await expect(toggleAll).toBeChecked();
+	});
+});
+
+test.describe('Item', () => {
+	test('should allow me to mark items as complete', async ({ page }) => {
+		// create a new todo locator
+		const newTodo = page.getByPlaceholder('What needs to be done?');
+
+		// Create two items.
+		for (const item of TODO_ITEMS.slice(0, 2)) {
+			await newTodo.fill(item);
+			await newTodo.press('Enter');
+		}
+
+		// Check first item.
+		const firstTodo = page.getByTestId('todo-item').nth(0);
+		await firstTodo.getByRole('checkbox').check();
+		await expect(firstTodo).toHaveClass('completed');
+
+		// Check second item.
+		const secondTodo = page.getByTestId('todo-item').nth(1);
+		await expect(secondTodo).not.toHaveClass('completed');
+		await secondTodo.getByRole('checkbox').check();
+
+		// Assert completed class.
+		await expect(firstTodo).toHaveClass('completed');
+		await expect(secondTodo).toHaveClass('completed');
+	});
+
+	test('should allow me to un-mark items as complete', async ({ page }) => {
+		// create a new todo locator
+		const newTodo = page.getByPlaceholder('What needs to be done?');
+
+		// Create two items.
+		for (const item of TODO_ITEMS.slice(0, 2)) {
+			await newTodo.fill(item);
+			await newTodo.press('Enter');
+		}
+
+		const firstTodo = page.getByTestId('todo-item').nth(0);
+		const secondTodo = page.getByTestId('todo-item').nth(1);
+		const firstTodoCheckbox = firstTodo.getByRole('checkbox');
+
+		await firstTodoCheckbox.check();
+		await expect(firstTodo).toHaveClass('completed');
+		await expect(secondTodo).not.toHaveClass('completed');
+		await checkNumberOfCompletedTodosInLocalStorage(page, 1);
+
+		await firstTodoCheckbox.uncheck();
+		await expect(firstTodo).not.toHaveClass('completed');
+		await expect(secondTodo).not.toHaveClass('completed');
+		await checkNumberOfCompletedTodosInLocalStorage(page, 0);
+	});
+
+	test('should allow me to edit an item', async ({ page }) => {
+		await createDefaultTodos(page);
+
+		const todoItems = page.getByTestId('todo-item');
+		const secondTodo = todoItems.nth(1);
+		await secondTodo.dblclick();
+		await expect(secondTodo.getByRole('textbox', { name: 'Edit' })).toHaveValue(TODO_ITEMS[1]);
+		await secondTodo.getByRole('textbox', { name: 'Edit' }).fill('buy some sausages');
+		await secondTodo.getByRole('textbox', { name: 'Edit' }).press('Enter');
+
+		// Explicitly assert the new text value.
+		await expect(todoItems).toHaveText([TODO_ITEMS[0], 'buy some sausages', TODO_ITEMS[2]]);
+		await checkTodosInLocalStorage(page, 'buy some sausages');
+	});
+});
+
+test.describe('Editing', () => {
+	test.beforeEach(async ({ page }) => {
+		await createDefaultTodos(page);
+		await checkNumberOfTodosInLocalStorage(page, 3);
+	});
+
+	test('should hide other controls when editing', async ({ page }) => {
+		const todoItem = page.getByTestId('todo-item').nth(1);
+		await todoItem.dblclick();
+		await expect(todoItem.getByRole('checkbox')).not.toBeVisible();
+		await expect(
+			todoItem.locator('label', {
+				hasText: TODO_ITEMS[1]
+			})
+		).not.toBeVisible();
+		await checkNumberOfTodosInLocalStorage(page, 3);
+	});
+
+	test('should save edits on blur', async ({ page }) => {
+		const todoItems = page.getByTestId('todo-item');
+		await todoItems.nth(1).dblclick();
+		await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).fill('buy some sausages');
+		await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).dispatchEvent('blur');
+
+		await expect(todoItems).toHaveText([TODO_ITEMS[0], 'buy some sausages', TODO_ITEMS[2]]);
+		await checkTodosInLocalStorage(page, 'buy some sausages');
+	});
+
+	test('should trim entered text', async ({ page }) => {
+		const todoItems = page.getByTestId('todo-item');
+		await todoItems.nth(1).dblclick();
+		await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).fill('    buy some sausages    ');
+		await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).press('Enter');
+
+		await expect(todoItems).toHaveText([TODO_ITEMS[0], 'buy some sausages', TODO_ITEMS[2]]);
+		await checkTodosInLocalStorage(page, 'buy some sausages');
+	});
+
+	test('should remove the item if an empty text string was entered', async ({ page }) => {
+		const todoItems = page.getByTestId('todo-item');
+		await todoItems.nth(1).dblclick();
+		await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).fill('');
+		await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).press('Enter');
+
+		await expect(todoItems).toHaveText([TODO_ITEMS[0], TODO_ITEMS[2]]);
+	});
+
+	test('should cancel edits on escape', async ({ page }) => {
+		const todoItems = page.getByTestId('todo-item');
+		await todoItems.nth(1).dblclick();
+		await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).fill('buy some sausages');
+		await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).press('Escape');
+		await expect(todoItems).toHaveText(TODO_ITEMS);
+	});
+});
+
+test.describe('Counter', () => {
+	test('should display the current number of todo items', async ({ page }) => {
+		// create a new todo locator
+		const newTodo = page.getByPlaceholder('What needs to be done?');
+
+		// create a todo count locator
+		const todoCount = page.getByTestId('todo-count');
+
+		await newTodo.fill(TODO_ITEMS[0]);
+		await newTodo.press('Enter');
+
+		await expect(todoCount).toContainText('1');
+
+		await newTodo.fill(TODO_ITEMS[1]);
+		await newTodo.press('Enter');
+		await expect(todoCount).toContainText('2');
+
+		await checkNumberOfTodosInLocalStorage(page, 2);
+	});
+});
+
+test.describe('Clear completed button', () => {
+	test.beforeEach(async ({ page }) => {
+		await createDefaultTodos(page);
+	});
+
+	test('should display the correct text', async ({ page }) => {
+		await page.locator('.todo-list li .toggle').first().check();
+		await expect(page.getByRole('button', { name: 'Clear completed' })).toBeVisible();
+	});
+
+	test('should remove completed items when clicked', async ({ page }) => {
+		const todoItems = page.getByTestId('todo-item');
+		await todoItems.nth(1).getByRole('checkbox').check();
+		await page.getByRole('button', { name: 'Clear completed' }).click();
+		await expect(todoItems).toHaveCount(2);
+		await expect(todoItems).toHaveText([TODO_ITEMS[0], TODO_ITEMS[2]]);
+	});
+
+	test('should be hidden when there are no items that are completed', async ({ page }) => {
+		await page.locator('.todo-list li .toggle').first().check();
+		await page.getByRole('button', { name: 'Clear completed' }).click();
+		await expect(page.getByRole('button', { name: 'Clear completed' })).toBeHidden();
+	});
+});
+
+test.describe('Persistence', () => {
+	test('should persist its data', async ({ page }) => {
+		// create a new todo locator
+		const newTodo = page.getByPlaceholder('What needs to be done?');
+
+		for (const item of TODO_ITEMS.slice(0, 2)) {
+			await newTodo.fill(item);
+			await newTodo.press('Enter');
+		}
+
+		const todoItems = page.getByTestId('todo-item');
+		const firstTodoCheck = todoItems.nth(0).getByRole('checkbox');
+		await firstTodoCheck.check();
+		await expect(todoItems).toHaveText([TODO_ITEMS[0], TODO_ITEMS[1]]);
+		await expect(firstTodoCheck).toBeChecked();
+		await expect(todoItems).toHaveClass(['completed', '']);
+
+		// Ensure there is 1 completed item.
+		await checkNumberOfCompletedTodosInLocalStorage(page, 1);
+
+		// Now reload.
+		await page.reload();
+		await expect(todoItems).toHaveText([TODO_ITEMS[0], TODO_ITEMS[1]]);
+		await expect(firstTodoCheck).toBeChecked();
+		await expect(todoItems).toHaveClass(['completed', '']);
+	});
+});
+
+test.describe('Routing', () => {
+	test.beforeEach(async ({ page }) => {
+		await createDefaultTodos(page);
+		// make sure the app had a chance to save updated todos in storage
+		// before navigating to a new view, otherwise the items can get lost :(
+		// in some frameworks like Durandal
+		await checkTodosInLocalStorage(page, TODO_ITEMS[0]);
+	});
+
+	test('should allow me to display active items', async ({ page }) => {
+		const todoItem = page.getByTestId('todo-item');
+		await page.getByTestId('todo-item').nth(1).getByRole('checkbox').check();
+
+		await checkNumberOfCompletedTodosInLocalStorage(page, 1);
+		await page.getByRole('link', { name: 'Active' }).click();
+		await expect(todoItem).toHaveCount(2);
+		await expect(todoItem).toHaveText([TODO_ITEMS[0], TODO_ITEMS[2]]);
+	});
+
+	test('should respect the back button', async ({ page }) => {
+		const todoItem = page.getByTestId('todo-item');
+		await page.getByTestId('todo-item').nth(1).getByRole('checkbox').check();
+
+		await checkNumberOfCompletedTodosInLocalStorage(page, 1);
+
+		await test.step('Showing all items', async () => {
+			await page.getByRole('link', { name: 'All' }).click();
+			await expect(todoItem).toHaveCount(3);
+		});
+
+		await test.step('Showing active items', async () => {
+			await page.getByRole('link', { name: 'Active' }).click();
+		});
+
+		await test.step('Showing completed items', async () => {
+			await page.getByRole('link', { name: 'Completed' }).click();
+		});
+
+		await expect(todoItem).toHaveCount(1);
+		await page.goBack();
+		await expect(todoItem).toHaveCount(2);
+		await page.goBack();
+		await expect(todoItem).toHaveCount(3);
+	});
+
+	test('should allow me to display completed items', async ({ page }) => {
+		await page.getByTestId('todo-item').nth(1).getByRole('checkbox').check();
+		await checkNumberOfCompletedTodosInLocalStorage(page, 1);
+		await page.getByRole('link', { name: 'Completed' }).click();
+		await expect(page.getByTestId('todo-item')).toHaveCount(1);
+	});
+
+	test('should allow me to display all items', async ({ page }) => {
+		await page.getByTestId('todo-item').nth(1).getByRole('checkbox').check();
+		await checkNumberOfCompletedTodosInLocalStorage(page, 1);
+		await page.getByRole('link', { name: 'Active' }).click();
+		await page.getByRole('link', { name: 'Completed' }).click();
+		await page.getByRole('link', { name: 'All' }).click();
+		await expect(page.getByTestId('todo-item')).toHaveCount(3);
+	});
+
+	test('should highlight the currently applied filter', async ({ page }) => {
+		await expect(page.getByRole('link', { name: 'All' })).toHaveClass('selected');
+
+		//create locators for active and completed links
+		const activeLink = page.getByRole('link', { name: 'Active' });
+		const completedLink = page.getByRole('link', { name: 'Completed' });
+		await activeLink.click();
+
+		// Page change - active items.
+		await expect(activeLink).toHaveClass('selected');
+		await completedLink.click();
+
+		// Page change - completed items.
+		await expect(completedLink).toHaveClass('selected');
+	});
+});
+
+async function createDefaultTodos(page: Page) {
+	// create a new todo locator
+	const newTodo = page.getByPlaceholder('What needs to be done?');
+
+	for (const item of TODO_ITEMS) {
+		await newTodo.fill(item);
+		await newTodo.press('Enter');
+	}
+}
+
+async function checkNumberOfTodosInLocalStorage(page: Page, expected: number) {
+	return await page.waitForFunction((e) => {
+		return JSON.parse(localStorage['react-todos']).length === e;
+	}, expected);
+}
+
+async function checkNumberOfCompletedTodosInLocalStorage(page: Page, expected: number) {
+	return await page.waitForFunction((e) => {
+		return (
+			JSON.parse(localStorage['react-todos']).filter((todo: any) => todo.completed).length === e
+		);
+	}, expected);
+}
+
+async function checkTodosInLocalStorage(page: Page, title: string) {
+	return await page.waitForFunction((t) => {
+		return JSON.parse(localStorage['react-todos'])
+			.map((todo: any) => todo.title)
+			.includes(t);
+	}, title);
+}
diff --git a/frontend/tests/example.spec.ts b/frontend/tests/example.spec.ts
new file mode 100644
index 00000000..787dd49e
--- /dev/null
+++ b/frontend/tests/example.spec.ts
@@ -0,0 +1,18 @@
+import { test, expect } from '@playwright/test';
+
+test('has title', async ({ page }) => {
+	await page.goto('https://playwright.dev/');
+
+	// Expect a title "to contain" a substring.
+	await expect(page).toHaveTitle(/Playwright/);
+});
+
+test('get started link', async ({ page }) => {
+	await page.goto('https://playwright.dev/');
+
+	// Click the get started link.
+	await page.getByRole('link', { name: 'Get started' }).click();
+
+	// Expects page to have a heading with the name of Installation.
+	await expect(page.getByRole('heading', { name: 'Installation' })).toBeVisible();
+});
-- 
GitLab