diff --git a/frontend/.gitignore b/frontend/.gitignore index e91699ff5893f3916c7d6d2f9455a02eb6ae636e..4084d88b867739e66ebd52add26f920fc7533971 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 0000000000000000000000000000000000000000..4a7c4f06b606e72a13d4f70fd19f5495c25bf485 --- /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 0000000000000000000000000000000000000000..19ce3eea33b04b7aba471f06a0cff511430f54ea --- /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 238e77e33d54257bad533e5bea31f6600db68e0e..3241b1cb10523091b498b23addeff023403fea0f 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 0000000000000000000000000000000000000000..dbee53ec602ed4325a0e07ee5a73c706052c128d --- /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 fa2d4e6f24020c7755103b3a391f260dcf2bee40..0bb0f36abd621395a415ed9115f7e7e40299bea6 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 0000000000000000000000000000000000000000..b105e788d047eed8955841a66c35f2a0a227586e --- /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 0000000000000000000000000000000000000000..787dd49e5857094a01d1ee1778c59a92a6736310 --- /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(); +});