From 8fd9b82ab191d5152ba7c5b801dc35922d62f2bb Mon Sep 17 00:00:00 2001 From: krokosik Date: Sun, 16 Aug 2026 18:28:39 +0200 Subject: [PATCH 1/9] Bootstrap testing harness --- jest.config.ts | 195 +--------------- jest.integration.config.ts | 18 ++ package.json | 10 +- pnpm-lock.yaml | 413 ++++++++++++++++++--------------- src/tests/setup/component.ts | 1 + src/tests/setup/integration.ts | 1 + 6 files changed, 260 insertions(+), 378 deletions(-) create mode 100644 jest.integration.config.ts create mode 100644 src/tests/setup/component.ts create mode 100644 src/tests/setup/integration.ts diff --git a/jest.config.ts b/jest.config.ts index f45658bc4..5d937f800 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -1,214 +1,25 @@ -/** - * For a detailed explanation regarding each configuration property, visit: - * https://jestjs.io/docs/configuration - */ - import type { Config } from 'jest'; import nextJest from 'next/jest.js'; // @ts-expect-error we are extending BigInt prototype for JSON serialization // oxlint-disable-next-line no-extend-native BigInt.prototype.toJSON = function toJSON() { - // Custom JSON serialization for BigInt to avoid errors in Jest return this.toString(); }; const createJestConfig = nextJest({ - // Provide the path to your Next.js app to load next.config.js and .env files in your test environment dir: './', }); const config: Config = { - // All imported modules in your tests should be mocked automatically - // automock: false, - - // Stop running tests after `n` failures - // bail: 0, - - // The directory where Jest should store its cached dependency information - // cacheDirectory: "C:\\Users\\Wiktor\\AppData\\Local\\Temp\\jest", - - // Automatically clear mock calls, instances, contexts and results before every test - // clearMocks: false, - - // Indicates whether the coverage information should be collected while executing the test - // collectCoverage: false, - - // An array of glob patterns indicating a set of files for which coverage information should be collected - // collectCoverageFrom: undefined, - - // The directory where Jest should output its coverage files - // coverageDirectory: undefined, - - // An array of regexp pattern strings used to skip coverage collection - // coveragePathIgnorePatterns: [ - // "\\\\node_modules\\\\" - // ], - - // Indicates which provider should be used to instrument code for coverage coverageProvider: 'v8', - - // A list of reporter names that Jest uses when writing coverage reports - // coverageReporters: [ - // "json", - // "text", - // "lcov", - // "clover" - // ], - - // An object that configures minimum threshold enforcement for coverage results - // coverageThreshold: undefined, - - // A path to a custom dependency extractor - // dependencyExtractor: undefined, - - // Make calling deprecated APIs throw helpful error messages - // errorOnDeprecated: false, - - // The default configuration for fake timers - // fakeTimers: { - // "enableGlobally": false - // }, - - // Force coverage collection from ignored files using an array of glob patterns - // forceCoverageMatch: [], - - // A path to a module which exports an async function that is triggered once before all test suites - // globalSetup: undefined, - - // A path to a module which exports an async function that is triggered once after all test suites - // globalTeardown: undefined, - - // A set of global variables that need to be available in all test environments - // globals: {}, - - // The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers. - // maxWorkers: "50%", - - // An array of directory names to be searched recursively up from the requiring module's location - // moduleDirectories: [ - // "node_modules" - // ], - - // An array of file extensions your modules use - // moduleFileExtensions: [ - // "js", - // "mjs", - // "cjs", - // "jsx", - // "ts", - // "tsx", - // "json", - // "node" - // ], - - // A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module moduleNameMapper: { '^~/(.*)$': '/src/$1', }, - - // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader - // modulePathIgnorePatterns: [], - - // Activates notifications for test results - // notify: false, - - // An enum that specifies notification mode. Requires { notify: true } - // notifyMode: "failure-change", - - // A preset that is used as a base for Jest's configuration - // preset: undefined, - - // Run tests from one or more projects - // projects: undefined, - - // Use this configuration option to add custom reporters to Jest - // reporters: undefined, - - // Automatically reset mock state before every test - // resetMocks: false, - - // Reset the module registry before running each individual test - // resetModules: false, - - // A path to a custom resolver - // resolver: undefined, - - // Automatically restore mock state and implementation before every test - // restoreMocks: false, - - // The root directory that Jest should scan for tests and modules within - // rootDir: undefined, - - // A list of paths to directories that Jest should use to search for files in - // roots: [ - // "" - // ], - - // Allows you to use a custom runner instead of Jest's default test runner - // runner: "jest-runner", - - // The paths to modules that run some code to configure or set up the testing environment before each test - // setupFiles: [], - - // A list of paths to modules that run some code to configure or set up the testing framework before each test - // setupFilesAfterEnv: [], - - // The number of seconds after which a test is considered as slow and reported as such in the results. - // slowTestThreshold: 5, - - // A list of paths to snapshot serializer modules Jest should use for snapshot testing - // snapshotSerializers: [], - - // The test environment that will be used for testing + setupFilesAfterEnv: ['/src/tests/setup/component.ts'], testEnvironment: 'jsdom', - - // Options that will be passed to the testEnvironment - // testEnvironmentOptions: {}, - - // Adds a location field to test results - // testLocationInResults: false, - - // The glob patterns Jest uses to detect test files - // testMatch: [ - // "**/__tests__/**/*.[jt]s?(x)", - // "**/?(*.)+(spec|test).[tj]s?(x)" - // ], - - // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped - // testPathIgnorePatterns: [ - // "\\\\node_modules\\\\" - // ], - - // The regexp pattern or array of patterns that Jest uses to detect test files - // testRegex: [], - - // This option allows the use of a custom results processor - // testResultsProcessor: undefined, - - // This option allows use of a custom test runner - // testRunner: "jest-circus/runner", - - // A map from regular expressions to paths to transformers - // transform: undefined, - - // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation - // transformIgnorePatterns: [ - // "\\\\node_modules\\\\", - // "\\.pnp\\.[^\\\\]+$" - // ], - - // An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them - // unmockedModulePathPatterns: undefined, - - // Indicates whether each individual test should be reported during the run - // verbose: undefined, - - // An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode - // watchPathIgnorePatterns: [], - - // Whether to use watchman for file crawling - // watchman: true, + testMatch: ['/src/**/*.{test,spec}.{ts,tsx}'], + testPathIgnorePatterns: ['/src/tests/integration/'], }; export default createJestConfig(config); diff --git a/jest.integration.config.ts b/jest.integration.config.ts new file mode 100644 index 000000000..4745972a6 --- /dev/null +++ b/jest.integration.config.ts @@ -0,0 +1,18 @@ +import type { Config } from 'jest'; +import nextJest from 'next/jest.js'; + +const createJestConfig = nextJest({ + dir: './', +}); + +const config: Config = { + coverageProvider: 'v8', + moduleNameMapper: { + '^~/(.*)$': '/src/$1', + }, + setupFilesAfterEnv: ['/src/tests/setup/integration.ts'], + testEnvironment: 'node', + testMatch: ['/src/tests/integration/**/*.{test,spec}.{ts,tsx}'], +}; + +export default createJestConfig(config); diff --git a/package.json b/package.json index 8ef2a1e14..7c866ee3a 100644 --- a/package.json +++ b/package.json @@ -22,8 +22,10 @@ "dx": "pnpm i && pnpm dx:up && pnpm db:dev", "dx:up": "docker compose -f docker/dev/compose.yml --env-file .env up -d", "dx:down": "docker compose -f docker/dev/compose.yml down", - "test": "jest", + "test": "jest --config jest.config.ts", "test:watch": "jest --watch", + "test:integration": "jest --config jest.integration.config.ts", + "test:integration:watch": "jest --config jest.integration.config.ts --watch", "prepare": "husky" }, "dependencies": { @@ -79,9 +81,13 @@ }, "devDependencies": { "@faker-js/faker": "10.1.0", + "@playwright/test": "^1.55.0", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.0", + "@testing-library/user-event": "^14.6.1", "@tailwindcss/postcss": "^4.1.10", "@types/formidable": "^3.4.6", - "@types/jest": "^29.5.14", + "@types/jest": "^30.0.0", "@types/node": "^22.15.21", "@types/nodemailer": "^8.0.0", "@types/react": "19.2.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b86928770..e97bff27d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -36,13 +36,13 @@ importers: version: 13.8.0(react@19.2.1) '@next-auth/prisma-adapter': specifier: ^1.0.7 - version: 1.0.7(@prisma/client@6.19.1(prisma@6.19.3(typescript@5.7.3))(typescript@5.7.3))(next-auth@4.24.14(next@15.5.18(@babel/core@7.28.3)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(nodemailer@9.0.1)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)) + version: 1.0.7(@prisma/client@6.19.1(prisma@6.19.3(typescript@5.7.3))(typescript@5.7.3))(next-auth@4.24.14(next@15.5.18(@babel/core@7.28.3)(@playwright/test@1.62.1)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(nodemailer@9.0.1)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)) '@prisma/client': specifier: ^6.16.2 version: 6.19.1(prisma@6.19.3(typescript@5.7.3))(typescript@5.7.3) '@serwist/next': specifier: ^9.2.1 - version: 9.2.3(next@15.5.18(@babel/core@7.28.3)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(typescript@5.7.3) + version: 9.2.3(next@15.5.18(@babel/core@7.28.3)(@playwright/test@1.62.1)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(typescript@5.7.3) '@t3-oss/env-nextjs': specifier: ^0.13.8 version: 0.13.10(typescript@5.7.3)(zod@3.25.76) @@ -54,7 +54,7 @@ importers: version: 11.8.0(@trpc/server@11.8.0(typescript@5.7.3))(typescript@5.7.3) '@trpc/next': specifier: ^11.2.0 - version: 11.8.0(@tanstack/react-query@5.90.12(react@19.2.1))(@trpc/client@11.8.0(@trpc/server@11.8.0(typescript@5.7.3))(typescript@5.7.3))(@trpc/react-query@11.8.0(@tanstack/react-query@5.90.12(react@19.2.1))(@trpc/client@11.8.0(@trpc/server@11.8.0(typescript@5.7.3))(typescript@5.7.3))(@trpc/server@11.8.0(typescript@5.7.3))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(typescript@5.7.3))(@trpc/server@11.8.0(typescript@5.7.3))(next@15.5.18(@babel/core@7.28.3)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(typescript@5.7.3) + version: 11.8.0(@tanstack/react-query@5.90.12(react@19.2.1))(@trpc/client@11.8.0(@trpc/server@11.8.0(typescript@5.7.3))(typescript@5.7.3))(@trpc/react-query@11.8.0(@tanstack/react-query@5.90.12(react@19.2.1))(@trpc/client@11.8.0(@trpc/server@11.8.0(typescript@5.7.3))(typescript@5.7.3))(@trpc/server@11.8.0(typescript@5.7.3))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(typescript@5.7.3))(@trpc/server@11.8.0(typescript@5.7.3))(next@15.5.18(@babel/core@7.28.3)(@playwright/test@1.62.1)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(typescript@5.7.3) '@trpc/react-query': specifier: ^11.2.0 version: 11.8.0(@tanstack/react-query@5.90.12(react@19.2.1))(@trpc/client@11.8.0(@trpc/server@11.8.0(typescript@5.7.3))(typescript@5.7.3))(@trpc/server@11.8.0(typescript@5.7.3))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(typescript@5.7.3) @@ -108,13 +108,13 @@ importers: version: 5.1.6 next: specifier: 15.5.18 - version: 15.5.18(@babel/core@7.28.3)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + version: 15.5.18(@babel/core@7.28.3)(@playwright/test@1.62.1)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) next-auth: specifier: ^4.24.14 - version: 4.24.14(next@15.5.18(@babel/core@7.28.3)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(nodemailer@9.0.1)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + version: 4.24.14(next@15.5.18(@babel/core@7.28.3)(@playwright/test@1.62.1)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(nodemailer@9.0.1)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) next-i18next: specifier: ^15.4.3 - version: 15.4.3(i18next@25.10.10(typescript@5.7.3))(next@15.5.18(@babel/core@7.28.3)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react-i18next@15.7.4(i18next@25.10.10(typescript@5.7.3))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(typescript@5.7.3))(react@19.2.1) + version: 15.4.3(i18next@25.10.10(typescript@5.7.3))(next@15.5.18(@babel/core@7.28.3)(@playwright/test@1.62.1)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react-i18next@15.7.4(i18next@25.10.10(typescript@5.7.3))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(typescript@5.7.3))(react@19.2.1) next-themes: specifier: ^0.4.6 version: 0.4.6(react-dom@19.2.1(react@19.2.1))(react@19.2.1) @@ -176,15 +176,27 @@ importers: '@faker-js/faker': specifier: 10.1.0 version: 10.1.0 + '@playwright/test': + specifier: ^1.55.0 + version: 1.62.1 '@tailwindcss/postcss': specifier: ^4.1.10 version: 4.1.18 + '@testing-library/jest-dom': + specifier: ^6.9.1 + version: 6.10.0(@testing-library/dom@10.4.1) + '@testing-library/react': + specifier: ^16.3.0 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.1(@types/react@19.2.1))(@types/react@19.2.1)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + '@testing-library/user-event': + specifier: ^14.6.1 + version: 14.6.4(@testing-library/dom@10.4.1) '@types/formidable': specifier: ^3.4.6 version: 3.4.6 '@types/jest': - specifier: ^29.5.14 - version: 29.5.14 + specifier: ^30.0.0 + version: 30.0.0 '@types/node': specifier: ^22.15.21 version: 22.19.3 @@ -257,6 +269,9 @@ importers: packages: + '@adobe/css-tools@4.5.0': + resolution: {integrity: sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==} + '@alloc/quick-lru@5.2.0': resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} @@ -859,10 +874,6 @@ packages: resolution: {integrity: sha512-SlLSF4Be735yQXyh2+mctBOzNDx5s5uLv88/j8Qn1wH679PDcwy67+YdADn8NJnGjzlXtN62asGH/T4vWOkfaw==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - '@jest/expect-utils@29.7.0': - resolution: {integrity: sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - '@jest/expect-utils@30.3.0': resolution: {integrity: sha512-j0+W5iQQ8hBh7tHZkTQv3q2Fh/M7Je72cIsYqC4OaktgtO7v1So9UTjp6uPBHIaB6beoF/RRsCgMJKvti0wADA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} @@ -896,10 +907,6 @@ packages: node-notifier: optional: true - '@jest/schemas@29.6.3': - resolution: {integrity: sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - '@jest/schemas@30.0.5': resolution: {integrity: sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} @@ -924,10 +931,6 @@ packages: resolution: {integrity: sha512-TLKY33fSLVd/lKB2YI1pH69ijyUblO/BQvCj566YvnwuzoTNr648iE0j22vRvVNk2HsPwByPxATg3MleS3gf5A==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - '@jest/types@29.6.3': - resolution: {integrity: sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - '@jest/types@30.3.0': resolution: {integrity: sha512-JHm87k7bA33hpBngtU8h6UBub/fqqA9uXfw+21j5Hmk7ooPHlboRNxHq0JcMtC+n8VJGP1mcfnD3Mk+XKe1oSw==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} @@ -1176,6 +1179,11 @@ packages: resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} + '@playwright/test@1.62.1': + resolution: {integrity: sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==} + engines: {node: '>=20'} + hasBin: true + '@prisma/client@6.19.1': resolution: {integrity: sha512-4SXj4Oo6HyQkLUWT8Ke5R0PTAfVOKip5Roo+6+b2EDTkFg5be0FnBWiuRJc0BC0sRQIWGMLKW1XguhVfW/z3/A==} engines: {node: '>=18.18'} @@ -1986,9 +1994,6 @@ packages: typescript: optional: true - '@sinclair/typebox@0.27.8': - resolution: {integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==} - '@sinclair/typebox@0.34.40': resolution: {integrity: sha512-gwBNIP8ZAYev/ORDWW0QvxdwPXwxBtLsdsJgSc7eDIRt8ubP+rxUBzPsrwnu16fgEF8Bx4lh/+mvQvJzcTM6Kw==} @@ -2134,6 +2139,38 @@ packages: peerDependencies: react: ^18 || ^19 + '@testing-library/dom@10.4.1': + resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} + engines: {node: '>=18'} + + '@testing-library/jest-dom@6.10.0': + resolution: {integrity: sha512-HQwu0KaB2zyT0iLzBL+8CLyZDL3KlZlZJ+2iyc9uCUnlJVskJU/UlPuVCyIPhtukjPQdT2QNoR5nCP5FqTmmDQ==} + engines: {node: '>=22', npm: '>=6', yarn: '>=1'} + deprecated: Incorrect minor release with breaking changes (Node >=22 and required @testing-library/dom peer). Use 6.9.1 for the 6.x line, or upgrade to 7.0.0. + peerDependencies: + '@testing-library/dom': '>=10 <11' + + '@testing-library/react@16.3.2': + resolution: {integrity: sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==} + engines: {node: '>=18'} + peerDependencies: + '@testing-library/dom': ^10.0.0 + '@types/react': ^18.0.0 || ^19.0.0 + '@types/react-dom': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@testing-library/user-event@14.6.4': + resolution: {integrity: sha512-QCGwP6QrjypBLwyj5cuyfVamkaIEy/XGY+1VDehbtbQqOggYmTFpFOdWR5mPz14vX8vXLMVjDHlRNBcClyO9ew==} + engines: {node: '>=12', npm: '>=6'} + peerDependencies: + '@testing-library/dom': '>=7.21.4' + '@trpc/client@11.8.0': resolution: {integrity: sha512-imJQeESX1hAapDaC4JB91yvXg41AZfBuTh/scnEiN/hAubZa5s/ikp0n+w29q2GCf+hREkr3WptUFKFJoDAIug==} peerDependencies: @@ -2187,6 +2224,9 @@ packages: '@tybys/wasm-util@0.10.0': resolution: {integrity: sha512-VyyPYFlOMNylG45GoAe0xDoLwWuowvf92F9kySqzYh8vmYm7D2u4iUJKa1tOUpS70Ku13ASrOkS4ScXFsTaCNQ==} + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + '@types/babel__core@7.20.5': resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} @@ -2214,8 +2254,8 @@ packages: '@types/istanbul-reports@3.0.4': resolution: {integrity: sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==} - '@types/jest@29.5.14': - resolution: {integrity: sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ==} + '@types/jest@30.0.0': + resolution: {integrity: sha512-XTYugzhuwqWjws0CVz8QpM36+T+Dz5mTEBKhNs/esGLnCIlGdRy+Dq78NRjd7ls7r8BC8ZRMOrKlkO1hU0JOwA==} '@types/jsdom@21.1.7': resolution: {integrity: sha512-yOriVnggzrnQ3a9OKOCxaVuSug3w3/SbOj5i7VwXWZEyUNl3bLF9V3MfxGbZKuwqJOQyRfqXyROBB1CoZLFWzA==} @@ -2444,6 +2484,13 @@ packages: resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} engines: {node: '>=10'} + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + + aria-query@5.3.2: + resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} + engines: {node: '>= 0.4'} + asap@2.0.6: resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} @@ -2497,10 +2544,6 @@ packages: brace-expansion@2.0.3: resolution: {integrity: sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==} - braces@3.0.3: - resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} - engines: {node: '>=8'} - browser-image-compression@2.0.2: resolution: {integrity: sha512-pBLlQyUf6yB8SmmngrcOw3EoS4RpQ1BcylI3T9Yqn7+4nrQTXJD4sJDe5ODnJdrvNMaio5OicFo75rDyJD2Ucw==} @@ -2561,10 +2604,6 @@ packages: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} - ci-info@3.9.0: - resolution: {integrity: sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==} - engines: {node: '>=8'} - ci-info@4.3.0: resolution: {integrity: sha512-l+2bNRMiQgcfILUi33labAZYIWlH1kWDp+ecNo5iisRKrbm0xcRyCww71/YU0Fkw0mAFpz9bJayXPjey6vkmaQ==} engines: {node: '>=8'} @@ -2671,6 +2710,9 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + css.escape@1.5.1: + resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} + cssstyle@4.6.0: resolution: {integrity: sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==} engines: {node: '>=18'} @@ -2726,6 +2768,10 @@ packages: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + destr@2.0.5: resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==} @@ -2743,14 +2789,16 @@ packages: dezalgo@1.0.4: resolution: {integrity: sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==} - diff-sequences@29.6.3: - resolution: {integrity: sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - diff@4.0.4: resolution: {integrity: sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==} engines: {node: '>=0.3.1'} + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + + dom-accessibility-api@0.6.3: + resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + dotenv@10.0.0: resolution: {integrity: sha512-rlBi9d8jpv9Sf1klPjNfFAuWDjKLwTIJJ/VxtoTwIR6hnZxcEOQCZg2oIL3MWBYw5GpUDKOEnND7LXTbIpQ03Q==} engines: {node: '>=10'} @@ -2852,10 +2900,6 @@ packages: resolution: {integrity: sha512-+I6B/IkJc1o/2tiURyz/ivu/O0nKNEArIUB5O7zBrlDVJr22SCLH3xTeEry428LvFhRzIA1g8izguxJ/gbNcVQ==} engines: {node: '>= 0.8.0'} - expect@29.7.0: - resolution: {integrity: sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - expect@30.3.0: resolution: {integrity: sha512-1zQrciTiQfRdo7qJM1uG4navm8DayFa2TgCSRlzUyNkhcJ6XUZF3hjnpkyr3VhAqPH7i/9GkG7Tv5abz6fqz0Q==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} @@ -2873,10 +2917,6 @@ packages: fb-watchman@2.0.2: resolution: {integrity: sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==} - fill-range@7.1.1: - resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} - engines: {node: '>=8'} - find-up@4.1.0: resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} engines: {node: '>=8'} @@ -2905,6 +2945,11 @@ packages: fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -3050,6 +3095,10 @@ packages: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} + indent-string@4.0.0: + resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} + engines: {node: '>=8'} + inflight@1.0.6: resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. @@ -3078,10 +3127,6 @@ packages: resolution: {integrity: sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==} engines: {node: '>=6'} - is-number@7.0.0: - resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} - engines: {node: '>=0.12.0'} - is-potential-custom-element-name@1.0.1: resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} @@ -3152,10 +3197,6 @@ packages: ts-node: optional: true - jest-diff@29.7.0: - resolution: {integrity: sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - jest-diff@30.3.0: resolution: {integrity: sha512-n3q4PDQjS4LrKxfWB3Z5KNk1XjXtZTBwQp71OP0Jo03Z6V60x++K5L8k6ZrW8MY8pOFylZvHM0zsjS1RqlHJZQ==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} @@ -3181,10 +3222,6 @@ packages: resolution: {integrity: sha512-4i6HItw/JSiJVsC5q0hnKIe/hbYfZLVG9YJ/0pU9Hz2n/9qZe3Rhn5s5CUZA5ORZlcdT/vmAXRMyONXJwPrmYQ==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - jest-get-type@29.6.3: - resolution: {integrity: sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - jest-haste-map@30.3.0: resolution: {integrity: sha512-mMi2oqG4KRU0R9QEtscl87JzMXfUhbKaFqOxmjb2CKcbHcUGFrJCBWHmnTiUqi6JcnzoBlO4rWfpdl2k/RfLCA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} @@ -3193,18 +3230,10 @@ packages: resolution: {integrity: sha512-cuKmUUGIjfXZAiGJ7TbEMx0bcqNdPPI6P1V+7aF+m/FUJqFDxkFR4JqkTu8ZOiU5AaX/x0hZ20KaaIPXQzbMGQ==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - jest-matcher-utils@29.7.0: - resolution: {integrity: sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - jest-matcher-utils@30.3.0: resolution: {integrity: sha512-HEtc9uFQgaUHkC7nLSlQL3Tph4Pjxt/yiPvkIrrDCt9jhoLIgxaubo1G+CFOnmHYMxHwwdaSN7mkIFs6ZK8OhA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - jest-message-util@29.7.0: - resolution: {integrity: sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - jest-message-util@30.3.0: resolution: {integrity: sha512-Z/j4Bo+4ySJ+JPJN3b2Qbl9hDq3VrXmnjjGEWD/x0BCXeOXPTV1iZYYzl2X8c1MaCOL+ewMyNBcm88sboE6YWw==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} @@ -3246,10 +3275,6 @@ packages: resolution: {integrity: sha512-f14c7atpb4O2DeNhwcvS810Y63wEn8O1HqK/luJ4F6M4NjvxmAKQwBUWjbExUtMxWJQ0wVgmCKymeJK6NZMnfQ==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - jest-util@29.7.0: - resolution: {integrity: sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - jest-util@30.3.0: resolution: {integrity: sha512-/jZDa00a3Sz7rdyu55NLrQCIrbyIkbBxareejQI315f/i8HjYN+ZWsDLLpoQSiUIEIyZF/R8fDg3BmB8AtHttg==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} @@ -3438,6 +3463,10 @@ packages: resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==} engines: {node: '>=12'} + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -3458,10 +3487,6 @@ packages: merge-stream@2.0.0: resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} - micromatch@4.0.8: - resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} - engines: {node: '>=8.6'} - mime-db@1.52.0: resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} engines: {node: '>= 0.6'} @@ -3478,6 +3503,10 @@ packages: resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} engines: {node: '>=18'} + min-indent@1.0.1: + resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} + engines: {node: '>=4'} + minimalistic-assert@1.0.1: resolution: {integrity: sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==} @@ -3723,6 +3752,16 @@ packages: resolution: {integrity: sha512-5CxHxiTEJUEohvxOhfNclWIrIKJYN6pODBoF7X6Iwr9nYYUsk2XsYzOMUMBybAFRTcanU2C2F5STPmsNxgT0PQ==} engines: {node: '>=10.0.0'} + playwright-core@1.62.1: + resolution: {integrity: sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==} + engines: {node: '>=20'} + hasBin: true + + playwright@1.62.1: + resolution: {integrity: sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==} + engines: {node: '>=20'} + hasBin: true + postcss@8.4.31: resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==} engines: {node: ^10 || ^12 || >=14} @@ -3809,9 +3848,9 @@ packages: resolution: {integrity: sha512-mQUvGU6aUFQ+rNvTIAcZuWGRT9a6f6Yrg9bHs4ImKF+HZCEK+plBvnAZYSIQztknZF2qnzNtr6F8s0+IuptdlQ==} engines: {node: ^14.13.1 || >=16.0.0} - pretty-format@29.7.0: - resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} pretty-format@3.8.0: resolution: {integrity: sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==} @@ -3905,6 +3944,9 @@ packages: react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + react-is@18.3.1: resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} @@ -3962,6 +4004,10 @@ packages: resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} engines: {node: '>= 14.18.0'} + redent@3.0.0: + resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} + engines: {node: '>=8'} + require-directory@2.1.1: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} @@ -4113,6 +4159,10 @@ packages: resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==} engines: {node: '>=6'} + strip-indent@3.0.0: + resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} + engines: {node: '>=8'} + strip-json-comments@3.1.1: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} @@ -4177,10 +4227,6 @@ packages: tmpl@1.0.5: resolution: {integrity: sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==} - to-regex-range@5.0.1: - resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} - engines: {node: '>=8.0'} - tough-cookie@5.1.2: resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==} engines: {node: '>=16'} @@ -4430,6 +4476,8 @@ packages: snapshots: + '@adobe/css-tools@4.5.0': {} + '@alloc/quick-lru@5.2.0': {} '@ampproject/remapping@2.3.0': @@ -4970,10 +5018,6 @@ snapshots: '@types/node': 22.19.3 jest-mock: 30.3.0 - '@jest/expect-utils@29.7.0': - dependencies: - jest-get-type: 29.6.3 - '@jest/expect-utils@30.3.0': dependencies: '@jest/get-type': 30.1.0 @@ -5038,10 +5082,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@jest/schemas@29.6.3': - dependencies: - '@sinclair/typebox': 0.27.8 - '@jest/schemas@30.0.5': dependencies: '@sinclair/typebox': 0.34.40 @@ -5092,15 +5132,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@jest/types@29.6.3': - dependencies: - '@jest/schemas': 29.6.3 - '@types/istanbul-lib-coverage': 2.0.6 - '@types/istanbul-reports': 3.0.4 - '@types/node': 22.19.3 - '@types/yargs': 17.0.33 - chalk: 4.1.2 - '@jest/types@30.3.0': dependencies: '@jest/pattern': 30.0.1 @@ -5144,10 +5175,10 @@ snapshots: '@tybys/wasm-util': 0.10.0 optional: true - '@next-auth/prisma-adapter@1.0.7(@prisma/client@6.19.1(prisma@6.19.3(typescript@5.7.3))(typescript@5.7.3))(next-auth@4.24.14(next@15.5.18(@babel/core@7.28.3)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(nodemailer@9.0.1)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))': + '@next-auth/prisma-adapter@1.0.7(@prisma/client@6.19.1(prisma@6.19.3(typescript@5.7.3))(typescript@5.7.3))(next-auth@4.24.14(next@15.5.18(@babel/core@7.28.3)(@playwright/test@1.62.1)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(nodemailer@9.0.1)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))': dependencies: '@prisma/client': 6.19.1(prisma@6.19.3(typescript@5.7.3))(typescript@5.7.3) - next-auth: 4.24.14(next@15.5.18(@babel/core@7.28.3)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(nodemailer@9.0.1)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + next-auth: 4.24.14(next@15.5.18(@babel/core@7.28.3)(@playwright/test@1.62.1)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(nodemailer@9.0.1)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) '@next/env@15.5.18': {} @@ -5263,6 +5294,10 @@ snapshots: '@pkgr/core@0.2.9': {} + '@playwright/test@1.62.1': + dependencies: + playwright: 1.62.1 + '@prisma/client@6.19.1(prisma@6.19.3(typescript@5.7.3))(typescript@5.7.3)': optionalDependencies: prisma: 6.19.3(typescript@5.7.3) @@ -6108,14 +6143,14 @@ snapshots: optionalDependencies: typescript: 5.7.3 - '@serwist/next@9.2.3(next@15.5.18(@babel/core@7.28.3)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(typescript@5.7.3)': + '@serwist/next@9.2.3(next@15.5.18(@babel/core@7.28.3)(@playwright/test@1.62.1)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(typescript@5.7.3)': dependencies: '@serwist/build': 9.2.3(typescript@5.7.3) '@serwist/webpack-plugin': 9.2.3(typescript@5.7.3) '@serwist/window': 9.2.3(typescript@5.7.3) chalk: 5.6.2 glob: 10.5.0 - next: 15.5.18(@babel/core@7.28.3)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + next: 15.5.18(@babel/core@7.28.3)(@playwright/test@1.62.1)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) serwist: 9.2.3(typescript@5.7.3) zod: 4.1.12 optionalDependencies: @@ -6138,8 +6173,6 @@ snapshots: optionalDependencies: typescript: 5.7.3 - '@sinclair/typebox@0.27.8': {} - '@sinclair/typebox@0.34.40': {} '@sinonjs/commons@3.0.1': @@ -6244,16 +6277,51 @@ snapshots: '@tanstack/query-core': 5.90.12 react: 19.2.1 + '@testing-library/dom@10.4.1': + dependencies: + '@babel/code-frame': 7.27.1 + '@babel/runtime': 7.29.2 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.5.1 + + '@testing-library/jest-dom@6.10.0(@testing-library/dom@10.4.1)': + dependencies: + '@adobe/css-tools': 4.5.0 + '@testing-library/dom': 10.4.1 + aria-query: 5.3.2 + css.escape: 1.5.1 + dom-accessibility-api: 0.6.3 + picocolors: 1.1.1 + redent: 3.0.0 + + '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.1(@types/react@19.2.1))(@types/react@19.2.1)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)': + dependencies: + '@babel/runtime': 7.29.2 + '@testing-library/dom': 10.4.1 + react: 19.2.1 + react-dom: 19.2.1(react@19.2.1) + optionalDependencies: + '@types/react': 19.2.1 + '@types/react-dom': 19.2.1(@types/react@19.2.1) + + '@testing-library/user-event@14.6.4(@testing-library/dom@10.4.1)': + dependencies: + '@testing-library/dom': 10.4.1 + '@trpc/client@11.8.0(@trpc/server@11.8.0(typescript@5.7.3))(typescript@5.7.3)': dependencies: '@trpc/server': 11.8.0(typescript@5.7.3) typescript: 5.7.3 - '@trpc/next@11.8.0(@tanstack/react-query@5.90.12(react@19.2.1))(@trpc/client@11.8.0(@trpc/server@11.8.0(typescript@5.7.3))(typescript@5.7.3))(@trpc/react-query@11.8.0(@tanstack/react-query@5.90.12(react@19.2.1))(@trpc/client@11.8.0(@trpc/server@11.8.0(typescript@5.7.3))(typescript@5.7.3))(@trpc/server@11.8.0(typescript@5.7.3))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(typescript@5.7.3))(@trpc/server@11.8.0(typescript@5.7.3))(next@15.5.18(@babel/core@7.28.3)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(typescript@5.7.3)': + '@trpc/next@11.8.0(@tanstack/react-query@5.90.12(react@19.2.1))(@trpc/client@11.8.0(@trpc/server@11.8.0(typescript@5.7.3))(typescript@5.7.3))(@trpc/react-query@11.8.0(@tanstack/react-query@5.90.12(react@19.2.1))(@trpc/client@11.8.0(@trpc/server@11.8.0(typescript@5.7.3))(typescript@5.7.3))(@trpc/server@11.8.0(typescript@5.7.3))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(typescript@5.7.3))(@trpc/server@11.8.0(typescript@5.7.3))(next@15.5.18(@babel/core@7.28.3)(@playwright/test@1.62.1)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(typescript@5.7.3)': dependencies: '@trpc/client': 11.8.0(@trpc/server@11.8.0(typescript@5.7.3))(typescript@5.7.3) '@trpc/server': 11.8.0(typescript@5.7.3) - next: 15.5.18(@babel/core@7.28.3)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + next: 15.5.18(@babel/core@7.28.3)(@playwright/test@1.62.1)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) react: 19.2.1 react-dom: 19.2.1(react@19.2.1) typescript: 5.7.3 @@ -6287,6 +6355,8 @@ snapshots: tslib: 2.8.1 optional: true + '@types/aria-query@5.0.4': {} + '@types/babel__core@7.20.5': dependencies: '@babel/parser': 7.28.3 @@ -6327,10 +6397,10 @@ snapshots: dependencies: '@types/istanbul-lib-report': 3.0.3 - '@types/jest@29.5.14': + '@types/jest@30.0.0': dependencies: - expect: 29.7.0 - pretty-format: 29.7.0 + expect: 30.3.0 + pretty-format: 30.3.0 '@types/jsdom@21.1.7': dependencies: @@ -6505,6 +6575,12 @@ snapshots: dependencies: tslib: 2.8.1 + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + + aria-query@5.3.2: {} + asap@2.0.6: {} asn1.js@5.4.1: @@ -6588,10 +6664,6 @@ snapshots: dependencies: balanced-match: 1.0.2 - braces@3.0.3: - dependencies: - fill-range: 7.1.1 - browser-image-compression@2.0.2: dependencies: uzip: 0.20201231.0 @@ -6653,8 +6725,6 @@ snapshots: dependencies: readdirp: 4.1.2 - ci-info@3.9.0: {} - ci-info@4.3.0: {} citty@0.1.6: @@ -6752,6 +6822,8 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + css.escape@1.5.1: {} + cssstyle@4.6.0: dependencies: '@asamuzakjp/css-color': 3.2.0 @@ -6786,6 +6858,8 @@ snapshots: delayed-stream@1.0.0: {} + dequal@2.0.3: {} + destr@2.0.5: {} detect-libc@2.1.2: {} @@ -6799,10 +6873,12 @@ snapshots: asap: 2.0.6 wrappy: 1.0.2 - diff-sequences@29.6.3: {} - diff@4.0.4: {} + dom-accessibility-api@0.5.16: {} + + dom-accessibility-api@0.6.3: {} + dotenv@10.0.0: {} dotenv@16.6.1: {} @@ -6915,14 +6991,6 @@ snapshots: exit-x@0.2.2: {} - expect@29.7.0: - dependencies: - '@jest/expect-utils': 29.7.0 - jest-get-type: 29.6.3 - jest-matcher-utils: 29.7.0 - jest-message-util: 29.7.0 - jest-util: 29.7.0 - expect@30.3.0: dependencies: '@jest/expect-utils': 30.3.0 @@ -6944,10 +7012,6 @@ snapshots: dependencies: bser: 2.1.1 - fill-range@7.1.1: - dependencies: - to-regex-range: 5.0.1 - find-up@4.1.0: dependencies: locate-path: 5.0.0 @@ -6976,6 +7040,9 @@ snapshots: fs.realpath@1.0.0: {} + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -7123,6 +7190,8 @@ snapshots: imurmurhash@0.1.4: {} + indent-string@4.0.0: {} + inflight@1.0.6: dependencies: once: 1.4.0 @@ -7145,8 +7214,6 @@ snapshots: is-generator-fn@2.1.0: {} - is-number@7.0.0: {} - is-potential-custom-element-name@1.0.1: {} is-stream@2.0.1: {} @@ -7275,13 +7342,6 @@ snapshots: - babel-plugin-macros - supports-color - jest-diff@29.7.0: - dependencies: - chalk: 4.1.2 - diff-sequences: 29.6.3 - jest-get-type: 29.6.3 - pretty-format: 29.7.0 - jest-diff@30.3.0: dependencies: '@jest/diff-sequences': 30.3.0 @@ -7321,8 +7381,6 @@ snapshots: jest-util: 30.3.0 jest-validate: 30.3.0 - jest-get-type@29.6.3: {} - jest-haste-map@30.3.0: dependencies: '@jest/types': 30.3.0 @@ -7343,13 +7401,6 @@ snapshots: '@jest/get-type': 30.1.0 pretty-format: 30.3.0 - jest-matcher-utils@29.7.0: - dependencies: - chalk: 4.1.2 - jest-diff: 29.7.0 - jest-get-type: 29.6.3 - pretty-format: 29.7.0 - jest-matcher-utils@30.3.0: dependencies: '@jest/get-type': 30.1.0 @@ -7357,18 +7408,6 @@ snapshots: jest-diff: 30.3.0 pretty-format: 30.3.0 - jest-message-util@29.7.0: - dependencies: - '@babel/code-frame': 7.27.1 - '@jest/types': 29.6.3 - '@types/stack-utils': 2.0.3 - chalk: 4.1.2 - graceful-fs: 4.2.11 - micromatch: 4.0.8 - pretty-format: 29.7.0 - slash: 3.0.0 - stack-utils: 2.0.6 - jest-message-util@30.3.0: dependencies: '@babel/code-frame': 7.27.1 @@ -7491,15 +7530,6 @@ snapshots: transitivePeerDependencies: - supports-color - jest-util@29.7.0: - dependencies: - '@jest/types': 29.6.3 - '@types/node': 22.19.3 - chalk: 4.1.2 - ci-info: 3.9.0 - graceful-fs: 4.2.11 - picomatch: 4.0.4 - jest-util@30.3.0: dependencies: '@jest/types': 30.3.0 @@ -7710,6 +7740,8 @@ snapshots: luxon@3.7.2: {} + lz-string@1.5.0: {} + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -7728,11 +7760,6 @@ snapshots: merge-stream@2.0.0: {} - micromatch@4.0.8: - dependencies: - braces: 3.0.3 - picomatch: 4.0.4 - mime-db@1.52.0: {} mime-types@2.1.35: @@ -7743,6 +7770,8 @@ snapshots: mimic-function@5.0.1: {} + min-indent@1.0.1: {} + minimalistic-assert@1.0.1: {} minimatch@9.0.9: @@ -7763,13 +7792,13 @@ snapshots: natural-compare@1.4.0: {} - next-auth@4.24.14(next@15.5.18(@babel/core@7.28.3)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(nodemailer@9.0.1)(react-dom@19.2.1(react@19.2.1))(react@19.2.1): + next-auth@4.24.14(next@15.5.18(@babel/core@7.28.3)(@playwright/test@1.62.1)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(nodemailer@9.0.1)(react-dom@19.2.1(react@19.2.1))(react@19.2.1): dependencies: '@babel/runtime': 7.29.2 '@panva/hkdf': 1.2.1 cookie: 0.7.2 jose: 4.15.9 - next: 15.5.18(@babel/core@7.28.3)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + next: 15.5.18(@babel/core@7.28.3)(@playwright/test@1.62.1)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) oauth: 0.9.15 openid-client: 5.7.1 preact: 10.28.3 @@ -7780,7 +7809,7 @@ snapshots: optionalDependencies: nodemailer: 9.0.1 - next-i18next@15.4.3(i18next@25.10.10(typescript@5.7.3))(next@15.5.18(@babel/core@7.28.3)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react-i18next@15.7.4(i18next@25.10.10(typescript@5.7.3))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(typescript@5.7.3))(react@19.2.1): + next-i18next@15.4.3(i18next@25.10.10(typescript@5.7.3))(next@15.5.18(@babel/core@7.28.3)(@playwright/test@1.62.1)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react-i18next@15.7.4(i18next@25.10.10(typescript@5.7.3))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(typescript@5.7.3))(react@19.2.1): dependencies: '@babel/runtime': 7.28.4 '@types/hoist-non-react-statics': 3.3.6 @@ -7788,7 +7817,7 @@ snapshots: hoist-non-react-statics: 3.3.2 i18next: 25.10.10(typescript@5.7.3) i18next-fs-backend: 2.6.0 - next: 15.5.18(@babel/core@7.28.3)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + next: 15.5.18(@babel/core@7.28.3)(@playwright/test@1.62.1)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) react: 19.2.1 react-i18next: 15.7.4(i18next@25.10.10(typescript@5.7.3))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(typescript@5.7.3) @@ -7797,7 +7826,7 @@ snapshots: react: 19.2.1 react-dom: 19.2.1(react@19.2.1) - next@15.5.18(@babel/core@7.28.3)(react-dom@19.2.1(react@19.2.1))(react@19.2.1): + next@15.5.18(@babel/core@7.28.3)(@playwright/test@1.62.1)(react-dom@19.2.1(react@19.2.1))(react@19.2.1): dependencies: '@next/env': 15.5.18 '@swc/helpers': 0.5.15 @@ -7815,6 +7844,7 @@ snapshots: '@next/swc-linux-x64-musl': 15.5.18 '@next/swc-win32-arm64-msvc': 15.5.18 '@next/swc-win32-x64-msvc': 15.5.18 + '@playwright/test': 1.62.1 sharp: 0.34.5 transitivePeerDependencies: - '@babel/core' @@ -7982,6 +8012,14 @@ snapshots: transitivePeerDependencies: - debug + playwright-core@1.62.1: {} + + playwright@1.62.1: + dependencies: + playwright-core: 1.62.1 + optionalDependencies: + fsevents: 2.3.2 + postcss@8.4.31: dependencies: nanoid: 3.3.11 @@ -8009,11 +8047,11 @@ snapshots: pretty-bytes@6.1.1: {} - pretty-format@29.7.0: + pretty-format@27.5.1: dependencies: - '@jest/schemas': 29.6.3 + ansi-regex: 5.0.1 ansi-styles: 5.2.0 - react-is: 18.3.1 + react-is: 17.0.2 pretty-format@3.8.0: {} @@ -8149,6 +8187,8 @@ snapshots: react-is@16.13.1: {} + react-is@17.0.2: {} + react-is@18.3.1: {} react-plaid-link@4.1.1(react-dom@19.2.1(react@19.2.1))(react@19.2.1): @@ -8199,6 +8239,11 @@ snapshots: readdirp@4.1.2: {} + redent@3.0.0: + dependencies: + indent-string: 4.0.0 + strip-indent: 3.0.0 + require-directory@2.1.1: {} resolve-cwd@3.0.0: @@ -8352,6 +8397,10 @@ snapshots: strip-final-newline@2.0.0: {} + strip-indent@3.0.0: + dependencies: + min-indent: 1.0.1 + strip-json-comments@3.1.1: {} styled-jsx@5.1.6(@babel/core@7.28.3)(react@19.2.1): @@ -8401,10 +8450,6 @@ snapshots: tmpl@1.0.5: {} - to-regex-range@5.0.1: - dependencies: - is-number: 7.0.0 - tough-cookie@5.1.2: dependencies: tldts: 6.1.86 diff --git a/src/tests/setup/component.ts b/src/tests/setup/component.ts new file mode 100644 index 000000000..7b0828bfa --- /dev/null +++ b/src/tests/setup/component.ts @@ -0,0 +1 @@ +import '@testing-library/jest-dom'; diff --git a/src/tests/setup/integration.ts b/src/tests/setup/integration.ts new file mode 100644 index 000000000..27c713348 --- /dev/null +++ b/src/tests/setup/integration.ts @@ -0,0 +1 @@ +jest.setTimeout(30_000); From f78a1a1ed23a3c3a4f0f9f28eb141a6b329c7061 Mon Sep 17 00:00:00 2001 From: krokosik Date: Sun, 16 Aug 2026 18:34:04 +0200 Subject: [PATCH 2/9] test: add browser testing harness --- .env.example | 5 +++ .gitignore | 4 ++- playwright.config.ts | 63 +++++++++++++++++++++++++++++++++ src/env.ts | 2 ++ src/instrumentation.ts | 8 +++-- tests/e2e/auth.setup.ts | 25 +++++++++++++ tests/e2e/authorization.spec.ts | 15 ++++++++ tests/e2e/fixtures.ts | 21 +++++++++++ tests/e2e/group-expense.spec.ts | 16 +++++++++ tests/e2e/settlement.spec.ts | 7 ++++ 10 files changed, 162 insertions(+), 4 deletions(-) create mode 100644 playwright.config.ts create mode 100644 tests/e2e/auth.setup.ts create mode 100644 tests/e2e/authorization.spec.ts create mode 100644 tests/e2e/fixtures.ts create mode 100644 tests/e2e/group-expense.spec.ts create mode 100644 tests/e2e/settlement.spec.ts diff --git a/.env.example b/.env.example index 100d27125..f6b6b0008 100644 --- a/.env.example +++ b/.env.example @@ -19,6 +19,11 @@ DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@splitpro-db:5432 # https://next-auth.js.org/configuration/options#secret NEXTAUTH_SECRET="secret" NEXTAUTH_URL="http://localhost:3000" +NEXTAUTH_URL_INTERNAL="http://localhost:3000" + +# Playwright uses only this disposable database and port. +E2E_DATABASE_URL="postgresql://postgres:strong-password@localhost:5432/splitpro_test" +E2E_BASE_URL="http://127.0.0.1:3176" # The default /home page is a blog page that may not be suitable for your use case. # You can change it to /balances or any other URL you want. diff --git a/.gitignore b/.gitignore index e2d67e58d..53dc7c661 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,8 @@ # testing /coverage +/test-results +/playwright/.auth # database /prisma/db.sqlite @@ -60,4 +62,4 @@ SEED_STATISTICS.md # Agents .worktrees/ -docs/superpowers/ \ No newline at end of file +docs/superpowers/ diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 000000000..f624662eb --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,63 @@ +import { defineConfig, devices } from '@playwright/test'; + +const port = 3176; +const databaseUrl = process.env.E2E_DATABASE_URL ?? 'postgresql://postgres:strong-password@localhost:5432/splitpro_test'; + +if (!databaseUrl.includes('_test')) { + throw new Error('E2E_DATABASE_URL must point at a disposable *_test database'); +} + +process.env.DATABASE_URL = databaseUrl; +process.env.NODE_ENV = 'test'; +process.env.TEST_MODE = '1'; +process.env.NEXTAUTH_SECRET ??= 'playwright-test-secret'; +process.env.NEXTAUTH_URL = `http://127.0.0.1:${port}`; +process.env.NEXTAUTH_URL_INTERNAL = `http://127.0.0.1:${port}`; +process.env.SKIP_ENV_VALIDATION = '1'; +process.env.ENABLE_SENDING_INVITES = '0'; +process.env.DISABLE_EMAIL_SIGNUP = '0'; +process.env.INVITE_ONLY = '0'; + +export default defineConfig({ + testDir: './tests/e2e', + outputDir: 'test-results/e2e', + fullyParallel: true, + forbidOnly: Boolean(process.env.CI), + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: process.env.CI ? 'line' : 'list', + use: { + ...devices['Desktop Chrome'], + baseURL: `http://127.0.0.1:${port}`, + trace: 'on-first-retry', + screenshot: 'only-on-failure', + video: 'on-first-retry', + }, + projects: [ + { name: 'setup', testMatch: /.*\.setup\.ts/ }, + { + name: 'chromium', + use: { ...devices['Desktop Chrome'], storageState: 'playwright/.auth/user.json' }, + dependencies: ['setup'], + }, + ], + webServer: { + command: `pnpm dev --port ${port}`, + url: `http://127.0.0.1:${port}`, + reuseExistingServer: !process.env.CI, + timeout: 120_000, + env: { + ...process.env, + DATABASE_URL: databaseUrl, + NODE_ENV: 'test', + TEST_MODE: '1', + NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET ?? 'playwright-test-secret', + NEXTAUTH_URL: `http://127.0.0.1:${port}`, + NEXTAUTH_URL_INTERNAL: `http://127.0.0.1:${port}`, + SKIP_ENV_VALIDATION: '1', + ENABLE_SENDING_INVITES: '0', + DISABLE_EMAIL_SIGNUP: '0', + INVITE_ONLY: '0', + }, + }, +}); diff --git a/src/env.ts b/src/env.ts index 11c46a0e0..84ab8a7ce 100644 --- a/src/env.ts +++ b/src/env.ts @@ -17,6 +17,7 @@ export const env = createEnv({ 'You forgot to change the default URL', ), NODE_ENV: z.enum(['development', 'test', 'production']).default('development'), + TEST_MODE: z.boolean().default(false), DOCKER_OUTPUT: z.boolean().default(false), NEXTAUTH_SECRET: 'production' === process.env.NODE_ENV ? z.string() : z.string().optional(), NEXTAUTH_URL: z.preprocess( @@ -96,6 +97,7 @@ export const env = createEnv({ process.env.DATABASE_URL ?? `postgresql://${process.env.POSTGRES_USER}:${process.env.POSTGRES_PASSWORD}@${process.env.POSTGRES_HOST}:${process.env.POSTGRES_PORT}`, NODE_ENV: process.env.NODE_ENV, + TEST_MODE: parseEnvBoolean(process.env.TEST_MODE), DOCKER_OUTPUT: parseEnvBoolean(process.env.DOCKER_OUTPUT), NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET, NEXTAUTH_URL: process.env.NEXTAUTH_URL, diff --git a/src/instrumentation.ts b/src/instrumentation.ts index 17672f6a9..490cfb972 100644 --- a/src/instrumentation.ts +++ b/src/instrumentation.ts @@ -18,8 +18,10 @@ export async function register() { const { checkRecurrenceNotifications } = await import('./server/api/services/notificationService'); - console.log('Starting recurrent expense notification checking...'); - setTimeout(checkRecurrenceNotifications, 1000 * 10); // Start after 10 seconds + if (!env.TEST_MODE) { + console.log('Starting recurrent expense notification checking...'); + setTimeout(checkRecurrenceNotifications, 1000 * 10); // Start after 10 seconds + } } if (process.env.NEXT_RUNTIME !== 'nodejs') { @@ -27,7 +29,7 @@ export async function register() { return; } - if (env.CLEAR_CACHE_CRON_RULE && env.CACHE_RETENTION_INTERVAL) { + if (!env.TEST_MODE && env.CLEAR_CACHE_CRON_RULE && env.CACHE_RETENTION_INTERVAL) { // Create cron jobs console.log('Setting up cron jobs...'); diff --git a/tests/e2e/auth.setup.ts b/tests/e2e/auth.setup.ts new file mode 100644 index 000000000..46bf4dfbb --- /dev/null +++ b/tests/e2e/auth.setup.ts @@ -0,0 +1,25 @@ +import { randomBytes } from 'node:crypto'; +import { mkdir, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { test as setup } from '@playwright/test'; +import { db } from '~/server/db'; + +const authDir = join(process.cwd(), 'playwright', '.auth'); +const email = `playwright-${Date.now()}-${randomBytes(4).toString('hex')}@example.test`; + +setup('create database-backed session', async () => { + const user = await db.user.create({ data: { email, name: 'Playwright Owner', preferredLanguage: 'en' } }); + const sessionToken = randomBytes(32).toString('hex'); + await db.session.create({ + data: { sessionToken, userId: user.id, expires: new Date(Date.now() + 86_400_000) }, + }); + await mkdir(authDir, { recursive: true }); + await writeFile(join(authDir, 'user-meta.json'), JSON.stringify({ userId: user.id, email })); + await writeFile( + join(authDir, 'user.json'), + JSON.stringify({ + cookies: [{ name: 'next-auth.session-token', value: sessionToken, domain: '127.0.0.1', path: '/', httpOnly: true, sameSite: 'Lax' }], + origins: [], + }), + ); +}); diff --git a/tests/e2e/authorization.spec.ts b/tests/e2e/authorization.spec.ts new file mode 100644 index 000000000..562f97a58 --- /dev/null +++ b/tests/e2e/authorization.spec.ts @@ -0,0 +1,15 @@ +import { test, expect } from './fixtures'; + +test('redirects unauthenticated visitors to sign in', async ({ browser }) => { + const context = await browser.newContext(); + const page = await context.newPage(); + await page.goto('/groups'); + await expect(page).toHaveURL(/\/auth\/signin/); + await context.close(); +}); + +test('keeps authenticated users on protected pages', async ({ page }) => { + await page.goto('/groups'); + await expect(page).not.toHaveURL(/\/auth\/signin/); + await expect(page.getByRole('main')).toBeVisible(); +}); diff --git a/tests/e2e/fixtures.ts b/tests/e2e/fixtures.ts new file mode 100644 index 000000000..f4862aade --- /dev/null +++ b/tests/e2e/fixtures.ts @@ -0,0 +1,21 @@ +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { test as base, expect } from '@playwright/test'; +import { db } from '~/server/db'; + +type Fixtures = { userId: number; uniqueName: string }; +type AuthMeta = { userId: number }; + +const metaPath = join(process.cwd(), 'playwright', '.auth', 'user-meta.json'); + +export const test = base.extend({ + userId: async ({}, use) => { + const meta = JSON.parse(await readFile(metaPath, 'utf8')) as AuthMeta; + await use(meta.userId); + }, + uniqueName: async ({}, use, testInfo) => { + await use(`E2E ${testInfo.project.name} ${testInfo.workerIndex} ${testInfo.testId}`); + }, +}); + +export { db, expect }; diff --git a/tests/e2e/group-expense.spec.ts b/tests/e2e/group-expense.spec.ts new file mode 100644 index 000000000..fbdcc7370 --- /dev/null +++ b/tests/e2e/group-expense.spec.ts @@ -0,0 +1,16 @@ +import { test, expect } from './fixtures'; + +test('creates an isolated group and records an expense', async ({ page, uniqueName }) => { + await page.goto('/groups'); + await page.getByRole('button', { name: /create group/i }).click(); + await page.getByPlaceholder(/group name/i).fill(uniqueName); + await page.getByRole('button', { name: /submit/i }).click(); + await expect(page.getByText(uniqueName)).toBeVisible(); + + await page.getByRole('link', { name: /add expense/i }).click(); + await page.getByPlaceholder(/description/i).fill(`${uniqueName} expense`); + await page.getByPlaceholder(/amount/i).fill('12.34'); + await page.getByRole('button', { name: /^save$/i }).last().click(); + await expect(page).toHaveURL(/\/groups\/\d+\/expenses\//); + await expect(page.getByText(`${uniqueName} expense`)).toBeVisible(); +}); diff --git a/tests/e2e/settlement.spec.ts b/tests/e2e/settlement.spec.ts new file mode 100644 index 000000000..c61cc6574 --- /dev/null +++ b/tests/e2e/settlement.spec.ts @@ -0,0 +1,7 @@ +import { test, expect } from './fixtures'; + +test('shows the group balance and settlement controls', async ({ page }) => { + await page.goto('/balances'); + await expect(page).toHaveURL(/\/balances/); + await expect(page.getByRole('main')).toBeVisible(); +}); From dc6125e35d6e240ba10a339d2cd69db3affb8162 Mon Sep 17 00:00:00 2001 From: krokosik Date: Sun, 16 Aug 2026 18:38:02 +0200 Subject: [PATCH 3/9] test: add UI component harness --- src/components/Friend/Settleup.test.tsx | 81 +++++++++++++++++++++++ src/components/Layout/MainLayout.test.tsx | 42 ++++++++++++ src/components/group/CreateGroup.test.tsx | 81 +++++++++++++++++++++++ src/tests/helpers/render.tsx | 67 +++++++++++++++++++ src/tests/helpers/resetStores.ts | 14 ++++ src/tests/helpers/router.ts | 36 ++++++++++ src/tests/helpers/session.ts | 23 +++++++ 7 files changed, 344 insertions(+) create mode 100644 src/components/Friend/Settleup.test.tsx create mode 100644 src/components/Layout/MainLayout.test.tsx create mode 100644 src/components/group/CreateGroup.test.tsx create mode 100644 src/tests/helpers/render.tsx create mode 100644 src/tests/helpers/resetStores.ts create mode 100644 src/tests/helpers/router.ts create mode 100644 src/tests/helpers/session.ts diff --git a/src/components/Friend/Settleup.test.tsx b/src/components/Friend/Settleup.test.tsx new file mode 100644 index 000000000..c9e0b4284 --- /dev/null +++ b/src/components/Friend/Settleup.test.tsx @@ -0,0 +1,81 @@ +import { SplitType } from '@prisma/client'; +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { renderWithProviders } from '~/tests/helpers/render'; +import { resetStores } from '~/tests/helpers/resetStores'; +import type { MinimalBalance } from '~/types/balance.types'; + +const mutate = jest.fn(); +const invalidate = jest.fn().mockResolvedValue(undefined); + +jest.mock('~/utils/api', () => ({ + api: { + expense: { addOrEditExpense: { useMutation: () => ({ mutate }) } }, + useUtils: () => ({ user: { invalidate }, expense: { invalidate } }), + }, +})); +jest.mock('next-auth/react', () => ({ + SessionProvider: ({ children }: React.PropsWithChildren) => children, + useSession: () => ({ + data: { user: { id: 1, name: 'Alex Example', email: 'alex@example.com', image: null } }, + status: 'authenticated', + }), +})); +jest.mock('next-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => + ({ + 'actions.save': 'Save', + 'actions.settle_up': 'Settle up', + 'ui.settle_up_name': 'Settlement', + 'ui.select_balance': 'Select balance', + 'actors.you': 'You', + 'ui.expense.you.pay': 'pay', + 'ui.expense.user.pay': 'pays', + })[key] ?? key, + i18n: { language: 'en' }, + }), +})); + +const { SettleUp } = require('./Settleup') as typeof import('./Settleup'); + +const friend = { id: 2, name: 'Sam Friend', email: 'sam@example.com', image: null } as never; +const balance = { + currency: 'USD', + amount: -1250n, + friendId: 2, + groupId: 7, + groupName: 'Trip', +} satisfies MinimalBalance; + +describe('SettleUp', () => { + afterEach(resetStores); + + beforeEach(() => mutate.mockClear()); + + it('submits a settlement with the correct BigInt direction', async () => { + const user = userEvent.setup(); + renderWithProviders( + + + , + ); + + await user.click(screen.getByRole('button', { name: 'Settle up' })); + await user.click(await screen.findByRole('button', { name: 'Save' })); + + await waitFor(() => expect(mutate).toHaveBeenCalled()); + expect(mutate.mock.calls[0]?.[0]).toMatchObject({ + amount: 1250n, + splitType: SplitType.SETTLEMENT, + paidBy: 1, + groupId: 7, + participants: [ + { userId: 1, amount: 1250n }, + { userId: 2, amount: -1250n }, + ], + }); + }); +}); diff --git a/src/components/Layout/MainLayout.test.tsx b/src/components/Layout/MainLayout.test.tsx new file mode 100644 index 000000000..d09e9c97d --- /dev/null +++ b/src/components/Layout/MainLayout.test.tsx @@ -0,0 +1,42 @@ +import { screen } from '@testing-library/react'; +import React from 'react'; + +import { renderWithProviders } from '~/tests/helpers/render'; +import { createMockRouter } from '~/tests/helpers/router'; +import { resetStores } from '~/tests/helpers/resetStores'; + +const mockRouter = createMockRouter({ pathname: '/groups/7' }); +jest.mock('next/router', () => ({ useRouter: () => mockRouter })); +jest.mock('next-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => + ({ + 'meta.application_name': 'SplitPro', + 'navigation.balances': 'Balances', + 'navigation.groups': 'Groups', + 'navigation.add_expense': 'Add Expense', + 'navigation.add': 'Add', + 'navigation.activity': 'Activity', + 'navigation.account': 'Account', + })[key] ?? key, + ready: true, + i18n: { language: 'en' }, + }), +})); + +const { default: MainLayout } = require('./MainLayout') as typeof import('./MainLayout'); + +it('renders navigation links and marks the active section', () => { + renderWithProviders( + +

Content

+
, + ); + + expect(screen.getAllByRole('link', { name: 'Groups' }).length).toBeGreaterThan(0); + expect(screen.getAllByRole('link', { name: 'Balances' }).length).toBeGreaterThan(0); + expect(screen.getByText('Content')).toBeInTheDocument(); + expect(screen.getAllByRole('link', { name: 'Groups' })[0]).toHaveAttribute('href', '/groups'); +}); + +afterEach(resetStores); diff --git a/src/components/group/CreateGroup.test.tsx b/src/components/group/CreateGroup.test.tsx new file mode 100644 index 000000000..e357c4cc1 --- /dev/null +++ b/src/components/group/CreateGroup.test.tsx @@ -0,0 +1,81 @@ +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { renderWithProviders } from '~/tests/helpers/render'; +import { resetStores } from '~/tests/helpers/resetStores'; + +const mutateAsync = jest.fn(); +const refetch = jest.fn().mockResolvedValue(undefined); +const push = jest.fn().mockResolvedValue(true); + +jest.mock('next/router', () => ({ useRouter: () => ({ push }) })); +jest.mock('~/utils/api', () => ({ + api: { + group: { create: { useMutation: () => ({ mutateAsync }) } }, + useUtils: () => ({ group: { getAllGroupsWithBalances: { refetch } } }), + }, +})); +jest.mock('next-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => + ({ + 'actions.cancel': 'Cancel', + 'actions.submit': 'Submit', + 'errors.name_required': 'Name is required', + 'group_details.create_group.title': 'Create group', + 'group_details.create_group.group_name_placeholder': 'Group name', + })[key] ?? key, + ready: true, + i18n: { language: 'en' }, + }), +})); + +const { CreateGroup } = require('./CreateGroup') as typeof import('./CreateGroup'); + +describe('CreateGroup', () => { + afterEach(resetStores); + + beforeEach(() => { + mutateAsync.mockReset().mockImplementation(async (_input, options) => { + options?.onSuccess?.({ id: 42 }); + return { id: 42 }; + }); + refetch.mockClear(); + push.mockClear().mockResolvedValue(true); + }); + + it('validates an empty name and submits the accessible form', async () => { + const user = userEvent.setup(); + renderWithProviders( + + + , + ); + + await user.click(screen.getByRole('button', { name: 'New group' })); + expect(await screen.findByRole('dialog')).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'Submit' })); + expect(await screen.findByText('Name is required')).toBeInTheDocument(); + expect(mutateAsync).not.toHaveBeenCalled(); + }); + + it('creates the group, refreshes groups, and navigates to it', async () => { + const user = userEvent.setup(); + renderWithProviders( + + + , + ); + + await user.click(screen.getByRole('button', { name: 'New group' })); + await user.type(await screen.findByPlaceholderText('Group name'), 'Trip'); + await user.click(screen.getByRole('button', { name: 'Submit' })); + + await waitFor(() => + expect(mutateAsync).toHaveBeenCalledWith({ name: 'Trip' }, expect.anything()), + ); + expect(refetch).toHaveBeenCalled(); + expect(push).toHaveBeenCalledWith('/groups/42'); + }); +}); diff --git a/src/tests/helpers/render.tsx b/src/tests/helpers/render.tsx new file mode 100644 index 000000000..71b8af61c --- /dev/null +++ b/src/tests/helpers/render.tsx @@ -0,0 +1,67 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { type RenderOptions, render } from '@testing-library/react'; +import { SessionProvider } from 'next-auth/react'; +import { ThemeProvider } from 'next-themes'; +import React from 'react'; + +import { CurrencyHelpersProvider } from '~/contexts/CurrencyHelpersContext'; +import { createTestSession } from './session'; + +export const englishTranslations: Record = { + 'actions.back': 'Back', + 'actions.cancel': 'Cancel', + 'actions.save': 'Save', + 'actions.submit': 'Submit', + 'actors.you': 'You', + 'errors.name_required': 'Name is required', + 'errors.saving_expense': 'Error while saving expense', + 'group_details.create_group.title': 'Create group', + 'group_details.create_group.group_name_placeholder': 'Group name', + 'navigation.account': 'Account', + 'navigation.activity': 'Activity', + 'navigation.add': 'Add', + 'navigation.add_expense': 'Add Expense', + 'navigation.balances': 'Balances', + 'navigation.groups': 'Groups', + 'ui.expense.user.pay': 'pays', + 'ui.expense.you.pay': 'pay', + 'ui.settle_up_name': 'Settlement', + 'ui.select_balance': 'Select balance', + 'meta.application_name': 'SplitPro', +}; + +const translate = (key: string) => englishTranslations[key] ?? key; + +jest.mock('next-i18next', () => ({ + useTranslation: () => ({ + t: translate, + ready: true, + i18n: { language: 'en' }, + }), +})); + +export interface AppRenderOptions extends Omit { + session?: ReturnType | null; + queryClient?: QueryClient; +} + +export const renderWithProviders = (ui: React.ReactElement, options: AppRenderOptions = {}) => { + const { + session = createTestSession(), + queryClient = new QueryClient(), + ...renderOptions + } = options; + const Wrapper = ({ children }: React.PropsWithChildren) => ( + + + + + {children} + + + + + ); + + return render(ui, { wrapper: Wrapper, ...renderOptions }); +}; diff --git a/src/tests/helpers/resetStores.ts b/src/tests/helpers/resetStores.ts new file mode 100644 index 000000000..27cc3dd8a --- /dev/null +++ b/src/tests/helpers/resetStores.ts @@ -0,0 +1,14 @@ +import { useAddExpenseStore } from '~/store/addStore'; +import { useAppStore } from '~/store/appStore'; +import { useCurrencyPreferenceStore } from '~/store/currencyPreferenceStore'; + +const initialAddExpenseState = useAddExpenseStore.getState(); +const initialAppState = useAppStore.getState(); +const initialCurrencyPreferenceState = useCurrencyPreferenceStore.getState(); + +export const resetStores = () => { + useAddExpenseStore.setState(initialAddExpenseState, true); + useAppStore.setState(initialAppState, true); + useCurrencyPreferenceStore.setState(initialCurrencyPreferenceState, true); + window.sessionStorage.clear(); +}; diff --git a/src/tests/helpers/router.ts b/src/tests/helpers/router.ts new file mode 100644 index 000000000..283f52e01 --- /dev/null +++ b/src/tests/helpers/router.ts @@ -0,0 +1,36 @@ +import type { NextRouter } from 'next/router'; + +export interface MockRouterOptions { + pathname?: string; + asPath?: string; + query?: NextRouter['query']; + locale?: string; +} + +export const createMockRouter = (options: MockRouterOptions = {}): NextRouter => { + const router = { + pathname: options.pathname ?? '/', + route: options.pathname ?? '/', + asPath: options.asPath ?? options.pathname ?? '/', + query: options.query ?? {}, + locale: options.locale ?? 'en', + locales: ['en'], + defaultLocale: 'en', + domainLocales: undefined, + isReady: true, + isFallback: false, + isPreview: false, + isLocaleDomain: false, + basePath: '', + push: jest.fn().mockResolvedValue(true), + replace: jest.fn().mockResolvedValue(true), + reload: jest.fn(), + back: jest.fn(), + forward: jest.fn(), + prefetch: jest.fn().mockResolvedValue(undefined), + beforePopState: jest.fn(), + events: { on: jest.fn(), off: jest.fn(), emit: jest.fn() }, + } satisfies NextRouter; + + return router; +}; diff --git a/src/tests/helpers/session.ts b/src/tests/helpers/session.ts new file mode 100644 index 000000000..59ae14bb1 --- /dev/null +++ b/src/tests/helpers/session.ts @@ -0,0 +1,23 @@ +import type { Session } from 'next-auth'; + +export const testUser = { + id: 1, + name: 'Alex Example', + email: 'alex@example.com', + image: null, + currency: 'USD', + defaultCurrency: null, + preferredLanguage: 'en', + hiddenFriendIds: [], +}; + +export const createTestSession = (overrides: Partial = {}): Session => ({ + user: { ...testUser, ...overrides }, + expires: '2099-01-01T00:00:00.000Z', +}); + +export const createMockSession = (session: Session | null = createTestSession()) => ({ + data: session, + status: session ? ('authenticated' as const) : ('unauthenticated' as const), + update: jest.fn().mockResolvedValue(session), +}); From bf6a931b13bacc77214ed9481b16178a5bd3bffa Mon Sep 17 00:00:00 2001 From: krokosik Date: Sun, 16 Aug 2026 18:42:13 +0200 Subject: [PATCH 4/9] Add database integration harness --- docker/test/compose.yml | 22 ++++++++ src/server/api/trpc.ts | 14 +++-- .../authorization.integration.test.ts | 29 +++++++++++ .../integration/balances.integration.test.ts | 26 ++++++++++ src/tests/integration/database.ts | 47 +++++++++++++++++ .../integration/expense.integration.test.ts | 51 +++++++++++++++++++ src/tests/integration/factories.ts | 49 ++++++++++++++++++ .../recurrence.integration.test.ts | 45 ++++++++++++++++ src/tests/integration/trpc.ts | 20 ++++++++ 9 files changed, 298 insertions(+), 5 deletions(-) create mode 100644 docker/test/compose.yml create mode 100644 src/tests/integration/authorization.integration.test.ts create mode 100644 src/tests/integration/balances.integration.test.ts create mode 100644 src/tests/integration/database.ts create mode 100644 src/tests/integration/expense.integration.test.ts create mode 100644 src/tests/integration/factories.ts create mode 100644 src/tests/integration/recurrence.integration.test.ts create mode 100644 src/tests/integration/trpc.ts diff --git a/docker/test/compose.yml b/docker/test/compose.yml new file mode 100644 index 000000000..040e63e7b --- /dev/null +++ b/docker/test/compose.yml @@ -0,0 +1,22 @@ +name: split-pro-test + +services: + postgres: + image: ossapps/postgres:18.3-trixie + container_name: splitpro-testing-harness-db + environment: + POSTGRES_USER: splitpro + POSTGRES_PASSWORD: test-password + POSTGRES_DB: splitpro_harness_test + command: >- + postgres + -c shared_preload_libraries=pg_cron + -c cron.database_name=splitpro_harness_test + -c cron.timezone=UTC + ports: + - '55439:5432' + healthcheck: + test: ['CMD-SHELL', 'pg_isready -U splitpro -d splitpro_harness_test'] + interval: 2s + timeout: 5s + retries: 15 diff --git a/src/server/api/trpc.ts b/src/server/api/trpc.ts index fa6fdbc1a..0a5f478ab 100644 --- a/src/server/api/trpc.ts +++ b/src/server/api/trpc.ts @@ -9,6 +9,7 @@ import { TRPCError, initTRPC } from '@trpc/server'; import { type CreateNextContextOptions } from '@trpc/server/adapters/next'; +import { type PrismaClient } from '@prisma/client'; import { type Session } from 'next-auth'; import superjson from 'superjson'; import { ZodError, z } from 'zod'; @@ -24,8 +25,9 @@ import { db } from '~/server/db'; * These allow you to access things when processing a request, like the database, the session, etc. */ -interface CreateContextOptions { +export interface CreateContextOptions { session: Session | null; + db?: PrismaClient; } /** @@ -38,9 +40,9 @@ interface CreateContextOptions { * * @see https://create.t3.gg/en/usage/trpc#-serverapitrpcts */ -const createInnerTRPCContext = (opts: CreateContextOptions) => ({ +export const createInnerTRPCContext = (opts: CreateContextOptions) => ({ session: opts.session, - db, + db: opts.db ?? db, }); /** @@ -60,6 +62,8 @@ export const createTRPCContext = async (opts: CreateNextContextOptions) => { }); }; +export type InnerTRPCContext = ReturnType; + /** * 2. INITIALIZATION * @@ -68,7 +72,7 @@ export const createTRPCContext = async (opts: CreateNextContextOptions) => { * errors on the backend. */ -const t = initTRPC.context().create({ +const t = initTRPC.context().create({ transformer: superjson, errorFormatter({ shape, error }) { return { @@ -119,7 +123,7 @@ export const protectedProcedure = t.procedure.use(({ ctx, next }) => { return next({ ctx: { - // infers the `session` as non-nullable + // Infers the `session` as non-nullable session: { ...ctx.session, user: ctx.session.user }, }, }); diff --git a/src/tests/integration/authorization.integration.test.ts b/src/tests/integration/authorization.integration.test.ts new file mode 100644 index 000000000..ae9041b34 --- /dev/null +++ b/src/tests/integration/authorization.integration.test.ts @@ -0,0 +1,29 @@ +import { TRPCError } from '@trpc/server'; + +jest.mock('~/server/auth', () => ({ getServerAuthSession: jest.fn() })); +jest.mock('nanoid', () => ({ nanoid: () => 'integration-public-id' })); +jest.mock('superjson', () => ({ + default: { serialize: (value: unknown) => value, deserialize: (value: unknown) => value }, +})); +jest.mock('~/server/db', () => { + const { PrismaClient } = require('@prisma/client') as typeof import('@prisma/client'); + return { db: new PrismaClient({ datasourceUrl: process.env.DATABASE_URL }) }; +}); + +import { resetDatabase } from './database'; +import { testGroup, testUser } from './factories'; +import { callerFor } from './trpc'; + +describe('authorization integration', () => { + beforeEach(() => resetDatabase()); + + it('rejects a non-member from group procedures', async () => { + const owner = await testUser('Owner'); + const outsider = await testUser('Outsider'); + const group = await testGroup(owner.id); + + await expect( + callerFor(outsider.id).group.getGroupDetails({ groupId: group.id }), + ).rejects.toMatchObject(new TRPCError({ code: 'FORBIDDEN', message: 'Not a group member' })); + }); +}); diff --git a/src/tests/integration/balances.integration.test.ts b/src/tests/integration/balances.integration.test.ts new file mode 100644 index 000000000..9e8a500c2 --- /dev/null +++ b/src/tests/integration/balances.integration.test.ts @@ -0,0 +1,26 @@ +jest.mock('~/server/db', () => { + const { PrismaClient } = require('@prisma/client') as typeof import('@prisma/client'); + return { db: new PrismaClient({ datasourceUrl: process.env.DATABASE_URL }) }; +}); + +import { db } from '~/server/db'; +import { resetDatabase } from './database'; +import { testExpense, testUser } from './factories'; + +describe('balance view integration', () => { + beforeEach(() => resetDatabase()); + + it('calculates both sides of a double-entry balance', async () => { + const payer = await testUser('Payer'); + const participant = await testUser('Participant'); + await testExpense({ paidBy: payer.id, participantId: participant.id }); + + const balances = await db.balanceView.findMany({ orderBy: { userId: 'asc' } }); + expect(balances).toEqual( + expect.arrayContaining([ + expect.objectContaining({ userId: payer.id, friendId: participant.id, amount: 1_000n }), + expect.objectContaining({ userId: participant.id, friendId: payer.id, amount: -1_000n }), + ]), + ); + }); +}); diff --git a/src/tests/integration/database.ts b/src/tests/integration/database.ts new file mode 100644 index 000000000..5ea9bf596 --- /dev/null +++ b/src/tests/integration/database.ts @@ -0,0 +1,47 @@ +import { PrismaClient } from '@prisma/client'; + +import { db } from '~/server/db'; + +const TEST_DATABASE_PATTERN = /_test(?:[/?]|$)/i; + +export const assertTestDatabase = (url = process.env.DATABASE_URL) => { + if (!url) { + throw new Error('Integration tests require DATABASE_URL'); + } + + const parsed = new URL(url); + if (!['localhost', '127.0.0.1', '::1'].includes(parsed.hostname)) { + throw new Error(`Refusing integration tests against non-local database: ${parsed.hostname}`); + } + if (!TEST_DATABASE_PATTERN.test(parsed.pathname)) { + throw new Error( + `Refusing integration tests against database without _test suffix: ${parsed.pathname}`, + ); + } +}; + +assertTestDatabase(); + +export const resetDatabase = async () => { + assertTestDatabase(); + await db.$transaction([ + db.expenseParticipant.deleteMany(), + db.expenseNote.deleteMany(), + db.expense.deleteMany(), + db.expenseRecurrence.deleteMany(), + db.groupDefaultSplit.deleteMany(), + db.groupUser.deleteMany(), + db.group.deleteMany(), + db.friendDefaultSplit.deleteMany(), + db.pushNotification.deleteMany(), + db.cachedBankData.deleteMany(), + db.cachedCurrencyRate.deleteMany(), + db.session.deleteMany(), + db.account.deleteMany(), + db.user.deleteMany(), + ]); +}; + +export const closeDatabase = async () => db.$disconnect(); + +export const createTestClient = () => new PrismaClient({ datasourceUrl: process.env.DATABASE_URL }); diff --git a/src/tests/integration/expense.integration.test.ts b/src/tests/integration/expense.integration.test.ts new file mode 100644 index 000000000..b5640a33e --- /dev/null +++ b/src/tests/integration/expense.integration.test.ts @@ -0,0 +1,51 @@ +jest.mock('~/server/api/services/notificationService', () => ({ + sendExpensePushNotification: jest.fn().mockResolvedValue(undefined), + sendGroupSimplifyDebtsToggleNotification: jest.fn().mockResolvedValue(undefined), +})); +jest.mock('~/server/auth', () => ({ getServerAuthSession: jest.fn() })); +jest.mock('nanoid', () => ({ nanoid: () => 'integration-public-id' })); +jest.mock('superjson', () => ({ + default: { serialize: (value: unknown) => value, deserialize: (value: unknown) => value }, +})); +jest.mock('~/server/db', () => { + const { PrismaClient } = require('@prisma/client') as typeof import('@prisma/client'); + return { db: new PrismaClient({ datasourceUrl: process.env.DATABASE_URL }) }; +}); + +import { SplitType } from '@prisma/client'; + +import { db } from '~/server/db'; +import { resetDatabase } from './database'; +import { testUser } from './factories'; +import { callerFor } from './trpc'; + +describe('expense integration', () => { + beforeEach(() => resetDatabase()); + + it('persists an expense transaction and exposes its balance view', async () => { + const payer = await testUser('Payer'); + const participant = await testUser('Participant'); + const caller = callerFor(payer.id); + + const [expense] = await caller.expense.addOrEditExpense({ + paidBy: payer.id, + name: 'Dinner', + category: 'Food', + amount: 2_500n, + groupId: null, + splitType: SplitType.EQUAL, + currency: 'USD', + participants: [ + { userId: payer.id, amount: 2_500n }, + { userId: participant.id, amount: -2_500n }, + ], + }); + + expect(expense?.id).toBeDefined(); + await expect(db.balanceView.findMany({ where: { groupId: null } })).resolves.toEqual( + expect.arrayContaining([ + expect.objectContaining({ userId: payer.id, friendId: participant.id, amount: 2_500n }), + ]), + ); + }); +}); diff --git a/src/tests/integration/factories.ts b/src/tests/integration/factories.ts new file mode 100644 index 000000000..9adf5534a --- /dev/null +++ b/src/tests/integration/factories.ts @@ -0,0 +1,49 @@ +import { type Prisma, SplitType } from '@prisma/client'; + +import { db } from '~/server/db'; + +let sequence = 0; + +export const testUser = async (name = `Test User ${sequence}`) => { + const id = sequence++; + return db.user.create({ + data: { name, email: `integration-${id}@splitpro.test`, currency: 'USD' }, + }); +}; + +export const testGroup = async (userId: number, name = `Test Group ${sequence++}`) => + db.group.create({ + data: { + name, + publicId: `integration-group-${sequence++}`, + userId, + groupUsers: { create: { userId } }, + }, + }); + +export const testExpense = async (input: { + paidBy: number; + participantId: number; + groupId?: number | null; + amount?: bigint; + name?: string; +}) => { + const amount = input.amount ?? 1_000n; + const data: Prisma.ExpenseCreateInput = { + name: input.name ?? `Test Expense ${sequence++}`, + category: 'Other', + amount, + currency: 'USD', + splitType: SplitType.EQUAL, + addedByUser: { connect: { id: input.paidBy } }, + paidByUser: { connect: { id: input.paidBy } }, + ...(input.groupId ? { group: { connect: { id: input.groupId } } } : {}), + expenseParticipants: { + create: [ + { userId: input.paidBy, amount }, + { userId: input.participantId, amount: -amount }, + ], + }, + }; + return db.expense.create({ data }); +}; diff --git a/src/tests/integration/recurrence.integration.test.ts b/src/tests/integration/recurrence.integration.test.ts new file mode 100644 index 000000000..c23fd5cd5 --- /dev/null +++ b/src/tests/integration/recurrence.integration.test.ts @@ -0,0 +1,45 @@ +jest.mock('~/server/api/services/notificationService', () => ({ + sendExpensePushNotification: jest.fn().mockResolvedValue(undefined), +})); +jest.mock('~/server/auth', () => ({ getServerAuthSession: jest.fn() })); +jest.mock('nanoid', () => ({ nanoid: () => 'integration-public-id' })); +jest.mock('superjson', () => ({ + default: { serialize: (value: unknown) => value, deserialize: (value: unknown) => value }, +})); +jest.mock('~/server/db', () => { + const { PrismaClient } = require('@prisma/client') as typeof import('@prisma/client'); + return { db: new PrismaClient({ datasourceUrl: process.env.DATABASE_URL }) }; +}); + +import { SplitType } from '@prisma/client'; + +import { db } from '~/server/db'; +import { resetDatabase } from './database'; +import { testUser } from './factories'; +import { callerFor } from './trpc'; + +describe('recurrence integration', () => { + beforeEach(() => resetDatabase()); + + it('creates a pg_cron-backed recurrence with an expense', async () => { + const user = await testUser(); + const caller = callerFor(user.id); + const [expense] = await caller.expense.addOrEditExpense({ + paidBy: user.id, + name: 'Recurring expense', + category: 'Other', + amount: 500n, + groupId: null, + splitType: SplitType.EQUAL, + currency: 'USD', + participants: [{ userId: user.id, amount: 500n }], + cronExpression: '0 0 * * *', + }); + + const recurrence = await db.expenseRecurrence.findFirst({ + include: { job: true, expense: true }, + }); + expect(recurrence?.expense[0]?.id).toBe(expense?.id); + expect(recurrence?.job.schedule).toBe('0 0 * * *'); + }); +}); diff --git a/src/tests/integration/trpc.ts b/src/tests/integration/trpc.ts new file mode 100644 index 000000000..fc9a50a31 --- /dev/null +++ b/src/tests/integration/trpc.ts @@ -0,0 +1,20 @@ +import { type Session } from 'next-auth'; + +import { appRouter } from '~/server/api/root'; +import { createInnerTRPCContext } from '~/server/api/trpc'; +import { db } from '~/server/db'; + +export const sessionFor = (userId: number): Session => ({ + user: { + id: userId, + name: `Integration User ${userId}`, + email: null, + currency: 'USD', + preferredLanguage: '', + hiddenFriendIds: [], + }, + expires: '2099-01-01T00:00:00.000Z', +}); + +export const callerFor = (userId: number) => + appRouter.createCaller(createInnerTRPCContext({ session: sessionFor(userId), db })); From ecd9c4d5f13791609d3fa348dd1cacdbd6f94444 Mon Sep 17 00:00:00 2001 From: krokosik Date: Sun, 16 Aug 2026 18:43:55 +0200 Subject: [PATCH 5/9] test: fix harness formatting and type check --- playwright.config.ts | 5 +++-- tests/e2e/auth.setup.ts | 15 +++++++++++++-- tests/e2e/group-expense.spec.ts | 7 +++++-- 3 files changed, 21 insertions(+), 6 deletions(-) diff --git a/playwright.config.ts b/playwright.config.ts index f624662eb..4097ef234 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,14 +1,15 @@ import { defineConfig, devices } from '@playwright/test'; const port = 3176; -const databaseUrl = process.env.E2E_DATABASE_URL ?? 'postgresql://postgres:strong-password@localhost:5432/splitpro_test'; +const databaseUrl = + process.env.E2E_DATABASE_URL ?? + 'postgresql://postgres:strong-password@localhost:5432/splitpro_test'; if (!databaseUrl.includes('_test')) { throw new Error('E2E_DATABASE_URL must point at a disposable *_test database'); } process.env.DATABASE_URL = databaseUrl; -process.env.NODE_ENV = 'test'; process.env.TEST_MODE = '1'; process.env.NEXTAUTH_SECRET ??= 'playwright-test-secret'; process.env.NEXTAUTH_URL = `http://127.0.0.1:${port}`; diff --git a/tests/e2e/auth.setup.ts b/tests/e2e/auth.setup.ts index 46bf4dfbb..d7c327757 100644 --- a/tests/e2e/auth.setup.ts +++ b/tests/e2e/auth.setup.ts @@ -8,7 +8,9 @@ const authDir = join(process.cwd(), 'playwright', '.auth'); const email = `playwright-${Date.now()}-${randomBytes(4).toString('hex')}@example.test`; setup('create database-backed session', async () => { - const user = await db.user.create({ data: { email, name: 'Playwright Owner', preferredLanguage: 'en' } }); + const user = await db.user.create({ + data: { email, name: 'Playwright Owner', preferredLanguage: 'en' }, + }); const sessionToken = randomBytes(32).toString('hex'); await db.session.create({ data: { sessionToken, userId: user.id, expires: new Date(Date.now() + 86_400_000) }, @@ -18,7 +20,16 @@ setup('create database-backed session', async () => { await writeFile( join(authDir, 'user.json'), JSON.stringify({ - cookies: [{ name: 'next-auth.session-token', value: sessionToken, domain: '127.0.0.1', path: '/', httpOnly: true, sameSite: 'Lax' }], + cookies: [ + { + name: 'next-auth.session-token', + value: sessionToken, + domain: '127.0.0.1', + path: '/', + httpOnly: true, + sameSite: 'Lax', + }, + ], origins: [], }), ); diff --git a/tests/e2e/group-expense.spec.ts b/tests/e2e/group-expense.spec.ts index fbdcc7370..c56c875a4 100644 --- a/tests/e2e/group-expense.spec.ts +++ b/tests/e2e/group-expense.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from './fixtures'; +import { expect, test } from './fixtures'; test('creates an isolated group and records an expense', async ({ page, uniqueName }) => { await page.goto('/groups'); @@ -10,7 +10,10 @@ test('creates an isolated group and records an expense', async ({ page, uniqueNa await page.getByRole('link', { name: /add expense/i }).click(); await page.getByPlaceholder(/description/i).fill(`${uniqueName} expense`); await page.getByPlaceholder(/amount/i).fill('12.34'); - await page.getByRole('button', { name: /^save$/i }).last().click(); + await page + .getByRole('button', { name: /^save$/i }) + .last() + .click(); await expect(page).toHaveURL(/\/groups\/\d+\/expenses\//); await expect(page.getByText(`${uniqueName} expense`)).toBeVisible(); }); From c233345d3fea111a0e9468bdc67d9b7ccfee2ef9 Mon Sep 17 00:00:00 2001 From: krokosik Date: Sun, 16 Aug 2026 18:48:32 +0200 Subject: [PATCH 6/9] docs: document testing harness CI --- .github/copilot-instructions.md | 11 ++++ .github/workflows/test-e2e.yml | 69 ++++++++++++++++++++++++++ .github/workflows/test-integration.yml | 55 ++++++++++++++++++++ AGENTS.md | 27 ++++++++++ docs/testing-strategy.md | 51 +++++++++++++++++++ 5 files changed, 213 insertions(+) create mode 100644 .github/workflows/test-e2e.yml create mode 100644 .github/workflows/test-integration.yml create mode 100644 docs/testing-strategy.md diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 1cad5e837..8bf4cb6c8 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -258,3 +258,14 @@ library/API documentation. This means you should automatically use the Context7 tools to resolve library id and get library docs without me having to explicitly ask. Do not generate documentation or tests if not explicitly requested. + +## Testing Harness Rules + +- Keep fast checks independent from PostgreSQL integration and Chromium E2E jobs. +- Select unit/component tests with `pnpm test`; select database tests with + `pnpm test:integration`; select browser tests with `pnpm exec playwright test --project=chromium`. +- Integration and E2E commands may only use a disposable local database whose name ends in + `_test`. Never run reset, push, or destructive test setup against shared or production data. +- Read `docs/testing-strategy.md` before changing test selection, CI workflows, or database setup. +- When browser tests fail, inspect `test-results/e2e` and `playwright-report` artifacts before + changing application code. diff --git a/.github/workflows/test-e2e.yml b/.github/workflows/test-e2e.yml new file mode 100644 index 000000000..ee815bc63 --- /dev/null +++ b/.github/workflows/test-e2e.yml @@ -0,0 +1,69 @@ +name: E2E Tests + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +jobs: + e2e: + name: Chromium E2E tests + runs-on: ubuntu-latest + env: + DATABASE_URL: postgresql://splitpro:test-password@localhost:5432/splitpro_harness_test + E2E_DATABASE_URL: postgresql://splitpro:test-password@localhost:5432/splitpro_harness_test + SKIP_ENV_VALIDATION: '1' + services: + postgres: + image: ossapps/postgres:18.3-trixie + env: + POSTGRES_USER: splitpro + POSTGRES_PASSWORD: test-password + POSTGRES_DB: splitpro_harness_test + options: >- + --health-cmd "pg_isready -U splitpro -d splitpro_harness_test" + --health-interval 2s + --health-timeout 5s + --health-retries 15 + ports: + - 5432:5432 + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Install pnpm + uses: pnpm/action-setup@v5 + with: + run_install: false + + - name: Install Node.js + uses: actions/setup-node@v6 + with: + node-version: 22.16.0 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Prepare disposable database + run: pnpm exec prisma db push --accept-data-loss + + - name: Install Chromium + run: pnpm exec playwright install --with-deps chromium + + - name: Run Chromium E2E tests + run: pnpm exec playwright test --project=chromium + + - name: Upload Playwright artifacts on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: playwright-artifacts + path: | + test-results/e2e + playwright-report + if-no-files-found: ignore diff --git a/.github/workflows/test-integration.yml b/.github/workflows/test-integration.yml new file mode 100644 index 000000000..0afb25265 --- /dev/null +++ b/.github/workflows/test-integration.yml @@ -0,0 +1,55 @@ +name: Integration Tests + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +jobs: + integration: + name: PostgreSQL integration tests + runs-on: ubuntu-latest + env: + DATABASE_URL: postgresql://splitpro:test-password@localhost:5432/splitpro_harness_test + SKIP_ENV_VALIDATION: '1' + services: + postgres: + image: ossapps/postgres:18.3-trixie + env: + POSTGRES_USER: splitpro + POSTGRES_PASSWORD: test-password + POSTGRES_DB: splitpro_harness_test + options: >- + --health-cmd "pg_isready -U splitpro -d splitpro_harness_test" + --health-interval 2s + --health-timeout 5s + --health-retries 15 + ports: + - 5432:5432 + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Install pnpm + uses: pnpm/action-setup@v5 + with: + run_install: false + + - name: Install Node.js + uses: actions/setup-node@v6 + with: + node-version: 22.16.0 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Prepare disposable database + run: pnpm exec prisma db push --accept-data-loss + + - name: Run integration tests + run: pnpm test:integration diff --git a/AGENTS.md b/AGENTS.md index b1d556d9c..43339e35d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -323,6 +323,33 @@ Husky runs on commit: Override with `git commit --no-verify` if needed. +## Testing Harness Workflow + +Keep the fast checks (`pnpm prettier --check .`, `pnpm lint`, `pnpm tsgo --noEmit`, +`pnpm test`, and `pnpm build --no-lint`) independent from the disposable database and +browser jobs. CI runs PostgreSQL integration tests with `pnpm test:integration` and Chromium +E2E tests with `pnpm exec playwright test --project=chromium` in separate jobs. + +### Test Selection + +- `pnpm test` selects `src/**/*.{test,spec}.{ts,tsx}` and excludes `src/tests/integration/`. +- `pnpm test:integration` selects only `src/tests/integration/**/*.{test,spec}.{ts,tsx}`. +- `pnpm exec playwright test --project=chromium` selects `tests/e2e/` through + `playwright.config.ts`; setup runs before the Chromium project. +- Run one Jest file with `pnpm test src/tests/simplify.test.ts`, one integration file with + `pnpm test:integration src/tests/integration/expense.integration.test.ts`, or one browser + file with `pnpm exec playwright test tests/e2e/group-expense.spec.ts`. + +### Database Safety + +Integration and E2E databases must be local/disposable and end in `_test`. The integration +harness refuses non-local or non-test URLs; never point these commands at development, +staging, or production data. CI creates a fresh PostgreSQL service and may use +`prisma db push --accept-data-loss` because that database is disposable. Local worktrees +must use a separate PostgreSQL container, database name, and host port. + +See `docs/testing-strategy.md` for the complete command matrix and agent workflow. + Use the `ctx7` CLI to fetch current documentation whenever the user asks about a library, framework, SDK, API, CLI tool, or cloud service -- even well-known ones like React, Next.js, Prisma, Express, Tailwind, Django, or Spring Boot. This includes API syntax, configuration, version migration, library-specific debugging, setup instructions, and CLI tool usage. Use even when you think you know the answer -- your training data may not reflect recent changes. Prefer this over web search for library docs. diff --git a/docs/testing-strategy.md b/docs/testing-strategy.md new file mode 100644 index 000000000..0f9f84b10 --- /dev/null +++ b/docs/testing-strategy.md @@ -0,0 +1,51 @@ +# Testing strategy + +SplitPro has three deliberately independent test surfaces. Fast checks fail quickly without +requiring PostgreSQL; database and browser checks each provision their own disposable service. + +## Command matrix + +| Purpose | Exact command | Selection | +| --- | --- | --- | +| Formatting | `pnpm prettier --check .` | All supported files | +| Lint | `pnpm lint` | Oxlint project sources | +| Types | `pnpm tsgo --noEmit` | TypeScript project | +| Unit/component tests | `pnpm test` | `src/**/*.{test,spec}.{ts,tsx}`, excluding `src/tests/integration/` | +| One unit file | `pnpm test src/tests/simplify.test.ts` | The named file | +| Integration tests | `pnpm test:integration` | `src/tests/integration/**/*.{test,spec}.{ts,tsx}` | +| One integration file | `pnpm test:integration src/tests/integration/expense.integration.test.ts` | The named file | +| Chromium E2E | `pnpm exec playwright test --project=chromium` | `tests/e2e/`, including setup dependency | +| One E2E file | `pnpm exec playwright test tests/e2e/group-expense.spec.ts` | The named file | +| Production build | `pnpm build --no-lint` | Next.js production build | + +The pull-request `Check` workflow runs formatting, lint, types, unit/component tests, and the +build. `Integration Tests` and `E2E Tests` are separate jobs and do not depend on `Check` or on +each other. E2E failures upload `test-results/e2e` and `playwright-report`. + +## Database safety + +Integration and E2E tests are destructive by design: they create, update, and delete records. +Use only a local disposable PostgreSQL database whose database name ends in `_test`. The +integration harness also requires a local host (`localhost`, `127.0.0.1`, or `::1`) and refuses +other URLs before tests run. CI creates a new `splitpro_harness_test` service for each job and +prepares it with: + +```bash +pnpm exec prisma db push --accept-data-loss +``` + +For local work, copy `.env.example`, use a dedicated container/database/port per worktree, and +set `DATABASE_URL` (and `E2E_DATABASE_URL` for Playwright) to that `_test` database. Do not use +`pnpm db:push`, reset, seed, or these test commands against development, staging, or production. +When the disposable container is no longer needed, stop it with the project’s test-container +workflow; never clean it by deleting data from a shared server. + +## Agent workflow + +1. Read this document and the existing harness/configuration before changing tests. +2. Make the smallest change in the owning packet; keep application, Prisma, Jest, manifest, and + lockfile changes out of CI/documentation work. +3. Run the narrowest affected command first, then `pnpm prettier --check .` and any available + fast checks. Run integration/E2E only with a disposable `_test` database. +4. For E2E failures, preserve and inspect Playwright traces, screenshots, videos, and reports. +5. Report exact commands and failures; do not weaken selection or database guards to make CI pass. From db1cb1b6b56f9a80c57bb470718a68befb22a6d6 Mon Sep 17 00:00:00 2001 From: krokosik Date: Sun, 16 Aug 2026 18:55:07 +0200 Subject: [PATCH 7/9] test: harden harness isolation and safety --- jest.integration.config.ts | 3 ++ playwright.config.ts | 29 +++++++++++++------ src/env.ts | 7 ++++- src/tests/integration/database.ts | 2 ++ .../integration/expense.integration.test.ts | 6 ++-- src/tests/setup/integration.ts | 6 ++++ 6 files changed, 41 insertions(+), 12 deletions(-) diff --git a/jest.integration.config.ts b/jest.integration.config.ts index 4745972a6..3af1260fa 100644 --- a/jest.integration.config.ts +++ b/jest.integration.config.ts @@ -7,12 +7,15 @@ const createJestConfig = nextJest({ const config: Config = { coverageProvider: 'v8', + cacheDirectory: '/tmp/splitpro-jest-cache', + cache: false, moduleNameMapper: { '^~/(.*)$': '/src/$1', }, setupFilesAfterEnv: ['/src/tests/setup/integration.ts'], testEnvironment: 'node', testMatch: ['/src/tests/integration/**/*.{test,spec}.{ts,tsx}'], + maxWorkers: 1, }; export default createJestConfig(config); diff --git a/playwright.config.ts b/playwright.config.ts index 4097ef234..9f1dc1ba1 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,19 +1,30 @@ import { defineConfig, devices } from '@playwright/test'; -const port = 3176; +const defaultPort = 3176; const databaseUrl = process.env.E2E_DATABASE_URL ?? 'postgresql://postgres:strong-password@localhost:5432/splitpro_test'; +const database = new URL(databaseUrl); +const baseUrl = process.env.E2E_BASE_URL ?? `http://127.0.0.1:${defaultPort}`; +const baseUrlDetails = new URL(baseUrl); -if (!databaseUrl.includes('_test')) { - throw new Error('E2E_DATABASE_URL must point at a disposable *_test database'); +if ( + !['localhost', '127.0.0.1', '::1'].includes(database.hostname) || + !database.pathname.endsWith('_test') +) { + throw new Error('E2E_DATABASE_URL must point at a local disposable *_test database'); } +if (!['localhost', '127.0.0.1', '::1'].includes(baseUrlDetails.hostname)) { + throw new Error('E2E_BASE_URL must point at a local test server'); +} + +const port = Number(baseUrlDetails.port) || 80; process.env.DATABASE_URL = databaseUrl; process.env.TEST_MODE = '1'; process.env.NEXTAUTH_SECRET ??= 'playwright-test-secret'; -process.env.NEXTAUTH_URL = `http://127.0.0.1:${port}`; -process.env.NEXTAUTH_URL_INTERNAL = `http://127.0.0.1:${port}`; +process.env.NEXTAUTH_URL = baseUrl; +process.env.NEXTAUTH_URL_INTERNAL = baseUrl; process.env.SKIP_ENV_VALIDATION = '1'; process.env.ENABLE_SENDING_INVITES = '0'; process.env.DISABLE_EMAIL_SIGNUP = '0'; @@ -29,7 +40,7 @@ export default defineConfig({ reporter: process.env.CI ? 'line' : 'list', use: { ...devices['Desktop Chrome'], - baseURL: `http://127.0.0.1:${port}`, + baseURL: baseUrl, trace: 'on-first-retry', screenshot: 'only-on-failure', video: 'on-first-retry', @@ -44,7 +55,7 @@ export default defineConfig({ ], webServer: { command: `pnpm dev --port ${port}`, - url: `http://127.0.0.1:${port}`, + url: baseUrl, reuseExistingServer: !process.env.CI, timeout: 120_000, env: { @@ -53,8 +64,8 @@ export default defineConfig({ NODE_ENV: 'test', TEST_MODE: '1', NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET ?? 'playwright-test-secret', - NEXTAUTH_URL: `http://127.0.0.1:${port}`, - NEXTAUTH_URL_INTERNAL: `http://127.0.0.1:${port}`, + NEXTAUTH_URL: baseUrl, + NEXTAUTH_URL_INTERNAL: baseUrl, SKIP_ENV_VALIDATION: '1', ENABLE_SENDING_INVITES: '0', DISABLE_EMAIL_SIGNUP: '0', diff --git a/src/env.ts b/src/env.ts index 84ab8a7ce..40c73db8e 100644 --- a/src/env.ts +++ b/src/env.ts @@ -17,7 +17,12 @@ export const env = createEnv({ 'You forgot to change the default URL', ), NODE_ENV: z.enum(['development', 'test', 'production']).default('development'), - TEST_MODE: z.boolean().default(false), + TEST_MODE: z + .boolean() + .default(false) + .refine((testMode) => !testMode || 'production' !== process.env.NODE_ENV, { + message: 'TEST_MODE cannot be enabled in production', + }), DOCKER_OUTPUT: z.boolean().default(false), NEXTAUTH_SECRET: 'production' === process.env.NODE_ENV ? z.string() : z.string().optional(), NEXTAUTH_URL: z.preprocess( diff --git a/src/tests/integration/database.ts b/src/tests/integration/database.ts index 5ea9bf596..c1bfe088d 100644 --- a/src/tests/integration/database.ts +++ b/src/tests/integration/database.ts @@ -40,6 +40,8 @@ export const resetDatabase = async () => { db.account.deleteMany(), db.user.deleteMany(), ]); + await db.$executeRawUnsafe('DELETE FROM cron.job_run_details'); + await db.$executeRawUnsafe('DELETE FROM cron.job'); }; export const closeDatabase = async () => db.$disconnect(); diff --git a/src/tests/integration/expense.integration.test.ts b/src/tests/integration/expense.integration.test.ts index b5640a33e..dafaaa0a0 100644 --- a/src/tests/integration/expense.integration.test.ts +++ b/src/tests/integration/expense.integration.test.ts @@ -3,7 +3,6 @@ jest.mock('~/server/api/services/notificationService', () => ({ sendGroupSimplifyDebtsToggleNotification: jest.fn().mockResolvedValue(undefined), })); jest.mock('~/server/auth', () => ({ getServerAuthSession: jest.fn() })); -jest.mock('nanoid', () => ({ nanoid: () => 'integration-public-id' })); jest.mock('superjson', () => ({ default: { serialize: (value: unknown) => value, deserialize: (value: unknown) => value }, })); @@ -42,10 +41,13 @@ describe('expense integration', () => { }); expect(expense?.id).toBeDefined(); - await expect(db.balanceView.findMany({ where: { groupId: null } })).resolves.toEqual( + const balances = await db.balanceView.findMany({ where: { groupId: null } }); + expect(balances).toEqual( expect.arrayContaining([ expect.objectContaining({ userId: payer.id, friendId: participant.id, amount: 2_500n }), + expect.objectContaining({ userId: participant.id, friendId: payer.id, amount: -2_500n }), ]), ); + expect(balances.reduce((total, balance) => total + balance.amount, 0n)).toBe(0n); }); }); diff --git a/src/tests/setup/integration.ts b/src/tests/setup/integration.ts index 27c713348..88f275fb1 100644 --- a/src/tests/setup/integration.ts +++ b/src/tests/setup/integration.ts @@ -1 +1,7 @@ +import { closeDatabase } from '~/tests/integration/database'; + jest.setTimeout(30_000); + +afterAll(async () => { + await closeDatabase(); +}); From 0ffbc6eb755d2d39380c21f000854d5a85c93d14 Mon Sep 17 00:00:00 2001 From: krokosik Date: Sun, 16 Aug 2026 18:55:29 +0200 Subject: [PATCH 8/9] docs: format testing strategy --- docs/testing-strategy.md | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/docs/testing-strategy.md b/docs/testing-strategy.md index 0f9f84b10..3933c3cdb 100644 --- a/docs/testing-strategy.md +++ b/docs/testing-strategy.md @@ -5,18 +5,18 @@ requiring PostgreSQL; database and browser checks each provision their own dispo ## Command matrix -| Purpose | Exact command | Selection | -| --- | --- | --- | -| Formatting | `pnpm prettier --check .` | All supported files | -| Lint | `pnpm lint` | Oxlint project sources | -| Types | `pnpm tsgo --noEmit` | TypeScript project | -| Unit/component tests | `pnpm test` | `src/**/*.{test,spec}.{ts,tsx}`, excluding `src/tests/integration/` | -| One unit file | `pnpm test src/tests/simplify.test.ts` | The named file | -| Integration tests | `pnpm test:integration` | `src/tests/integration/**/*.{test,spec}.{ts,tsx}` | -| One integration file | `pnpm test:integration src/tests/integration/expense.integration.test.ts` | The named file | -| Chromium E2E | `pnpm exec playwright test --project=chromium` | `tests/e2e/`, including setup dependency | -| One E2E file | `pnpm exec playwright test tests/e2e/group-expense.spec.ts` | The named file | -| Production build | `pnpm build --no-lint` | Next.js production build | +| Purpose | Exact command | Selection | +| -------------------- | ------------------------------------------------------------------------- | ------------------------------------------------------------------- | +| Formatting | `pnpm prettier --check .` | All supported files | +| Lint | `pnpm lint` | Oxlint project sources | +| Types | `pnpm tsgo --noEmit` | TypeScript project | +| Unit/component tests | `pnpm test` | `src/**/*.{test,spec}.{ts,tsx}`, excluding `src/tests/integration/` | +| One unit file | `pnpm test src/tests/simplify.test.ts` | The named file | +| Integration tests | `pnpm test:integration` | `src/tests/integration/**/*.{test,spec}.{ts,tsx}` | +| One integration file | `pnpm test:integration src/tests/integration/expense.integration.test.ts` | The named file | +| Chromium E2E | `pnpm exec playwright test --project=chromium` | `tests/e2e/`, including setup dependency | +| One E2E file | `pnpm exec playwright test tests/e2e/group-expense.spec.ts` | The named file | +| Production build | `pnpm build --no-lint` | Next.js production build | The pull-request `Check` workflow runs formatting, lint, types, unit/component tests, and the build. `Integration Tests` and `E2E Tests` are separate jobs and do not depend on `Check` or on From 19e733a06bcae66c0d072e552e739ddc0b9f8104 Mon Sep 17 00:00:00 2001 From: krokosik Date: Sun, 16 Aug 2026 19:19:03 +0200 Subject: [PATCH 9/9] test: finalize automated harness --- .github/workflows/test-e2e.yml | 4 +-- .github/workflows/test-integration.yml | 2 +- docs/testing-strategy.md | 6 ++--- jest.config.ts | 1 + jest.integration.config.ts | 3 +-- package.json | 1 + src/tests/integration/database.ts | 4 +-- .../integration/expense.integration.test.ts | 2 ++ src/tests/setup/component.ts | 22 ++++++++++++++++ tests/e2e/authorization.spec.ts | 6 ++--- tests/e2e/fixtures.ts | 25 +++++++++++++++---- tests/e2e/group-expense.spec.ts | 6 +++-- tests/e2e/settlement.spec.ts | 2 +- 13 files changed, 63 insertions(+), 21 deletions(-) diff --git a/.github/workflows/test-e2e.yml b/.github/workflows/test-e2e.yml index ee815bc63..cbf143558 100644 --- a/.github/workflows/test-e2e.yml +++ b/.github/workflows/test-e2e.yml @@ -50,13 +50,13 @@ jobs: run: pnpm install --frozen-lockfile - name: Prepare disposable database - run: pnpm exec prisma db push --accept-data-loss + run: pnpm exec prisma migrate deploy - name: Install Chromium run: pnpm exec playwright install --with-deps chromium - name: Run Chromium E2E tests - run: pnpm exec playwright test --project=chromium + run: pnpm test:e2e -- --project=chromium - name: Upload Playwright artifacts on failure if: failure() diff --git a/.github/workflows/test-integration.yml b/.github/workflows/test-integration.yml index 0afb25265..de7ec2897 100644 --- a/.github/workflows/test-integration.yml +++ b/.github/workflows/test-integration.yml @@ -49,7 +49,7 @@ jobs: run: pnpm install --frozen-lockfile - name: Prepare disposable database - run: pnpm exec prisma db push --accept-data-loss + run: pnpm exec prisma migrate deploy - name: Run integration tests run: pnpm test:integration diff --git a/docs/testing-strategy.md b/docs/testing-strategy.md index 3933c3cdb..02f5db55d 100644 --- a/docs/testing-strategy.md +++ b/docs/testing-strategy.md @@ -14,8 +14,8 @@ requiring PostgreSQL; database and browser checks each provision their own dispo | One unit file | `pnpm test src/tests/simplify.test.ts` | The named file | | Integration tests | `pnpm test:integration` | `src/tests/integration/**/*.{test,spec}.{ts,tsx}` | | One integration file | `pnpm test:integration src/tests/integration/expense.integration.test.ts` | The named file | -| Chromium E2E | `pnpm exec playwright test --project=chromium` | `tests/e2e/`, including setup dependency | -| One E2E file | `pnpm exec playwright test tests/e2e/group-expense.spec.ts` | The named file | +| Chromium E2E | `pnpm test:e2e -- --project=chromium` | `tests/e2e/`, including setup dependency | +| One E2E file | `pnpm test:e2e -- tests/e2e/group-expense.spec.ts` | The named file | | Production build | `pnpm build --no-lint` | Next.js production build | The pull-request `Check` workflow runs formatting, lint, types, unit/component tests, and the @@ -31,7 +31,7 @@ other URLs before tests run. CI creates a new `splitpro_harness_test` service fo prepares it with: ```bash -pnpm exec prisma db push --accept-data-loss +pnpm exec prisma migrate deploy ``` For local work, copy `.env.example`, use a dedicated container/database/port per worktree, and diff --git a/jest.config.ts b/jest.config.ts index 5d937f800..d553ddef2 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -13,6 +13,7 @@ const createJestConfig = nextJest({ const config: Config = { coverageProvider: 'v8', + cacheDirectory: '/node_modules/.cache/jest', moduleNameMapper: { '^~/(.*)$': '/src/$1', }, diff --git a/jest.integration.config.ts b/jest.integration.config.ts index 3af1260fa..77f73212f 100644 --- a/jest.integration.config.ts +++ b/jest.integration.config.ts @@ -7,8 +7,7 @@ const createJestConfig = nextJest({ const config: Config = { coverageProvider: 'v8', - cacheDirectory: '/tmp/splitpro-jest-cache', - cache: false, + cacheDirectory: '/node_modules/.cache/jest', moduleNameMapper: { '^~/(.*)$': '/src/$1', }, diff --git a/package.json b/package.json index 7c866ee3a..4c995b3e9 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "test:watch": "jest --watch", "test:integration": "jest --config jest.integration.config.ts", "test:integration:watch": "jest --config jest.integration.config.ts --watch", + "test:e2e": "PWTEST_CACHE_DIR=node_modules/.cache/playwright playwright test", "prepare": "husky" }, "dependencies": { diff --git a/src/tests/integration/database.ts b/src/tests/integration/database.ts index c1bfe088d..1318a62c9 100644 --- a/src/tests/integration/database.ts +++ b/src/tests/integration/database.ts @@ -1,7 +1,5 @@ import { PrismaClient } from '@prisma/client'; -import { db } from '~/server/db'; - const TEST_DATABASE_PATTERN = /_test(?:[/?]|$)/i; export const assertTestDatabase = (url = process.env.DATABASE_URL) => { @@ -22,6 +20,8 @@ export const assertTestDatabase = (url = process.env.DATABASE_URL) => { assertTestDatabase(); +const db = new PrismaClient({ datasourceUrl: process.env.DATABASE_URL }); + export const resetDatabase = async () => { assertTestDatabase(); await db.$transaction([ diff --git a/src/tests/integration/expense.integration.test.ts b/src/tests/integration/expense.integration.test.ts index dafaaa0a0..bc12acc10 100644 --- a/src/tests/integration/expense.integration.test.ts +++ b/src/tests/integration/expense.integration.test.ts @@ -3,6 +3,8 @@ jest.mock('~/server/api/services/notificationService', () => ({ sendGroupSimplifyDebtsToggleNotification: jest.fn().mockResolvedValue(undefined), })); jest.mock('~/server/auth', () => ({ getServerAuthSession: jest.fn() })); +let mockNanoidSequence = 0; +jest.mock('nanoid', () => ({ nanoid: () => `integration-public-id-${mockNanoidSequence++}` })); jest.mock('superjson', () => ({ default: { serialize: (value: unknown) => value, deserialize: (value: unknown) => value }, })); diff --git a/src/tests/setup/component.ts b/src/tests/setup/component.ts index 7b0828bfa..902f5a618 100644 --- a/src/tests/setup/component.ts +++ b/src/tests/setup/component.ts @@ -1 +1,23 @@ import '@testing-library/jest-dom'; + +Object.defineProperty(window, 'matchMedia', { + writable: true, + value: (query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: () => undefined, + removeListener: () => undefined, + addEventListener: () => undefined, + removeEventListener: () => undefined, + dispatchEvent: () => false, + }), +}); + +Object.defineProperty(CSSStyleDeclaration.prototype, 'transform', { + configurable: true, + value: 'none', +}); + +HTMLElement.prototype.setPointerCapture = () => undefined; +HTMLElement.prototype.releasePointerCapture = () => undefined; diff --git a/tests/e2e/authorization.spec.ts b/tests/e2e/authorization.spec.ts index 562f97a58..a16298821 100644 --- a/tests/e2e/authorization.spec.ts +++ b/tests/e2e/authorization.spec.ts @@ -1,9 +1,9 @@ -import { test, expect } from './fixtures'; +import { expect, test } from './fixtures'; test('redirects unauthenticated visitors to sign in', async ({ browser }) => { - const context = await browser.newContext(); + const context = await browser.newContext({ storageState: { cookies: [], origins: [] } }); const page = await context.newPage(); - await page.goto('/groups'); + await page.goto('/balances'); await expect(page).toHaveURL(/\/auth\/signin/); await context.close(); }); diff --git a/tests/e2e/fixtures.ts b/tests/e2e/fixtures.ts index f4862aade..9fb126dc5 100644 --- a/tests/e2e/fixtures.ts +++ b/tests/e2e/fixtures.ts @@ -3,17 +3,32 @@ import { join } from 'node:path'; import { test as base, expect } from '@playwright/test'; import { db } from '~/server/db'; -type Fixtures = { userId: number; uniqueName: string }; -type AuthMeta = { userId: number }; +interface Fixtures { + userId: number; + uniqueName: string; +} + +interface AuthMeta { + userId: number; +} const metaPath = join(process.cwd(), 'playwright', '.auth', 'user-meta.json'); export const test = base.extend({ - userId: async ({}, use) => { - const meta = JSON.parse(await readFile(metaPath, 'utf8')) as AuthMeta; + userId: async ({ page: _page }, use) => { + const parsed: unknown = JSON.parse(await readFile(metaPath, 'utf8')); + if ( + !parsed || + 'object' !== typeof parsed || + !('userId' in parsed) || + 'number' !== typeof parsed.userId + ) { + throw new Error('Playwright authentication metadata has no numeric userId'); + } + const meta: AuthMeta = { userId: parsed.userId }; await use(meta.userId); }, - uniqueName: async ({}, use, testInfo) => { + uniqueName: async ({ page: _page }, use, testInfo) => { await use(`E2E ${testInfo.project.name} ${testInfo.workerIndex} ${testInfo.testId}`); }, }); diff --git a/tests/e2e/group-expense.spec.ts b/tests/e2e/group-expense.spec.ts index c56c875a4..de5055f6e 100644 --- a/tests/e2e/group-expense.spec.ts +++ b/tests/e2e/group-expense.spec.ts @@ -2,12 +2,14 @@ import { expect, test } from './fixtures'; test('creates an isolated group and records an expense', async ({ page, uniqueName }) => { await page.goto('/groups'); - await page.getByRole('button', { name: /create group/i }).click(); + await page.getByRole('button', { name: /^create$/i }).click(); await page.getByPlaceholder(/group name/i).fill(uniqueName); await page.getByRole('button', { name: /submit/i }).click(); await expect(page.getByText(uniqueName)).toBeVisible(); - await page.getByRole('link', { name: /add expense/i }).click(); + await expect(page).toHaveURL(/\/groups\/\d+/); + const groupId = page.url().match(/\/groups\/(\d+)/)?.[1]; + await page.goto(`/add?groupId=${groupId}`); await page.getByPlaceholder(/description/i).fill(`${uniqueName} expense`); await page.getByPlaceholder(/amount/i).fill('12.34'); await page diff --git a/tests/e2e/settlement.spec.ts b/tests/e2e/settlement.spec.ts index c61cc6574..518ffef3f 100644 --- a/tests/e2e/settlement.spec.ts +++ b/tests/e2e/settlement.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from './fixtures'; +import { expect, test } from './fixtures'; test('shows the group balance and settlement controls', async ({ page }) => { await page.goto('/balances');