diff --git a/.gitignore b/.gitignore index 2dbc56f..c8d2cdb 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,11 @@ next-env.d.ts # Sentry Config File .env.sentry-build-plugin + +# Playwright +node_modules/ +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ +/playwright/.auth/ diff --git a/package-lock.json b/package-lock.json index cbd205f..bb8335f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,6 +29,7 @@ "clsx": "^2.1.1", "cm6-theme-basic-dark": "^0.2.0", "dayjs": "^1.11.18", + "dotenv-cli": "^7.4.0", "lucide-react": "^0.545.0", "mongoose": "^8.19.1", "next": "15.5.7", @@ -48,6 +49,7 @@ }, "devDependencies": { "@eslint/eslintrc": "^3", + "@playwright/test": "^1.59.1", "@tailwindcss/postcss": "^4", "@tailwindcss/typography": "^0.5.19", "@testing-library/dom": "^10.4.1", @@ -234,6 +236,7 @@ "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", @@ -1046,6 +1049,7 @@ "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.11.3.tgz", "integrity": "sha512-9HBM2XnwDj7fnu0551HkGdrUrrqmYq/WC5iv6nbY2WdicXdGbhR/gfbZOH73Aqj4351alY1+aoG9rCNfiwS1RA==", "license": "MIT", + "peer": true, "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.23.0", @@ -1134,6 +1138,7 @@ "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.2.tgz", "integrity": "sha512-FVqsPqtPWKVVL3dPSxy8wEF/ymIEuVzF1PK3VbUgrxXpJUSHQWWZz4JMToquRxnkw+36LTamCZG2iua2Ptq0fA==", "license": "MIT", + "peer": true, "dependencies": { "@marijn/find-cluster-break": "^1.0.0" } @@ -1143,6 +1148,7 @@ "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.38.5.tgz", "integrity": "sha512-SFVsNAgsAoou+BjRewMqN+m9jaztB9wCWN9RSRgePqUbq8UVlvJfku5zB2KVhLPgH/h0RLk38tvd4tGeAhygnw==", "license": "MIT", + "peer": true, "dependencies": { "@codemirror/state": "^6.5.0", "crelt": "^1.0.6", @@ -1317,6 +1323,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -1340,6 +1347,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -2968,6 +2976,7 @@ "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.1.tgz", "integrity": "sha512-Z5duk4RN/3zuVO7Jq0pGLJ3qynpxUVsh7IbUbGj88+uV2ApSAn6kWg2au3iJb+0Zi7kKtqffIESgNcRXWZWmSA==", "license": "MIT", + "peer": true, "dependencies": { "@lezer/common": "^1.0.0" } @@ -3507,6 +3516,23 @@ "url": "https://opencollective.com/pkgr" } }, + "node_modules/@playwright/test": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz", + "integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==", + "devOptional": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "playwright": "1.59.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@radix-ui/colors": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@radix-ui/colors/-/colors-3.0.0.tgz", @@ -4900,6 +4926,7 @@ "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", @@ -5243,6 +5270,7 @@ "integrity": "sha512-CmyhGZanP88uuC5GpWU9q+fI61j2SkhO3UGMUdfYRE6Bcy0ccyzn1Rqj9YAB/ZY4kOXmNf0ocah5GtphmLMP6Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.14.0" } @@ -5252,6 +5280,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz", "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -5262,6 +5291,7 @@ "integrity": "sha512-/EEvYBdT3BflCWvTMO7YkYBHVE9Ci6XdqZciZANQgKpaiDRGOLIlRo91jbTNRQjgPFWVaRxcYc0luVNFitz57A==", "devOptional": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -5364,6 +5394,7 @@ "integrity": "sha512-n1H6IcDhmmUEG7TNVSspGmiHHutt7iVKtZwRppD7e04wha5MrkV1h3pti9xQLcCMt6YWsncpoT0HMjkH1FNwWQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.46.0", "@typescript-eslint/types": "8.46.0", @@ -5895,6 +5926,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -6518,6 +6550,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.9", "caniuse-lite": "^1.0.30001746", @@ -7029,7 +7062,6 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -7386,6 +7418,30 @@ "url": "https://dotenvx.com" } }, + "node_modules/dotenv-cli": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/dotenv-cli/-/dotenv-cli-7.4.0.tgz", + "integrity": "sha512-fZGFOGCC5rEz1OJ0Pp+1LN8y78ClMcyXDmBEmjvJwqCqZVsPHcQ85bLCh5hZ4Bqotw4dptXOLvvw0vxm2MD30g==", + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "dotenv": "^16.3.0", + "dotenv-expand": "^10.0.0", + "minimist": "^1.2.6" + }, + "bin": { + "dotenv": "cli.js" + } + }, + "node_modules/dotenv-expand": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-10.0.0.tgz", + "integrity": "sha512-GopVGCpVS1UKH75VKHGuQFqS1Gusej0z4FyQkPdwjil2gNIv+LNsqBlboOzpJFZKVT95GkCyWJbBSdFEFUWI2A==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, "node_modules/downshift": { "version": "7.6.2", "resolved": "https://registry.npmjs.org/downshift/-/downshift-7.6.2.tgz", @@ -7781,6 +7837,7 @@ "integrity": "sha512-XyLmROnACWqSxiGYArdef1fItQd47weqB7iwtfr9JHwRrqIXZdcFMvvEcL9xHCmL0SNsOvF0c42lWyM1U5dgig==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -7870,6 +7927,7 @@ "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "dev": true, "license": "MIT", + "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -7971,6 +8029,7 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -9881,7 +9940,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, "license": "ISC" }, "node_modules/isomorphic.js": { @@ -9889,7 +9947,6 @@ "resolved": "https://registry.npmjs.org/isomorphic.js/-/isomorphic.js-0.2.5.tgz", "integrity": "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==", "license": "MIT", - "peer": true, "funding": { "type": "GitHub Sponsors โค", "url": "https://github.com/sponsors/dmonad" @@ -10995,6 +11052,7 @@ "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "cssstyle": "^4.2.1", "data-urls": "^5.0.0", @@ -11188,7 +11246,6 @@ "resolved": "https://registry.npmjs.org/lib0/-/lib0-0.2.114.tgz", "integrity": "sha512-gcxmNFzA4hv8UYi8j43uPlQ7CGcyMJ2KQb5kZASw6SnAKAf10hK12i2fjrS3Cl/ugZa5Ui6WwIu1/6MIXiHttQ==", "license": "MIT", - "peer": true, "dependencies": { "isomorphic.js": "^0.2.4" }, @@ -13385,7 +13442,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -13593,6 +13649,53 @@ "node": ">=8" } }, + "node_modules/playwright": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz", + "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.59.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz", + "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", @@ -13651,6 +13754,7 @@ "resolved": "https://registry.npmjs.org/preact/-/preact-10.24.3.tgz", "integrity": "sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA==", "license": "MIT", + "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/preact" @@ -13681,6 +13785,7 @@ "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -13956,6 +14061,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -13974,6 +14080,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -14002,6 +14109,7 @@ "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.64.0.tgz", "integrity": "sha512-fnN+vvTiMLnRqKNTVhDysdrUay0kUUAymQnFIznmgDvapjveUWOOPqMNzPg+A+0yf9DuE2h6xzBjN1s+Qx8wcg==", "license": "MIT", + "peer": true, "engines": { "node": ">=18.0.0" }, @@ -14633,7 +14741,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -14646,7 +14753,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -15342,7 +15448,8 @@ "version": "4.1.14", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.14.tgz", "integrity": "sha512-b7pCxjGO98LnxVkKjaZSDeNuljC4ueKUddjENJOADtubtdo8llTaJy7HwBMeLNSSo2N5QIAgklslK1+Ir8r6CA==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/tailwindcss-animate": { "version": "1.0.7", @@ -15471,6 +15578,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -15591,6 +15699,7 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -15794,6 +15903,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -16280,7 +16390,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" @@ -16677,6 +16786,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.12.tgz", "integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index f6b6a9f..eebd676 100644 --- a/package.json +++ b/package.json @@ -6,10 +6,13 @@ "dev": "next dev --turbo", "build": "next build", "start": "next start", + "start:test": "dotenv -e .env.test -- next start", "lint": "next lint", "test": "jest", "test:watch": "jest --watch", - "test:coverage": "jest --coverage" + "test:coverage": "jest --coverage", + "test:e2e": "playwright test", + "test:report": "playwright show-report" }, "dependencies": { "@ai-sdk/openai": "^2.0.47", @@ -33,6 +36,7 @@ "clsx": "^2.1.1", "cm6-theme-basic-dark": "^0.2.0", "dayjs": "^1.11.18", + "dotenv-cli": "^7.4.0", "lucide-react": "^0.545.0", "mongoose": "^8.19.1", "next": "15.5.7", @@ -52,6 +56,7 @@ }, "devDependencies": { "@eslint/eslintrc": "^3", + "@playwright/test": "^1.59.1", "@tailwindcss/postcss": "^4", "@tailwindcss/typography": "^0.5.19", "@testing-library/dom": "^10.4.1", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..0ba92c0 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,56 @@ +import { defineConfig, devices } from "@playwright/test"; + +/** + * Read environment variables from file. + * https://github.com/motdotla/dotenv + */ +import dotenv from "dotenv"; +import path from "path"; +dotenv.config({ path: path.resolve(__dirname, ".env.test") }); + +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + testDir: "./tests/e2e", + globalSetup: "./tests/e2e/setup/global-setup.ts", + globalTeardown: "./tests/e2e/setup/global-teardown.ts", + /* Run tests in files in parallel */ + fullyParallel: true, + timeout: 60 * 1000, // Maximum time a test can run + expect: { timeout: 30 * 1000 }, // Maximum time for an assertion + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* Retry on CI only */ + retries: process.env.CI ? 2 : 0, + /* Opt out of parallel tests on CI. */ + workers: process.env.CI ? 1 : undefined, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: "html", + outputDir: "test-results", + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Base URL to use in actions like `await page.goto('')`. */ + trace: "on-first-retry", + baseURL: process.env.BASE_URL, + screenshot: "only-on-failure", + }, + + /* Configure projects for major browsers */ + projects: [ + { name: "setup-chromium", testMatch: /tests\/e2e\/setup\/auth\.chromium\.setup\.ts/ }, + { + name: "chromium", + use: { ...devices["Desktop Chrome"], storageState: "storage/user_chrome.json" }, + dependencies: ["setup-chromium"], + }, + ], + + /* Run your local dev server before starting the tests */ + webServer: { + command: "npm run start:test", + url: process.env.BASE_URL, + timeout: 120 * 1000, + reuseExistingServer: !process.env.CI, + }, +}); diff --git a/storage/user_chrome.json b/storage/user_chrome.json new file mode 100644 index 0000000..31e0883 --- /dev/null +++ b/storage/user_chrome.json @@ -0,0 +1,35 @@ +{ + "cookies": [ + { + "name": "authjs.csrf-token", + "value": "9f85f62fce79f5b51c922d9988c4b3134bc8573145a99f80562350a34e2e8884%7C22294db7450c039e3a1322e5436bd8956df0794098160045c72cb5dcce0fb06c", + "domain": "localhost", + "path": "/", + "expires": -1, + "httpOnly": true, + "secure": false, + "sameSite": "Lax" + }, + { + "name": "authjs.callback-url", + "value": "http%3A%2F%2Flocalhost%3A3000%2Fsign-in", + "domain": "localhost", + "path": "/", + "expires": -1, + "httpOnly": true, + "secure": false, + "sameSite": "Lax" + }, + { + "name": "authjs.session-token", + "value": "eyJhbGciOiJkaXIiLCJlbmMiOiJBMjU2Q0JDLUhTNTEyIiwia2lkIjoiek9HSG9RZ19Jdm9menJHWjNRNklWMmVteEpXQm50MTNoV09Rdnc2NkU2OVBOcHRRa0lxTGZDamswclpRb00yUUNzcHcwX1lmUFdfM0owakZXTEFVcEEifQ..YLNpE_VujsxhJeqalXx91Q.2HSPir7Ttfd2-BUZUjHJ0bHREaraB9wyZCNhMMOI8uPUqL_4JVtqdoIgV2pSjs13SJXWdQwN0KQfw3upOt79mMY1zDGj0Cbpnt638Lz_neoqDtEvmJWxPc8akCvUqg8BAhJudAxEXCkO20zbLfOenS4g_nk-_ZsHDeK9q3ZSwQZXLzaY8mNNqYZYLl9d3RxPE5L4i7ek-CoZlUBvKUuOuiPcROndQssymvDkcvl7c4c.0uKwsBGDZEn-bgDRzEQEQa1bwLjBRMQ992618lmKDDc", + "domain": "localhost", + "path": "/", + "expires": 1777705992.688505, + "httpOnly": true, + "secure": false, + "sameSite": "Lax" + } + ], + "origins": [] +} \ No newline at end of file diff --git a/tests/config/db-e2e.ts b/tests/config/db-e2e.ts new file mode 100644 index 0000000..812939c --- /dev/null +++ b/tests/config/db-e2e.ts @@ -0,0 +1,69 @@ +import { config } from "dotenv"; +import mongoose from "mongoose"; + +config({ path: ".env.test" }); + +const MONGODB_URI = process.env.MONGODB_URI as string; + +export function isE2EDBConnected() { + return mongoose.connection.readyState === 1; +} + +export async function connectE2EDB() { + if (!MONGODB_URI) { + throw new Error("๐Ÿ”‘ Please define the MONGODB_URI environment variable "); + } + + if (isE2EDBConnected()) { + console.log("๐Ÿ—„๏ธ Already connected to E2E test database"); + return; + } + + try { + await mongoose.connect(MONGODB_URI, { dbName: "devflow" }); + console.log("๐Ÿ—„๏ธ Connected to E2E test database"); + } catch (error) { + console.error("๐Ÿ”ด Connection to E2E test database failed:", error); + throw error; + } +} + +export async function disconnectE2EDB() { + if (!isE2EDBConnected()) { + console.log("๐Ÿ—„๏ธ Already disconnected from E2E test database"); + return; + } + + try { + await mongoose.disconnect(); + console.log("๐Ÿ”Œ Disconnected from E2E test database"); + } catch (error) { + console.error("๐Ÿ”ด Disconnection from E2E test database failed:", error); + throw error; + } +} + +export async function cleanupE2EData() { + if (!isE2EDBConnected()) { + console.log("๐Ÿ—„๏ธ E2E test database is not connected"); + return; + } + + try { + await mongoose.connection.dropDatabase(); + console.log("๐ŸŸข Dropped entire E2E test database"); + } catch (err) { + console.error("๐Ÿ”ด Cleanup E2E test data failed:", err); + } +} + +export function getE2EConnectionInfo() { + const { connection } = mongoose; + + return { + dbName: connection.db?.databaseName || null, + host: connection.host || null, + port: connection.port || null, + isConnected: isE2EDBConnected(), + }; +} diff --git a/tests/e2e/fixtures/question.fixture.ts b/tests/e2e/fixtures/question.fixture.ts new file mode 100644 index 0000000..5ec13ab --- /dev/null +++ b/tests/e2e/fixtures/question.fixture.ts @@ -0,0 +1,43 @@ +import { test as base, expect, BrowserContext, Page } from "@playwright/test"; +import { SAMPLE_QUESTIONS, TestQuestion } from "@/tests/fixtures/questions"; + +type QuestionFixture = { + question: TestQuestion; + createQuestion: { questionId: string; title: string; page: Page }; +}; + +const FALLBACK_QUESTION = SAMPLE_QUESTIONS[0]; + +export const test = base.extend({ + question: [FALLBACK_QUESTION, { scope: "test" }], + createQuestion: [ + async ({ browser, question }, use) => { + const context: BrowserContext = await browser.newContext(); + const page: Page = await context.newPage(); + + await page.goto("/ask-question"); + await expect(page).toHaveURL("/ask-question"); + + await page.getByRole("textbox", { name: "Question Title *" }).fill(question.title); + await page.getByRole("textbox", { name: "editable markdown" }).fill(question.content); + + for (const tag of question.tags) { + await page.getByRole("textbox", { name: "Add tags..." }).fill(tag); + await page.getByRole("textbox", { name: "Add tags..." }).press("Enter"); + } + + await page.getByRole("button", { name: "Ask a question" }).click(); + await expect(page).toHaveURL(/\/questions\/[a-f0-9]+$/); + + const url = page.url(); + const questionId = url.split("/").pop(); + if (!questionId) throw new Error("Failed to extract questionId from URL"); + + await expect(page.getByRole("heading", { name: question.title, exact: true })).toBeVisible(); + + await use({ questionId, title: question.title, page }); + await context.close(); + }, + { scope: "test" }, + ], +}); diff --git a/tests/e2e/setup/auth.chromium.setup.ts b/tests/e2e/setup/auth.chromium.setup.ts new file mode 100644 index 0000000..c82df85 --- /dev/null +++ b/tests/e2e/setup/auth.chromium.setup.ts @@ -0,0 +1,35 @@ +import { test, expect } from "@playwright/test"; + +import { BROWSER_USERS } from "@/tests/fixtures/users"; + +test.describe("Authentication Setup", () => { + test("authenticate and save storage state", async ({ page }) => { + // Go directly to sign-in + await page.goto("/sign-in"); + + // Sign in using real UI + await page.getByLabel("Email Address").fill(BROWSER_USERS.chrome.email); + await page.getByLabel("Password").fill(BROWSER_USERS.chrome.password); + await page.getByRole("button", { name: /sign in/i }).click(); + + // Wait for session cookie to be set, then go home + await expect + .poll(async () => { + const cookies = await page.context().cookies(); + return cookies.some((c) => + [ + "next-auth.session-token", + "__Secure-next-auth.session-token", + "authjs.session-token", + "__Secure-authjs.session-token", + ].includes(c.name) + ); + }) + .toBe(true); + + await page.goto("/"); + + // Persist authenticated state for dependent tests + await page.context().storageState({ path: "storage/user_chrome.json" }); + }); +}); diff --git a/tests/e2e/setup/global-setup.ts b/tests/e2e/setup/global-setup.ts new file mode 100644 index 0000000..35e7e86 --- /dev/null +++ b/tests/e2e/setup/global-setup.ts @@ -0,0 +1,39 @@ +import { connectE2EDB, cleanupE2EData, getE2EConnectionInfo, isE2EDBConnected } from "@/tests/config/db-e2e"; +import { seed } from "@/tests/seeders/e2e.seeder"; + +async function globalSetup() { + console.log("๐Ÿš€ Starting E2E test global setup..."); + + try { + // Connect to the E2E test database + await connectE2EDB(); + + // Verify connection is established + const connectionInfo = getE2EConnectionInfo(); + console.log("๐Ÿ” E2E Database connection info:", { + database: connectionInfo.dbName, + host: connectionInfo.host, + port: connectionInfo.port, + isConnected: connectionInfo.isConnected, + }); + + if (!isE2EDBConnected) { + throw new Error("Failed to establish stable E2E database connection"); + } + + // Clean up any existing test data to start fresh + await cleanupE2EData(); + + // Seed the database with test data + const seedData = await seed(); + + console.log("โœ… E2E global setup completed successfully"); + console.log(`๐Ÿ‘ฅ Created ${Object.keys(seedData.users).length} test users`); + console.log(`โ“ Created ${Object.keys(seedData.questions).length} test questions`); + } catch (error) { + console.error("โŒ E2E global setup failed:", error); + process.exit(1); // Exit with error code to stop test execution + } +} + +export default globalSetup; diff --git a/tests/e2e/setup/global-teardown.ts b/tests/e2e/setup/global-teardown.ts new file mode 100644 index 0000000..5ee691e --- /dev/null +++ b/tests/e2e/setup/global-teardown.ts @@ -0,0 +1,21 @@ +import { disconnectE2EDB, cleanupE2EData } from "@/tests/config/db-e2e"; + +async function globalTeardown() { + console.log("๐Ÿงน Starting E2E test global teardown..."); + + try { + // Clean up all test data from the database + await cleanupE2EData(); + + // Disconnect from the E2E test database + await disconnectE2EDB(); + + console.log("โœ… E2E global teardown completed successfully"); + console.log("๐Ÿ—‘๏ธ Test data cleaned up"); + console.log("๐Ÿ”Œ Database connection closed"); + } catch (error) { + console.error("โŒ E2E global teardown failed:", error); + } +} + +export default globalTeardown; diff --git a/tests/e2e/specs/answer.spec.ts b/tests/e2e/specs/answer.spec.ts new file mode 100644 index 0000000..938b974 --- /dev/null +++ b/tests/e2e/specs/answer.spec.ts @@ -0,0 +1,23 @@ +import { expect } from "@playwright/test"; +import { SAMPLE_QUESTIONS } from "@/tests/fixtures/questions"; +import { test } from "../fixtures/question.fixture"; + +const answer = + "This is my answer to the question. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. It should be at least 100 characters long. Should be enough to test the answer."; + +test.describe("Answer Flow", () => { + test.use({ question: SAMPLE_QUESTIONS[1] }); + + test("should submit answer to a question successfully", async ({ page, createQuestion }) => { + const { questionId } = createQuestion; + + await page.goto(`/questions/${questionId}`); + await expect(page).toHaveURL(`/questions/${questionId}`); + + await page.getByRole("img", { name: "Upvote" }).click(); + await page.getByRole("textbox", { name: "editable markdown" }).fill(answer); + await page.getByRole("button", { name: "Post Answer" }).click(); + + await expect(page.getByText(answer, { exact: false })).toBeVisible(); + }); +}); diff --git a/tests/e2e/specs/profile.spec.ts b/tests/e2e/specs/profile.spec.ts new file mode 100644 index 0000000..7a58348 --- /dev/null +++ b/tests/e2e/specs/profile.spec.ts @@ -0,0 +1,35 @@ +import { test, expect } from "@playwright/test"; + +test.describe("Signup and Profile Update", () => { + test("should allow user to update profile", async ({ page }) => { + // No fixture needed, just navigate and test + await page.goto("/profile/edit"); + await expect(page).toHaveURL("/profile/edit"); + + // Verify we're on the right page + await expect(page.getByRole("heading", { name: "Edit Profile" })).toBeVisible(); + + // Fill out the profile form with updated values + await page.getByPlaceholder("Your Name").fill("Updated Test User"); + await page.getByPlaceholder("Your username").fill("updatedusername"); + await page.getByPlaceholder("Your Portfolio link").fill("https://updated-portfolio.com"); + await page.getByPlaceholder("Where do you live?").fill("Updated City, Country"); + await page + .getByPlaceholder("What's special about you?") + .fill("This is my updated bio with more information about myself."); + + // Submit the form + await page.getByRole("button", { name: "Submit" }).click(); + + // After submission, should redirect to profile page + await expect(page).toHaveURL(/\/profile\/[a-f0-9]+$/); + + // Verify updated profile information is visible + await expect(page.getByRole("heading", { name: "Updated Test User", exact: true })).toBeVisible(); + await expect(page.getByText("@updatedusername")).toBeVisible(); + await expect( + page.getByText("This is my updated bio with more information about myself.", { exact: false }) + ).toBeVisible(); + await expect(page.getByText("Updated City, Country", { exact: false })).toBeVisible(); + }); +}); diff --git a/tests/e2e/specs/question.spec.ts b/tests/e2e/specs/question.spec.ts new file mode 100644 index 0000000..ba5cac0 --- /dev/null +++ b/tests/e2e/specs/question.spec.ts @@ -0,0 +1,42 @@ +import { expect } from "@playwright/test"; + +import { test } from "../fixtures/question.fixture"; +// import { SAMPLE_QUESTIONS } from "@/tests/fixtures/questions"; + +// const question = SAMPLE_QUESTIONS[0]; + +test.describe("Question Flow", () => { + test("should allow user to update question", async ({ page, createQuestion, question }) => { + const { questionId } = createQuestion; + + await page.goto(`/questions/${questionId}/edit`); + await expect(page).toHaveURL(/\/questions\/[a-f0-9]+\/edit$/); + + // Fill out the question form + await page.getByRole("textbox", { name: "Question Title *" }).fill(`${question.title} - E2E Test`); + await page.getByRole("textbox", { name: "editable markdown" }).fill(`${question.content} - E2E Test`); + + // remove first tag and add a new one + await page.getByRole("button", { name: question.tags[0] }).getByRole("img", { name: "close icon" }).click(); + + await page.getByRole("textbox", { name: "Add tags..." }).fill("test"); + await page.getByRole("textbox", { name: "Add tags..." }).press("Enter"); + + await page.getByRole("button", { name: "Save edits" }).click(); + + // after submission, check udpated question details + await expect(page).toHaveURL(`/questions/${questionId}`); + // await expect(page).toHaveURL(/\/questions\/[a-f0-9]+$/); + + await expect( + page.getByRole("heading", { + name: `${question.title} - E2E Test`, + exact: true, + }) + ).toBeVisible(); + await expect(page.getByText(`${question.content} - E2E Test`, { exact: false })).toBeVisible(); + + await expect(page.getByRole("link", { name: question.tags[0], exact: true })).not.toBeVisible(); + await expect(page.getByRole("link", { name: "test", exact: true })).toBeVisible(); + }); +}); diff --git a/tests/fixtures/questions.ts b/tests/fixtures/questions.ts new file mode 100644 index 0000000..874edd2 --- /dev/null +++ b/tests/fixtures/questions.ts @@ -0,0 +1,81 @@ +export interface TestQuestion { + title: string; + content: string; + tags: string[]; +} + +export const SAMPLE_QUESTIONS: TestQuestion[] = [ + { + title: "How to use React hooks effectively?", + content: + "I'm learning React and want to understand how to use hooks properly. What are the best practices?. What are the gotchas? What are the tradeoffs? Explain with examples.", + tags: ["react", "javascript", "hooks"], + }, + { + title: "React state management patterns", + content: + "What are the different state management patterns available in modern React applications? I am particularly interested in understanding the trade-offs and knowing exactly when I should use the native Context API versus a library like Redux.", + tags: ["state-management", "context", "redux"], + }, + { + title: "Optimizing React performance with useMemo", + content: "How can I optimize my React app performance using useMemo and useCallback? What are the gotchas?", + tags: ["react", "performance", "usememo", "optimization"], + }, + + { + title: "Next.js routing best practices", + content: "What are the best practices for routing in Next.js applications? How do I handle dynamic routes?", + tags: ["nextjs", "routing", "typescript"], + }, + { + title: "Next.js App Router vs Pages Router", + content: "What are the differences between App Router and Pages Router in Next.js? When should I use each?", + tags: ["nextjs", "app-router", "pages-router", "migration"], + }, + { + title: "Server-side rendering optimization in Next.js", + content: "How can I optimize SSR performance in Next.js? What are the trade-offs between SSR, SSG, and ISR?", + tags: ["nextjs", "ssr", "ssg", "performance"], + }, + + { + title: "Understanding JavaScript closures", + content: "Can someone explain JavaScript closures with practical examples? When are they useful?", + tags: ["javascript", "closures", "fundamentals"], + }, + { + title: "Async/await vs Promises", + content: "What's the difference between using async/await and .then()/.catch()? Which approach is better?", + tags: ["javascript", "async", "promises", "es6"], + }, + + { + title: "Best practices for E2E testing", + content: "What are the best practices for implementing end-to-end testing in web applications?", + tags: ["testing", "e2e", "automation"], + }, + { + title: "Unit testing React components", + content: "How do I write effective unit tests for React components? What should I test and what should I avoid?", + tags: ["testing", "react", "jest", "unit-tests"], + }, + { + title: "Testing strategies for microservices", + content: + "What are the best testing strategies for microservices architecture? How do I handle integration testing?", + tags: ["testing", "microservices", "integration", "architecture"], + }, + + { + title: "Getting started with web development", + content: + "I'm new to web development. What technologies should I learn first? What's the recommended learning path?", + tags: ["beginners", "web-development", "learning-path"], + }, + { + title: "Code review best practices", + content: "What are the best practices for conducting code reviews? How can I give constructive feedback?", + tags: ["code-review", "best-practices", "teamwork"], + }, +]; diff --git a/tests/fixtures/users.ts b/tests/fixtures/users.ts new file mode 100644 index 0000000..c070307 --- /dev/null +++ b/tests/fixtures/users.ts @@ -0,0 +1,68 @@ +export interface TestUser { + name: string; + username: string; + email: string; + password: string; +} + +// Browser-specific users for E2E authentication +export const BROWSER_USERS: Record = { + chrome: { + name: "Chrome Test User", + username: "chromeuser", + email: "e2e-chrome@test.com", + password: "password123", + }, + firefox: { + name: "Firefox Test User", + username: "firefoxuser", + email: "e2e-firefox@test.com", + password: "password123", + }, + safari: { + name: "Safari Test User", + username: "safariuser", + email: "e2e-safari@test.com", + password: "password123", + }, + edge: { + name: "Edge Test User", + username: "edgeuser", + email: "e2e-edge@test.com", + password: "password123", + }, +}; + +// Common test users for various scenarios +export const COMMON_USERS: TestUser[] = [ + { + name: "Regular Test User", + username: "testuser", + email: "test@example.com", + password: "password123", + }, + { + name: "Admin User", + username: "admin", + email: "admin@example.com", + password: "admin123", + }, + { + name: "Moderator User", + username: "moderator", + email: "mod@example.com", + password: "mod123", + }, + { + name: "John Developer", + username: "johndev", + email: "john@dev.com", + password: "dev123", + }, + { + name: "Sarah Designer", + username: "sarahdesign", + email: "sarah@design.com", + password: "design123", + }, +]; diff --git a/tests/seeders/e2e.seeder.ts b/tests/seeders/e2e.seeder.ts new file mode 100644 index 0000000..1c5c3ff --- /dev/null +++ b/tests/seeders/e2e.seeder.ts @@ -0,0 +1,33 @@ +import { createTestQuestion } from "./question.seeder"; +import { createTestUser } from "./user.seeder"; +import { SAMPLE_QUESTIONS } from "../fixtures/questions"; +import { BROWSER_USERS, COMMON_USERS } from "../fixtures/users"; + +export async function seed() { + try { + // Create browser users + const chromeUser = await createTestUser(BROWSER_USERS.chrome); + + const userPromises = COMMON_USERS.map(async (user) => createTestUser(user)); + const allUsers = await Promise.all(userPromises); + + // Create test questions + const questionPromises = SAMPLE_QUESTIONS.slice(0, 3).map(async (question) => + createTestQuestion({ + ...question, + author: chromeUser._id.toString(), + }) + ); + const allQuestions = await Promise.all(questionPromises); + + console.log("๐ŸŒฑ E2E database seeded with test data"); + + return { + users: { chromeUser, ...allUsers }, + questions: { ...allQuestions }, + }; + } catch (error) { + console.error("๐Ÿ”ด Failed to seed E2E data:", error); + throw error; + } +} diff --git a/tests/seeders/question.seeder.ts b/tests/seeders/question.seeder.ts new file mode 100644 index 0000000..b3fb514 --- /dev/null +++ b/tests/seeders/question.seeder.ts @@ -0,0 +1,28 @@ +import { Question, Tag } from "@/database"; + +import { TestQuestion } from "../fixtures/questions"; + +export async function createTestQuestion(questionDetails: TestQuestion & { author: string }) { + // Create or get all tags in parallel using findOneAndUpdate + const tagIds = await Promise.all( + questionDetails.tags.map(async (tagName: string) => { + const tag = await Tag.findOneAndUpdate( + { name: tagName }, + { $setOnInsert: { name: tagName } }, + { upsert: true, new: true, setDefaultsOnInsert: true } + ); + + return tag._id; + }) + ); + + const question = await Question.create({ + title: questionDetails.title, + content: questionDetails.content, + tags: tagIds, + author: questionDetails.author, + }); + + console.log(`โ“ Created test question: ${questionDetails.title}`); + return question; +} diff --git a/tests/seeders/user.seeder.ts b/tests/seeders/user.seeder.ts new file mode 100644 index 0000000..8038555 --- /dev/null +++ b/tests/seeders/user.seeder.ts @@ -0,0 +1,24 @@ +import bcrypt from "bcryptjs"; + +import { Account, User } from "@/database"; + +import { TestUser } from "../fixtures/users"; + +export async function createTestUser(userDetails: TestUser) { + const user = await User.create({ + name: userDetails.name, + username: userDetails.username, + email: userDetails.email, + }); + + await Account.create({ + userId: user._id, + name: userDetails.name, + provider: "credentials", + providerAccountId: userDetails.email, + password: await bcrypt.hash(userDetails.password || "password123", 12), + }); + + console.log(`๐Ÿ‘ค Created test user: ${userDetails.username}`); + return user; +}