Update e2e tests (#1404)

This commit is contained in:
Leendert de Borst
2025-12-08 20:43:32 +01:00
parent dc78618dd9
commit ab4dff738f
16 changed files with 1423 additions and 130 deletions

View File

@@ -28,4 +28,5 @@ web-ext.config.ts
# Test related output files
playwright-report
tests/screenshots
tests/test-results
test-results

View File

@@ -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",

View File

@@ -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",

View File

@@ -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',
});

View File

@@ -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;

View File

@@ -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.

View File

@@ -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');
});
});

View File

@@ -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' });
});
});

View File

@@ -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' });
});
});

View File

@@ -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' });
});
});

View File

@@ -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');
});
});

View File

@@ -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;
}

View File

@@ -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<TestFixtures>({
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<void> {
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<void> {
// 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<Page> {
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<void> {
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<void> {
// 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<void> {
// 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<void> {
await configureApiUrl(popup, apiUrl);
await login(popup, username, password, waitForSuccess);
}

View File

@@ -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';

View File

@@ -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<void> {
await closeCachedContext();
}

View File

@@ -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<Uint8Array> {
// 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<string> {
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<void> {
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<TokenModel> {
// 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<TestUser> {
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<boolean> {
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;
}
}