From ab4dff738f8e950b0726e36f3124d3ffc00b763b Mon Sep 17 00:00:00 2001 From: Leendert de Borst Date: Mon, 8 Dec 2025 20:43:32 +0100 Subject: [PATCH] Update e2e tests (#1404) --- apps/browser-extension/.gitignore | 1 + apps/browser-extension/package-lock.json | 459 ++++++++++++++++++ apps/browser-extension/package.json | 3 + apps/browser-extension/playwright.config.ts | 51 +- .../Credentials/Details/FieldBlock.tsx | 3 +- .../src/entrypoints/popup/utils/SrpUtility.ts | 2 +- .../tests/e2e/01-extension-loading.spec.ts | 46 ++ .../tests/e2e/02-ui-navigation.spec.ts | 57 +++ .../tests/e2e/03-authentication.spec.ts | 53 ++ .../tests/e2e/04-post-login.spec.ts | 38 ++ .../browser-extension/tests/extension.spec.ts | 62 --- apps/browser-extension/tests/fixtures.ts | 59 --- .../tests/fixtures/fixtures.ts | 259 ++++++++++ .../browser-extension/tests/fixtures/index.ts | 17 + .../tests/global-teardown.ts | 11 + .../tests/helpers/test-api.ts | 432 +++++++++++++++++ 16 files changed, 1423 insertions(+), 130 deletions(-) create mode 100644 apps/browser-extension/tests/e2e/01-extension-loading.spec.ts create mode 100644 apps/browser-extension/tests/e2e/02-ui-navigation.spec.ts create mode 100644 apps/browser-extension/tests/e2e/03-authentication.spec.ts create mode 100644 apps/browser-extension/tests/e2e/04-post-login.spec.ts delete mode 100644 apps/browser-extension/tests/extension.spec.ts delete mode 100644 apps/browser-extension/tests/fixtures.ts create mode 100644 apps/browser-extension/tests/fixtures/fixtures.ts create mode 100644 apps/browser-extension/tests/fixtures/index.ts create mode 100644 apps/browser-extension/tests/global-teardown.ts create mode 100644 apps/browser-extension/tests/helpers/test-api.ts diff --git a/apps/browser-extension/.gitignore b/apps/browser-extension/.gitignore index 302b2e412..7c0502804 100644 --- a/apps/browser-extension/.gitignore +++ b/apps/browser-extension/.gitignore @@ -28,4 +28,5 @@ web-ext.config.ts # Test related output files playwright-report tests/screenshots +tests/test-results test-results diff --git a/apps/browser-extension/package-lock.json b/apps/browser-extension/package-lock.json index 4c4714d0c..d9c5eff33 100644 --- a/apps/browser-extension/package-lock.json +++ b/apps/browser-extension/package-lock.json @@ -29,6 +29,7 @@ }, "devDependencies": { "@playwright/test": "^1.57.0", + "@types/better-sqlite3": "^7.6.13", "@types/chrome": "^0.0.280", "@types/jsdom": "^21.1.7", "@types/node": "^22.13.10", @@ -41,7 +42,9 @@ "@typescript-eslint/parser": "^8.21.0", "@vitest/coverage-v8": "^3.0.8", "@wxt-dev/module-react": "^1.1.2", + "argon2": "^0.44.0", "autoprefixer": "^10.4.20", + "better-sqlite3": "^12.5.0", "eslint": "^9.19.0", "eslint-import-resolver-typescript": "^4.4.3", "eslint-plugin-import": "^2.31.0", @@ -739,6 +742,13 @@ "tslib": "^2.4.0" } }, + "node_modules/@epic-web/invariant": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz", + "integrity": "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==", + "dev": true, + "license": "MIT" + }, "node_modules/@es-joy/jsdoccomment": { "version": "0.49.0", "resolved": "https://registry.npmjs.org/@es-joy/jsdoccomment/-/jsdoccomment-0.49.0.tgz", @@ -1600,6 +1610,16 @@ "node": ">= 8" } }, + "node_modules/@phc/format": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@phc/format/-/format-1.0.0.tgz", + "integrity": "sha512-m7X9U6BG2+J+R1lSOdCiITLLrxm+cWlNI3HUFA92oLO77ObGNzaKdh8pMLqdZcshtkKuV84olNNXDfMc4FezBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -2044,6 +2064,16 @@ "@babel/types": "^7.20.7" } }, + "node_modules/@types/better-sqlite3": { + "version": "7.6.13", + "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", + "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/chrome": { "version": "0.0.280", "resolved": "https://registry.npmjs.org/@types/chrome/-/chrome-0.0.280.tgz", @@ -3051,6 +3081,23 @@ "dev": true, "license": "MIT" }, + "node_modules/argon2": { + "version": "0.44.0", + "resolved": "https://registry.npmjs.org/argon2/-/argon2-0.44.0.tgz", + "integrity": "sha512-zHPGN3S55sihSQo0dBbK0A5qpi2R31z7HZDZnry3ifOyj8bZZnpZND2gpmhnRGO1V/d555RwBqIK5W4Mrmv3ig==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@phc/format": "^1.0.0", + "cross-env": "^10.0.0", + "node-addon-api": "^8.5.0", + "node-gyp-build": "^4.8.4" + }, + "engines": { + "node": ">=16.17.0" + } + }, "node_modules/argon2-browser": { "version": "1.18.0", "resolved": "https://registry.npmjs.org/argon2-browser/-/argon2-browser-1.18.0.tgz", @@ -3392,6 +3439,21 @@ ], "license": "MIT" }, + "node_modules/better-sqlite3": { + "version": "12.5.0", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.5.0.tgz", + "integrity": "sha512-WwCZ/5Diz7rsF29o27o0Gcc1Du+l7Zsv7SYtVPG0X3G/uUI1LqdxrQI7c9Hs2FWpqXXERjW9hp6g3/tH7DlVKg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + }, + "engines": { + "node": "20.x || 22.x || 23.x || 24.x || 25.x" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -3405,6 +3467,68 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/bl/node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/bl/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/bluebird": { "version": "3.7.2", "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", @@ -3889,6 +4013,13 @@ "node": ">= 6" } }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "dev": true, + "license": "ISC" + }, "node_modules/chrome-launcher": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/chrome-launcher/-/chrome-launcher-1.2.0.tgz", @@ -4261,6 +4392,24 @@ "dev": true, "license": "MIT" }, + "node_modules/cross-env": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz", + "integrity": "sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@epic-web/invariant": "^1.0.0", + "cross-spawn": "^7.0.6" + }, + "bin": { + "cross-env": "dist/bin/cross-env.js", + "cross-env-shell": "dist/bin/cross-env-shell.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -4470,6 +4619,22 @@ "devOptional": true, "license": "MIT" }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/deep-eql": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", @@ -4599,6 +4764,16 @@ "dev": true, "license": "MIT" }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/didyoumean": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", @@ -4817,6 +4992,16 @@ "integrity": "sha512-ucAnuBEhUK4boH2HjVYG5Q2mQyPorvv0u/ocS+zhdw0S8AlHYY+GOFhP1Gio5z4icpP2ivFSvhtFjQi8+T9ppw==", "license": "MIT" }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, "node_modules/entities": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.0.tgz", @@ -5657,6 +5842,16 @@ "dev": true, "license": "MIT" }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "dev": true, + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, "node_modules/expect-type": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.2.1.tgz", @@ -5757,6 +5952,13 @@ "node": ">=16.0.0" } }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "dev": true, + "license": "MIT" + }, "node_modules/filesize": { "version": "11.0.13", "resolved": "https://registry.npmjs.org/filesize/-/filesize-11.0.13.tgz", @@ -5905,6 +6107,13 @@ "url": "https://github.com/sponsors/rawify" } }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "dev": true, + "license": "MIT" + }, "node_modules/fs-extra": { "version": "11.3.2", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.2.tgz", @@ -6154,6 +6363,13 @@ "giget": "dist/cli.mjs" } }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "dev": true, + "license": "MIT" + }, "node_modules/glob": { "version": "10.5.0", "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", @@ -8068,6 +8284,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/minimatch": { "version": "9.0.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", @@ -8104,6 +8333,13 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "dev": true, + "license": "MIT" + }, "node_modules/mlly": { "version": "1.7.4", "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.7.4.tgz", @@ -8237,6 +8473,13 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "dev": true, + "license": "MIT" + }, "node_modules/napi-postinstall": { "version": "0.2.4", "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.2.4.tgz", @@ -8260,6 +8503,29 @@ "dev": true, "license": "MIT" }, + "node_modules/node-abi": { + "version": "3.85.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.85.0.tgz", + "integrity": "sha512-zsFhmbkAzwhTft6nd3VxcG0cvJsT70rL+BIGHWVq5fi6MwGrHwzqKaxXE+Hl2GmnGItnDKPPkO5/LQqjVkIdFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-addon-api": { + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.5.0.tgz", + "integrity": "sha512-/bRZty2mXUIFY/xU5HLvveNHlswNJej+RnxBjOMkidWfwZzgTbPG1E3K5TOxRLOR+5hX7bSofy8yf1hZevMS8A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18 || ^20 || >= 21" + } + }, "node_modules/node-fetch-native": { "version": "1.6.7", "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz", @@ -8277,6 +8543,18 @@ "node": ">= 6.13.0" } }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "dev": true, + "license": "MIT", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, "node_modules/node-notifier": { "version": "10.0.1", "resolved": "https://registry.npmjs.org/node-notifier/-/node-notifier-10.0.1.tgz", @@ -8557,6 +8835,16 @@ "node": ">=14.0.0" } }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, "node_modules/open": { "version": "10.2.0", "resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz", @@ -9252,6 +9540,33 @@ "dev": true, "license": "MIT" }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "dev": true, + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -9368,6 +9683,17 @@ "url": "https://github.com/sponsors/colinhacks" } }, + "node_modules/pump": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", + "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -10141,6 +10467,27 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/safe-push-apply": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", @@ -10488,6 +10835,53 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, "node_modules/sisteransi": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", @@ -11061,6 +11455,51 @@ "node": ">=14.0.0" } }, + "node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tar-stream/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/test-exclude": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.1.tgz", @@ -11337,6 +11776,19 @@ "dev": true, "license": "0BSD" }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -12396,6 +12848,13 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, "node_modules/ws": { "version": "8.18.2", "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.2.tgz", diff --git a/apps/browser-extension/package.json b/apps/browser-extension/package.json index 51a3f4af0..18bc1f427 100644 --- a/apps/browser-extension/package.json +++ b/apps/browser-extension/package.json @@ -48,6 +48,7 @@ }, "devDependencies": { "@playwright/test": "^1.57.0", + "@types/better-sqlite3": "^7.6.13", "@types/chrome": "^0.0.280", "@types/jsdom": "^21.1.7", "@types/node": "^22.13.10", @@ -60,7 +61,9 @@ "@typescript-eslint/parser": "^8.21.0", "@vitest/coverage-v8": "^3.0.8", "@wxt-dev/module-react": "^1.1.2", + "argon2": "^0.44.0", "autoprefixer": "^10.4.20", + "better-sqlite3": "^12.5.0", "eslint": "^9.19.0", "eslint-import-resolver-typescript": "^4.4.3", "eslint-plugin-import": "^2.31.0", diff --git a/apps/browser-extension/playwright.config.ts b/apps/browser-extension/playwright.config.ts index 23d81905e..534a2d6c0 100644 --- a/apps/browser-extension/playwright.config.ts +++ b/apps/browser-extension/playwright.config.ts @@ -1,23 +1,57 @@ import { defineConfig, devices } from '@playwright/test'; -import path from 'path'; /** * Playwright configuration for browser extension E2E tests. * * These tests load the real extension and interact with it using Playwright. * The extension must be built first using `npm run build:chrome`. + * + * Test Organization: + * - Tests are numbered (1.x, 2.x, etc.) and run in alphabetical order + * - fullyParallel is disabled to ensure tests run sequentially + * - Each test gets a fresh browser context via fixtures */ export default defineConfig({ - testDir: './tests', - fullyParallel: true, + testDir: './tests/e2e', + + // Global teardown to clean up browser contexts + globalTeardown: './tests/global-teardown.ts', + + // Run tests sequentially to ensure predictable order + fullyParallel: false, + + // Fail the build on CI if you accidentally left test.only in the source code forbidOnly: !!process.env.CI, - retries: process.env.CI ? 2 : 0, - workers: process.env.CI ? 1 : undefined, - reporter: 'html', - timeout: 30000, + + // Retry failed tests (more retries on CI) + retries: process.env.CI ? 2 : 1, + + // Use single worker to ensure sequential execution + workers: 1, + + // Reporter configuration + reporter: [ + ['html', { open: 'never' }], + ['list'], // Show test names in console + ], + + // Global timeout for each test + timeout: 60000, + + // Expect timeout + expect: { + timeout: 10000, + }, use: { + // Collect trace on first retry trace: 'on-first-retry', + + // Take screenshot on failure + screenshot: 'only-on-failure', + + // Record video on first retry + video: 'on-first-retry', }, projects: [ @@ -32,4 +66,7 @@ export default defineConfig({ }, }, ], + + // Output folder for test artifacts + outputDir: 'tests/test-results', }); diff --git a/apps/browser-extension/src/entrypoints/popup/components/Credentials/Details/FieldBlock.tsx b/apps/browser-extension/src/entrypoints/popup/components/Credentials/Details/FieldBlock.tsx index ee713ebb7..c1d80507f 100644 --- a/apps/browser-extension/src/entrypoints/popup/components/Credentials/Details/FieldBlock.tsx +++ b/apps/browser-extension/src/entrypoints/popup/components/Credentials/Details/FieldBlock.tsx @@ -2,12 +2,13 @@ import React, { useState, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { FormInputCopyToClipboard } from '@/entrypoints/popup/components/Forms/FormInputCopyToClipboard'; -import FieldHistoryModal from './FieldHistoryModal'; import { useDb } from '@/entrypoints/popup/context/DbContext'; import type { ItemField } from '@/utils/dist/shared/models/vault'; import { getSystemField } from '@/utils/dist/shared/models/vault'; +import FieldHistoryModal from './FieldHistoryModal'; + interface FieldBlockProps { field: ItemField; itemId?: string; diff --git a/apps/browser-extension/src/entrypoints/popup/utils/SrpUtility.ts b/apps/browser-extension/src/entrypoints/popup/utils/SrpUtility.ts index bff675451..5f1c83834 100644 --- a/apps/browser-extension/src/entrypoints/popup/utils/SrpUtility.ts +++ b/apps/browser-extension/src/entrypoints/popup/utils/SrpUtility.ts @@ -1,7 +1,7 @@ +import { SrpAuthService } from '@/utils/auth/SrpAuthService'; import type { LoginResponse, ValidateLoginResponse, ValidateLoginRequest, ValidateLoginRequest2Fa, BadRequestResponse } from '@/utils/dist/shared/models/webapi'; import { ApiAuthError } from '@/utils/types/errors/ApiAuthError'; import { WebApiService } from '@/utils/WebApiService'; -import { SrpAuthService } from '@/utils/auth/SrpAuthService'; /** * Utility class for SRP authentication operations. diff --git a/apps/browser-extension/tests/e2e/01-extension-loading.spec.ts b/apps/browser-extension/tests/e2e/01-extension-loading.spec.ts new file mode 100644 index 000000000..56514137c --- /dev/null +++ b/apps/browser-extension/tests/e2e/01-extension-loading.spec.ts @@ -0,0 +1,46 @@ +import type { Page } from '@playwright/test'; + +import { test, expect, openPopup } from '../fixtures'; + +/** + * Category 1: Extension Loading (No API required) + * + * These tests verify the extension loads and renders correctly. + * They don't require an API server to be running. + * Tests run sequentially and share the same popup page. + */ +test.describe.serial('1. Extension Loading', () => { + let popup: Page; + + test.afterAll(async () => { + // Close popup to start fresh for next test group + await popup?.close(); + }); + + test('1.1 should load the popup and show login form', async ({ context, extensionId }) => { + // Open popup (first test opens it, subsequent tests reuse it) + popup = await openPopup(context, extensionId); + + // Check that React rendered (the app container should exist) + const appContent = popup.locator('#root'); + await expect(appContent).toBeVisible(); + + // Should show login form elements + await expect(popup.locator('input[type="text"]')).toBeVisible(); + await expect(popup.locator('input[type="password"]')).toBeVisible(); + + // Take a screenshot for debugging + await popup.screenshot({ path: 'tests/screenshots/1.1-popup-loaded.png' }); + }); + + test('1.2 should have a running service worker', async ({ context, extensionId }) => { + // Service worker should be running + const serviceWorkers = context.serviceWorkers(); + expect(serviceWorkers.length).toBeGreaterThan(0); + + // The service worker URL should match our extension + const swUrl = serviceWorkers[0].url(); + expect(swUrl).toContain(extensionId); + expect(swUrl).toContain('background'); + }); +}); diff --git a/apps/browser-extension/tests/e2e/02-ui-navigation.spec.ts b/apps/browser-extension/tests/e2e/02-ui-navigation.spec.ts new file mode 100644 index 000000000..f91bd2741 --- /dev/null +++ b/apps/browser-extension/tests/e2e/02-ui-navigation.spec.ts @@ -0,0 +1,57 @@ +import type { Page } from '@playwright/test'; + +import { test, expect, openPopup, configureApiUrl } from '../fixtures'; + +/** + * Category 2: UI Navigation (No API required) + * + * These tests verify UI navigation works without needing authentication. + * They don't require an API server to be running. + * Tests run sequentially and share the same popup page. + */ +test.describe.serial('2. UI Navigation', () => { + let popup: Page; + + test.afterAll(async () => { + // Close popup to start fresh for next test group + await popup?.close(); + }); + + test('2.1 should have working settings button', async ({ context, extensionId }) => { + // Open popup (first test opens it) + popup = await openPopup(context, extensionId); + + // Click settings button + const settingsButton = popup.locator('button#settings'); + await expect(settingsButton).toBeVisible(); + await settingsButton.click(); + + // Should navigate to settings page + // Check for settings-related elements (like the server selection dropdown) + await expect(popup.locator('select')).toBeVisible(); + + // Take a screenshot + await popup.screenshot({ path: 'tests/screenshots/2.1-settings-navigation.png' }); + + // Should have a back button + const backButton = popup.locator('button#back'); + await expect(backButton).toBeVisible(); + + // Click back and verify we return to login + await backButton.click(); + await expect(popup.locator('input[type="text"]')).toBeVisible(); + }); + + test('2.2 should allow configuring custom API URL', async ({ apiUrl }) => { + // Reuse popup from previous test + // Configure the API URL + await configureApiUrl(popup, apiUrl); + + // After going back, we should still be on the login page + await expect(popup.locator('input[type="text"]')).toBeVisible(); + await expect(popup.locator('input[type="password"]')).toBeVisible(); + + // Take a screenshot + await popup.screenshot({ path: 'tests/screenshots/2.2-api-configured.png' }); + }); +}); diff --git a/apps/browser-extension/tests/e2e/03-authentication.spec.ts b/apps/browser-extension/tests/e2e/03-authentication.spec.ts new file mode 100644 index 000000000..2f9ba1266 --- /dev/null +++ b/apps/browser-extension/tests/e2e/03-authentication.spec.ts @@ -0,0 +1,53 @@ +import type { Page } from '@playwright/test'; + +import { test, expect, openPopup, configureApiUrl, login, waitForLoggedIn } from '../fixtures'; + +/** + * Category 3: Authentication Flow (Requires API) + * + * These tests verify login/authentication works correctly. + * They require an API server to be running at localhost:5092. + * Tests run sequentially and share the same popup page. + */ +test.describe.serial('3. Authentication Flow', () => { + let popup: Page; + + test.afterAll(async () => { + // Close popup to start fresh for next test group + await popup?.close(); + }); + + test('3.1 should display error for invalid credentials', async ({ context, extensionId, apiUrl }) => { + // Open popup (first test opens it) + popup = await openPopup(context, extensionId); + + // Configure API URL + await configureApiUrl(popup, apiUrl); + + // Try to login with invalid credentials + await popup.fill('input[type="text"]', 'nonexistent@test.com'); + await popup.fill('input[type="password"]', 'wrongpassword'); + await popup.click('button:has-text("Log in")'); + + // Wait for error message to appear + await expect(popup.locator('text=Invalid username or password')).toBeVisible({ timeout: 10000 }); + + // Take a screenshot + await popup.screenshot({ path: 'tests/screenshots/3.1-login-failed.png' }); + + // Clear the form for the next test + await popup.fill('input[type="text"]', ''); + await popup.fill('input[type="password"]', ''); + }); + + test('3.2 should successfully login with valid credentials', async ({ testUser }) => { + // Login with valid credentials + await login(popup, testUser.username, testUser.password); + + // Verify we're logged in + await waitForLoggedIn(popup); + + // Take a screenshot + await popup.screenshot({ path: 'tests/screenshots/3.2-login-success.png' }); + }); +}); diff --git a/apps/browser-extension/tests/e2e/04-post-login.spec.ts b/apps/browser-extension/tests/e2e/04-post-login.spec.ts new file mode 100644 index 000000000..20d9e966f --- /dev/null +++ b/apps/browser-extension/tests/e2e/04-post-login.spec.ts @@ -0,0 +1,38 @@ +import type { Page } from '@playwright/test'; + +import { test, expect, openPopup, fullLoginFlow, waitForLoggedIn } from '../fixtures'; + +/** + * Category 4: Post-Login Features (Requires API + Authentication) + * + * These tests verify functionality after successful login. + * They require an API server to be running at localhost:5092. + * Tests run sequentially and share the same popup page (already logged in). + */ +test.describe.serial('4. Post-Login Features', () => { + let popup: Page; + + test('4.1 should show vault content after login', async ({ context, extensionId, testUser, apiUrl }) => { + // Open popup and login + popup = await openPopup(context, extensionId); + await fullLoginFlow(popup, apiUrl, testUser.username, testUser.password); + + // Wait for the vault to load + await waitForLoggedIn(popup); + + // For a new user, the vault should be empty but rendered + const rootContent = await popup.locator('#root').textContent(); + expect(rootContent).toBeTruthy(); + expect(rootContent!.length).toBeGreaterThan(0); + + // Take a screenshot + await popup.screenshot({ path: 'tests/screenshots/4.1-vault-content.png' }); + }); + + test('4.2 should be able to navigate tabs after login', async () => { + await waitForLoggedIn(popup); + + // Take a screenshot + await popup.screenshot({ path: 'tests/screenshots/4.2-post-login-navigation.png' }); + }); +}); diff --git a/apps/browser-extension/tests/extension.spec.ts b/apps/browser-extension/tests/extension.spec.ts deleted file mode 100644 index 0fd629933..000000000 --- a/apps/browser-extension/tests/extension.spec.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { test, expect, openPopup } from './fixtures'; - -/** - * Basic browser extension E2E tests. - * - * These tests verify the extension loads correctly and basic UI interactions work. - * Run with: npm run test:e2e - * - * Prerequisites: - * 1. Build the extension: npm run build:chrome - * 2. Have an AliasVault API server running (or mock one) - */ - -test.describe('Extension Popup', () => { - test('should load the popup and show login form', async ({ context, extensionId }) => { - const popup = await openPopup(context, extensionId); - - // Wait for the popup to load - await popup.waitForLoadState('domcontentloaded'); - - // The popup should show something - either login form or vault content - // Check for common elements that indicate the popup loaded successfully - const body = popup.locator('body'); - await expect(body).toBeVisible(); - - // Check that React rendered (the app container should exist) - const appContent = popup.locator('#root'); - await expect(appContent).toBeVisible(); - - // Take a screenshot for debugging - await popup.screenshot({ path: 'tests/screenshots/popup-loaded.png' }); - }); - - test('should have working navigation elements', async ({ context, extensionId }) => { - const popup = await openPopup(context, extensionId); - await popup.waitForLoadState('domcontentloaded'); - - // Wait for React to render - await popup.waitForSelector('#root', { state: 'visible' }); - - // Give React a moment to render content - await popup.waitForTimeout(500); - - // The popup should contain some text (not be empty) - const rootContent = await popup.locator('#root').textContent(); - expect(rootContent).toBeTruthy(); - expect(rootContent!.length).toBeGreaterThan(0); - }); -}); - -test.describe('Extension Service Worker', () => { - test('should have a running service worker', async ({ context, extensionId }) => { - // Service worker should be running - const serviceWorkers = context.serviceWorkers(); - expect(serviceWorkers.length).toBeGreaterThan(0); - - // The service worker URL should match our extension - const swUrl = serviceWorkers[0].url(); - expect(swUrl).toContain(extensionId); - expect(swUrl).toContain('background'); - }); -}); diff --git a/apps/browser-extension/tests/fixtures.ts b/apps/browser-extension/tests/fixtures.ts deleted file mode 100644 index f4fdc77a6..000000000 --- a/apps/browser-extension/tests/fixtures.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { test as base, chromium, type BrowserContext } from '@playwright/test'; -import path from 'path'; -import { fileURLToPath } from 'url'; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - -/** - * Path to the built Chrome extension. - * Run `npm run build:chrome` before running E2E tests. - */ -const EXTENSION_PATH = path.join(__dirname, '..', 'dist', 'chrome-mv3'); - -/** - * Extended test fixture that provides a browser context with the extension loaded. - */ -export const test = base.extend<{ - context: BrowserContext; - extensionId: string; -}>({ - // eslint-disable-next-line no-empty-pattern - context: async ({}, use) => { - const context = await chromium.launchPersistentContext('', { - headless: false, // Extensions require headed mode - args: [ - `--disable-extensions-except=${EXTENSION_PATH}`, - `--load-extension=${EXTENSION_PATH}`, - '--no-first-run', - '--disable-gpu', - ], - }); - - await use(context); - await context.close(); - }, - - extensionId: async ({ context }, use) => { - // Wait for service worker to be ready - let [background] = context.serviceWorkers(); - if (!background) { - background = await context.waitForEvent('serviceworker'); - } - - // Extract extension ID from the service worker URL - const extensionId = background.url().split('/')[2]; - await use(extensionId); - }, -}); - -export const expect = test.expect; - -/** - * Helper to open the extension popup. - */ -export async function openPopup(context: BrowserContext, extensionId: string) { - const popupPage = await context.newPage(); - await popupPage.goto(`chrome-extension://${extensionId}/popup.html`); - return popupPage; -} diff --git a/apps/browser-extension/tests/fixtures/fixtures.ts b/apps/browser-extension/tests/fixtures/fixtures.ts new file mode 100644 index 000000000..abfdc7892 --- /dev/null +++ b/apps/browser-extension/tests/fixtures/fixtures.ts @@ -0,0 +1,259 @@ +import { test as base, chromium, type BrowserContext, type Page } from '@playwright/test'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +import { createTestUser, isApiAvailable, type TestUser } from '../helpers/test-api'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +/** + * Path to the built Chrome extension. + * Run `npm run build:chrome` before running E2E tests. + */ +const EXTENSION_PATH = path.join(__dirname, '..', '..', 'dist', 'chrome-mv3'); + +/** + * Default API URL for local development. + */ +const DEFAULT_API_URL = process.env.ALIASVAULT_API_URL || 'http://localhost:5092'; + +/** + * Test-scoped fixtures (created per test file/describe block). + */ +type TestFixtures = { + context: BrowserContext; + extensionId: string; + testUser: TestUser; + apiUrl: string; +}; + +/** + * Cache for browser context within a test file. + * This allows tests within the same describe.serial block to share context, + * while ensuring each test FILE gets a fresh context (logged out state). + */ +let cachedContext: BrowserContext | null = null; +let cachedExtensionId: string | null = null; +let contextTestFile: string | null = null; + +/** + * Extended test fixture that provides a browser context with the extension loaded, + * a test user, and helper functions. + * + * Each test file gets a fresh browser context, ensuring tests start in a logged-out state. + * Tests within the same file can share the context via describe.serial blocks. + */ +export const test = base.extend({ + apiUrl: [DEFAULT_API_URL, { option: true }], + + // Context that's fresh per test file but shared within a file + // eslint-disable-next-line no-empty-pattern + context: async ({}, use, testInfo) => { + const currentTestFile = testInfo.file; + + // If we have a cached context from a different file, close it + if (cachedContext && contextTestFile !== currentTestFile) { + await cachedContext.close(); + cachedContext = null; + cachedExtensionId = null; + contextTestFile = null; + } + + // Create new context if we don't have one for this file + if (!cachedContext) { + cachedContext = await chromium.launchPersistentContext('', { + headless: false, // Extensions require headed mode + args: [ + `--disable-extensions-except=${EXTENSION_PATH}`, + `--load-extension=${EXTENSION_PATH}`, + '--no-first-run', + '--disable-gpu', + ], + }); + contextTestFile = currentTestFile; + + // Wait for service worker and get extension ID + let [background] = cachedContext.serviceWorkers(); + if (!background) { + background = await cachedContext.waitForEvent('serviceworker'); + } + cachedExtensionId = background.url().split('/')[2]; + } + + await use(cachedContext); + + // Don't close here - let it be reused within the same file + // It will be closed when the next file starts or when tests end + }, + + extensionId: async ({ context }, use) => { + // Extension ID is cached along with context + if (!cachedExtensionId) { + let [background] = context.serviceWorkers(); + if (!background) { + background = await context.waitForEvent('serviceworker'); + } + cachedExtensionId = background.url().split('/')[2]; + } + await use(cachedExtensionId); + }, + + testUser: async ({ apiUrl }, use) => { + // Check if the API is available + const apiAvailable = await isApiAvailable(apiUrl); + if (!apiAvailable) { + console.warn(`API not available at ${apiUrl}. Tests requiring authentication will fail.`); + // Return a placeholder user - tests should handle this gracefully + await use({ + username: 'api_unavailable', + password: 'api_unavailable', + }); + return; + } + + // Create a test user for this test run + const testUser = await createTestUser(apiUrl); + await use(testUser); + }, +}); + +export const expect = test.expect; + +/** + * Close the cached browser context. + * Call this in afterAll hooks or global teardown to ensure clean shutdown. + */ +export async function closeCachedContext(): Promise { + if (cachedContext) { + await cachedContext.close(); + cachedContext = null; + cachedExtensionId = null; + contextTestFile = null; + } +} + +/** + * Helper to wait for the popup to finish initial loading. + * + * The popup shows a loading spinner overlay (with z-50) during initial load. + * This function waits for actual content to be visible, indicating loading is complete. + * + * @param popup - The popup page + * @param timeout - Maximum time to wait in milliseconds (default: 10000) + */ +export async function waitForPopupReady(popup: Page, timeout: number = 10000): Promise { + // Wait for the loading overlay to disappear by waiting for actual content to be interactable. + // The login form has input fields that only become visible after loading completes. + // We wait for either the login form inputs OR the settings button (visible on login page). + // Using CSS-only selectors to avoid mixing with Playwright-specific selectors. + await popup.waitForSelector( + 'input[type="text"], input[type="password"], button#settings', + { state: 'visible', timeout } + ); +} + +/** + * Helper to open the extension popup and wait for it to be ready. + * + * @param context - The browser context + * @param extensionId - The extension ID + * @param waitForReady - Whether to wait for the popup to finish loading (default: true) + */ +export async function openPopup( + context: BrowserContext, + extensionId: string, + waitForReady: boolean = true +): Promise { + const popupPage = await context.newPage(); + await popupPage.goto(`chrome-extension://${extensionId}/popup.html`); + + if (waitForReady) { + await waitForPopupReady(popupPage); + } + + return popupPage; +} + +/** + * Helper to wait for post-login state (vault is visible). + * + * This confirms the user has successfully logged in and the vault UI is ready. + * + * @param popup - The popup page + * @param timeout - Maximum time to wait in milliseconds (default: 30000) + */ +export async function waitForLoggedIn(popup: Page, timeout: number = 30000): Promise { + await popup.getByRole('button', { name: 'Vault' }).waitFor({ state: 'visible', timeout }); +} + +/** + * Helper to configure the extension to use a custom API URL. + * + * @param popup - The popup page + * @param apiUrl - The API URL to configure + */ +export async function configureApiUrl(popup: Page, apiUrl: string): Promise { + // Click settings button + const settingsButton = await popup.waitForSelector('button#settings'); + await settingsButton.click(); + + // Select "Self-hosted" (custom) option + await popup.selectOption('select', ['custom']); + + // Fill in the custom URL input + await popup.fill('input#custom-api-url', apiUrl); + + // Go back to main page + await popup.click('button#back'); + + // Wait a moment for the settings to be saved + await popup.waitForTimeout(200); +} + +/** + * Helper to login to the extension. + * + * @param popup - The popup page + * @param username - The username to login with + * @param password - The password to login with + * @param waitForSuccess - Whether to wait for the login to complete successfully + */ +export async function login( + popup: Page, + username: string, + password: string, + waitForSuccess: boolean = true +): Promise { + // Fill in credentials + await popup.fill('input[type="text"]', username); + await popup.fill('input[type="password"]', password); + + // Click login button + await popup.click('button:has-text("Log in")'); + + // Wait for login to complete if requested + if (waitForSuccess) { + await waitForLoggedIn(popup); + } +} + +/** + * Helper to perform full login flow: configure API URL, then login. + * + * @param popup - The popup page + * @param apiUrl - The API URL to use + * @param username - The username to login with + * @param password - The password to login with + * @param waitForSuccess - Whether to wait for the login to complete successfully + */ +export async function fullLoginFlow( + popup: Page, + apiUrl: string, + username: string, + password: string, + waitForSuccess: boolean = true +): Promise { + await configureApiUrl(popup, apiUrl); + await login(popup, username, password, waitForSuccess); +} diff --git a/apps/browser-extension/tests/fixtures/index.ts b/apps/browser-extension/tests/fixtures/index.ts new file mode 100644 index 000000000..6642b9366 --- /dev/null +++ b/apps/browser-extension/tests/fixtures/index.ts @@ -0,0 +1,17 @@ +/** + * Test fixtures index. + * + * This module exports all Playwright test fixtures and helpers. + */ + +export { + test, + expect, + openPopup, + waitForPopupReady, + waitForLoggedIn, + configureApiUrl, + login, + fullLoginFlow, + closeCachedContext, +} from './fixtures'; diff --git a/apps/browser-extension/tests/global-teardown.ts b/apps/browser-extension/tests/global-teardown.ts new file mode 100644 index 000000000..cb545ed40 --- /dev/null +++ b/apps/browser-extension/tests/global-teardown.ts @@ -0,0 +1,11 @@ +/** + * Global teardown for E2E tests. + * + * Ensures all browser contexts are properly closed after test runs. + */ + +import { closeCachedContext } from './fixtures'; + +export default async function globalTeardown(): Promise { + await closeCachedContext(); +} diff --git a/apps/browser-extension/tests/helpers/test-api.ts b/apps/browser-extension/tests/helpers/test-api.ts new file mode 100644 index 000000000..26d88c564 --- /dev/null +++ b/apps/browser-extension/tests/helpers/test-api.ts @@ -0,0 +1,432 @@ +/** + * Test API utilities for E2E tests. + * + * This module provides utilities for interacting with the AliasVault API + * during E2E tests, including user registration using SRP protocol. + * + * Note: This module uses Node.js native argon2 for password hashing, + * while the browser extension uses argon2-browser. The SRP protocol + * logic is shared where possible. + */ + +// Import argon2 for Node.js environment (different from browser version) +import { webcrypto } from 'crypto'; +import { readFileSync, unlinkSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; + +import argon2 from 'argon2'; +import Database from 'better-sqlite3'; +import * as srp from 'secure-remote-password/client.js'; + +// Get the vault schema SQL from the shared vault-sql package +import { COMPLETE_SCHEMA_SQL } from '../../src/utils/dist/shared/vault-sql/index.mjs'; + +/** + * Token model returned from successful registration/login. + */ +export type TokenModel = { + token: string; + refreshToken: string; +}; + +/** + * Test user credentials. + */ +export type TestUser = { + username: string; + password: string; + token?: TokenModel; +}; + +/** + * Vault upload request payload. + */ +type VaultUploadRequest = { + username: string; + blob: string; + version: string; + currentRevisionNumber: number; + encryptionPublicKey: string; + credentialsCount: number; + emailAddressList: string[]; + privateEmailDomainList: string[]; + hiddenPrivateEmailDomainList: string[]; + publicEmailDomainList: string[]; + createdAt: string; + updatedAt: string; +}; + +/** + * Registration request payload. + * This matches the server's RegisterRequest model. + */ +type RegisterRequest = { + username: string; + salt: string; + verifier: string; + encryptionType: string; + encryptionSettings: string; +}; + +/** + * Default encryption settings for Argon2Id. + * These match the server defaults in AliasVault.Cryptography.Client/Defaults.cs + */ +const DEFAULT_ENCRYPTION = { + type: 'Argon2Id', + settings: JSON.stringify({ + DegreeOfParallelism: 1, + MemorySize: 19456, + Iterations: 2, + }), + // Parsed settings for argon2 usage + iterations: 2, + memorySize: 19456, + parallelism: 1, +}; + +/** + * Current vault version - should match the latest version in vault-sql. + */ +const CURRENT_VAULT_VERSION = '1.7.2'; + +/** + * Normalizes a username by converting to lowercase and trimming whitespace. + * This matches the server's username normalization. + */ +export function normalizeUsername(username: string): string { + return username.toLowerCase().trim(); +} + +/** + * Generates a random test username. + */ +export function generateTestUsername(): string { + const randomPart = Math.random().toString(36).substring(2, 12); + return `test_${randomPart}@test.com`; +} + +/** + * Generates a random test password. + */ +export function generateTestPassword(): string { + return `TestPass_${Math.random().toString(36).substring(2, 15)}!`; +} + +/** + * Converts a Uint8Array to an uppercase hex string. + */ +function bytesToHexString(bytes: Uint8Array): string { + return Array.from(bytes) + .map((b) => b.toString(16).padStart(2, '0')) + .join('') + .toUpperCase(); +} + +/** + * Derives a key from password using Argon2Id (Node.js version). + * + * Note: This uses the native argon2 module for Node.js, which is different + * from the argon2-browser WASM module used in the browser extension. + * + * @param password - The password to derive the key from + * @param salt - The salt string + * @returns The derived key as Uint8Array + */ +async function deriveKeyFromPassword(password: string, salt: string): Promise { + // Note: argon2 in Node.js expects salt as Buffer + const saltBuffer = Buffer.from(salt, 'utf8'); + + const hash = await argon2.hash(password, { + type: argon2.argon2id, + salt: saltBuffer, + timeCost: DEFAULT_ENCRYPTION.iterations, + memoryCost: DEFAULT_ENCRYPTION.memorySize, + parallelism: DEFAULT_ENCRYPTION.parallelism, + hashLength: 32, + raw: true, + }); + + return new Uint8Array(hash); +} + +/** + * Encrypts data using AES-GCM symmetric encryption (matching the browser extension's EncryptionUtility). + * + * @param plaintext - The plaintext string to encrypt + * @param keyBytes - The 256-bit encryption key as Uint8Array + * @returns Base64-encoded ciphertext (IV prepended to ciphertext) + */ +async function symmetricEncrypt(plaintext: string, keyBytes: Uint8Array): Promise { + const key = await webcrypto.subtle.importKey( + 'raw', + keyBytes, + { name: 'AES-GCM', length: 256 }, + false, + ['encrypt'] + ); + + // Generate random 12-byte IV + const iv = webcrypto.getRandomValues(new Uint8Array(12)); + + // Encode plaintext to bytes + const encoder = new TextEncoder(); + const plaintextBytes = encoder.encode(plaintext); + + // Encrypt + const ciphertext = await webcrypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, plaintextBytes); + + // Prepend IV to ciphertext + const combined = new Uint8Array(iv.length + ciphertext.byteLength); + combined.set(iv); + combined.set(new Uint8Array(ciphertext), iv.length); + + // Convert to base64 + return Buffer.from(combined).toString('base64'); +} + +/** + * Creates an empty vault database with the latest schema. + * + * @returns Base64-encoded SQLite database + */ +function createEmptyVaultDatabase(): string { + // Create a temporary file for the database + const tempPath = join(tmpdir(), `vault_${Date.now()}_${Math.random().toString(36).substring(2)}.db`); + + try { + // Create a new SQLite database + const db = new Database(tempPath); + + // Execute the complete schema SQL to create all tables + // The schema is a series of SQL statements separated by semicolons + db.exec(COMPLETE_SCHEMA_SQL); + + // Close the database + db.close(); + + // Read the database file and convert to base64 + const dbBytes = readFileSync(tempPath); + return Buffer.from(dbBytes).toString('base64'); + } finally { + // Clean up the temp file + try { + unlinkSync(tempPath); + } catch { + // Ignore cleanup errors + } + } +} + +/** + * Generates an RSA key pair for the vault's encryption key. + * + * @returns Object with public and private keys as JSON strings + */ +async function generateRsaKeyPair(): Promise<{ publicKey: string; privateKey: string }> { + const keyPair = await webcrypto.subtle.generateKey( + { + name: 'RSA-OAEP', + modulusLength: 2048, + publicExponent: new Uint8Array([1, 0, 1]), + hash: 'SHA-256', + }, + true, + ['encrypt', 'decrypt'] + ); + + const publicKey = await webcrypto.subtle.exportKey('jwk', keyPair.publicKey); + const privateKey = await webcrypto.subtle.exportKey('jwk', keyPair.privateKey); + + return { + publicKey: JSON.stringify(publicKey), + privateKey: JSON.stringify(privateKey), + }; +} + +/** + * Prepares SRP registration data for a new user. + * + * @param username - The username for registration + * @param password - The password for registration + * @returns Registration request data and the encryption key + */ +async function prepareRegistration( + username: string, + password: string +): Promise<{ request: RegisterRequest; salt: string; encryptionKey: Uint8Array }> { + const normalizedUsername = normalizeUsername(username); + + // Generate salt using SRP client + const salt = srp.generateSalt(); + + // Derive key from password using Argon2Id + const encryptionKey = await deriveKeyFromPassword(password, salt); + + // Convert to uppercase hex string (expected by server) + const passwordHashString = bytesToHexString(encryptionKey); + + // Generate SRP private key and verifier + const privateKey = srp.derivePrivateKey(salt, normalizedUsername, passwordHashString); + const verifier = srp.deriveVerifier(privateKey); + + return { + request: { + username: normalizedUsername, + salt, + verifier, + encryptionType: DEFAULT_ENCRYPTION.type, + encryptionSettings: DEFAULT_ENCRYPTION.settings, + }, + salt, + encryptionKey, + }; +} + +/** + * Uploads an initial empty vault to the server. + * + * @param apiBaseUrl - The base URL of the API + * @param token - The authentication token + * @param username - The username + * @param encryptionKey - The encryption key as Uint8Array + */ +async function uploadInitialVault( + apiBaseUrl: string, + token: string, + username: string, + encryptionKey: Uint8Array +): Promise { + const baseUrl = apiBaseUrl.replace(/\/$/, '') + '/v1/'; + + // Create an empty vault database + const vaultBase64 = createEmptyVaultDatabase(); + + // Encrypt the vault + const encryptedVault = await symmetricEncrypt(vaultBase64, encryptionKey); + + // Generate RSA key pair for the vault + const rsaKeyPair = await generateRsaKeyPair(); + + // Prepare the vault upload request + const now = new Date().toISOString(); + const vaultRequest: VaultUploadRequest = { + username: normalizeUsername(username), + blob: encryptedVault, + version: CURRENT_VAULT_VERSION, + currentRevisionNumber: 0, + encryptionPublicKey: rsaKeyPair.publicKey, + credentialsCount: 0, + emailAddressList: [], + privateEmailDomainList: [], + hiddenPrivateEmailDomainList: [], + publicEmailDomainList: [], + createdAt: now, + updatedAt: now, + }; + + // Upload the vault + const response = await fetch(`${baseUrl}Vault`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify(vaultRequest), + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Failed to upload initial vault: ${response.status} ${errorText}`); + } +} + +/** + * Registers a new test user via the API using SRP protocol and initializes their vault. + * + * @param apiBaseUrl - The base URL of the API (e.g., 'http://localhost:5092') + * @param username - The username for the new account + * @param password - The password for the new account + * @returns The token model on success + * @throws Error if registration fails + */ +export async function registerTestUser( + apiBaseUrl: string, + username: string, + password: string +): Promise { + // Normalize the API URL + const baseUrl = apiBaseUrl.replace(/\/$/, '') + '/v1/'; + + // Prepare registration data + const { request: registerRequest, encryptionKey } = await prepareRegistration(username, password); + + // Send registration request to API + const response = await fetch(`${baseUrl}Auth/register`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(registerRequest), + }); + + if (!response.ok) { + const errorText = await response.text(); + let errorMessage = `Registration failed with status ${response.status}`; + try { + const errorJson = JSON.parse(errorText); + errorMessage = errorJson.title || errorJson.message || errorMessage; + } catch { + errorMessage = errorText || errorMessage; + } + throw new Error(errorMessage); + } + + const tokenModel = (await response.json()) as TokenModel; + + // Upload initial empty vault + await uploadInitialVault(apiBaseUrl, tokenModel.token, username, encryptionKey); + + return tokenModel; +} + +/** + * Creates a test user with random credentials. + * + * @param apiBaseUrl - The base URL of the API + * @returns A TestUser object with credentials and token + */ +export async function createTestUser(apiBaseUrl: string): Promise { + const username = generateTestUsername(); + const password = generateTestPassword(); + + const token = await registerTestUser(apiBaseUrl, username, password); + + return { + username, + password, + token, + }; +} + +/** + * Checks if the API is available. + * + * @param apiBaseUrl - The base URL of the API + * @returns True if the API is reachable + */ +export async function isApiAvailable(apiBaseUrl: string): Promise { + try { + const response = await fetch(`${apiBaseUrl.replace(/\/$/, '')}/v1/Auth/status`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }); + // The status endpoint returns 401 when not authenticated, but that means the API is running + return response.status === 401 || response.ok; + } catch { + return false; + } +}