From 15f833b42d819306b11e3d5a9794191efe3c70c3 Mon Sep 17 00:00:00 2001 From: myrmidex Date: Fri, 26 Sep 2025 18:04:38 +0200 Subject: [PATCH 01/14] Implement Sanctum --- .gitignore | 1 + .../Http/Controllers/API/AuthController.php | 103 ++++++ backend/app/Http/Middleware/Cors.php | 38 +++ backend/app/Models/User.php | 3 +- backend/bootstrap/app.php | 5 +- backend/composer.json | 2 +- backend/composer.lock | 2 +- backend/config/cors.php | 34 ++ backend/config/sanctum.php | 84 +++++ ...47_create_personal_access_tokens_table.php | 33 ++ backend/routes/api.php | 23 ++ docker-compose.dev.yml | 18 +- docker/backend/Dockerfile.dev | 17 +- docker/frontend/Dockerfile.dev | 6 +- frontend/.env | 2 + frontend/package-lock.json | 280 ++++++++++++++++ frontend/package.json | 1 + frontend/src/App.css | 299 ++++++++++++++++-- frontend/src/App.jsx | 35 +- frontend/src/components/Dashboard.jsx | 49 +++ frontend/src/components/LoginForm.jsx | 120 +++++++ frontend/src/components/RegistrationForm.jsx | 148 +++++++++ frontend/src/components/auth/AuthGuard.jsx | 74 +++++ .../src/components/auth/ProtectedRoute.jsx | 26 ++ frontend/src/contexts/AuthContext.jsx | 87 +++++ frontend/src/utils/api.js | 40 +++ 26 files changed, 1447 insertions(+), 83 deletions(-) create mode 100644 backend/app/Http/Controllers/API/AuthController.php create mode 100644 backend/app/Http/Middleware/Cors.php create mode 100644 backend/config/cors.php create mode 100644 backend/config/sanctum.php create mode 100644 backend/database/migrations/2025_09_25_234247_create_personal_access_tokens_table.php create mode 100644 backend/routes/api.php create mode 100644 frontend/.env create mode 100644 frontend/src/components/Dashboard.jsx create mode 100644 frontend/src/components/LoginForm.jsx create mode 100644 frontend/src/components/RegistrationForm.jsx create mode 100644 frontend/src/components/auth/AuthGuard.jsx create mode 100644 frontend/src/components/auth/ProtectedRoute.jsx create mode 100644 frontend/src/contexts/AuthContext.jsx create mode 100644 frontend/src/utils/api.js diff --git a/.gitignore b/.gitignore index a09c56d..70ea49a 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ /.idea +/docker/data \ No newline at end of file diff --git a/backend/app/Http/Controllers/API/AuthController.php b/backend/app/Http/Controllers/API/AuthController.php new file mode 100644 index 0000000..455393a --- /dev/null +++ b/backend/app/Http/Controllers/API/AuthController.php @@ -0,0 +1,103 @@ +all(), [ + 'name' => 'required|string|max:255', + 'email' => 'required|string|email|max:255|unique:users', + 'password' => 'required|string|min:8|confirmed', + ]); + + if ($validator->fails()) { + return response()->json([ + 'success' => false, + 'message' => 'Validation errors', + 'data' => $validator->errors() + ], 422); + } + + $user = User::create([ + 'name' => $request->name, + 'email' => $request->email, + 'password' => Hash::make($request->password), + ]); + + $token = $user->createToken('auth_token')->plainTextToken; + + return response()->json([ + 'success' => true, + 'message' => 'User registered successfully', + 'data' => [ + 'user' => $user, + 'access_token' => $token, + 'token_type' => 'Bearer' + ] + ], 201); + } + + public function login(Request $request) + { + $validator = Validator::make($request->all(), [ + 'email' => 'required|email', + 'password' => 'required', + ]); + + if ($validator->fails()) { + return response()->json([ + 'success' => false, + 'message' => 'Validation errors', + 'data' => $validator->errors() + ], 422); + } + + $user = User::where('email', $request->email)->first(); + + if (!$user || !Hash::check($request->password, $user->password)) { + throw ValidationException::withMessages([ + 'email' => ['The provided credentials are incorrect.'], + ]); + } + + $token = $user->createToken('auth_token')->plainTextToken; + + return response()->json([ + 'success' => true, + 'message' => 'Login successful', + 'data' => [ + 'user' => $user, + 'access_token' => $token, + 'token_type' => 'Bearer' + ] + ]); + } + + public function profile(Request $request) + { + return response()->json([ + 'success' => true, + 'message' => 'Profile retrieved successfully', + 'data' => $request->user() + ]); + } + + public function logout(Request $request) + { + $request->user()->currentAccessToken()->delete(); + + return response()->json([ + 'success' => true, + 'message' => 'Logout successful' + ]); + } +} \ No newline at end of file diff --git a/backend/app/Http/Middleware/Cors.php b/backend/app/Http/Middleware/Cors.php new file mode 100644 index 0000000..48e3ece --- /dev/null +++ b/backend/app/Http/Middleware/Cors.php @@ -0,0 +1,38 @@ +headers->get('Origin'); + $allowedOrigin = env('FRONTEND_URL', 'http://localhost:5173'); + + // Only set CORS headers if the origin matches our frontend + if ($origin === $allowedOrigin) { + $response->headers->set('Access-Control-Allow-Origin', $origin); + $response->headers->set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS'); + $response->headers->set('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Requested-With, Accept, X-XSRF-TOKEN'); + $response->headers->set('Access-Control-Allow-Credentials', 'true'); + } + + // Handle preflight OPTIONS requests + if ($request->getMethod() === 'OPTIONS') { + $response->setStatusCode(200); + } + + return $response; + } +} \ No newline at end of file diff --git a/backend/app/Models/User.php b/backend/app/Models/User.php index 749c7b7..91135d7 100644 --- a/backend/app/Models/User.php +++ b/backend/app/Models/User.php @@ -6,11 +6,12 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; +use Laravel\Sanctum\HasApiTokens; class User extends Authenticatable { /** @use HasFactory<\Database\Factories\UserFactory> */ - use HasFactory, Notifiable; + use HasApiTokens, HasFactory, Notifiable; /** * The attributes that are mass assignable. diff --git a/backend/bootstrap/app.php b/backend/bootstrap/app.php index c183276..3e248e3 100644 --- a/backend/bootstrap/app.php +++ b/backend/bootstrap/app.php @@ -7,11 +7,14 @@ return Application::configure(basePath: dirname(__DIR__)) ->withRouting( web: __DIR__.'/../routes/web.php', + api: __DIR__.'/../routes/api.php', commands: __DIR__.'/../routes/console.php', health: '/up', ) ->withMiddleware(function (Middleware $middleware): void { - // + $middleware->api(prepend: [ + \App\Http\Middleware\Cors::class, + ]); }) ->withExceptions(function (Exceptions $exceptions): void { // diff --git a/backend/composer.json b/backend/composer.json index 34284d9..40d10fc 100644 --- a/backend/composer.json +++ b/backend/composer.json @@ -11,7 +11,7 @@ "require": { "php": "^8.2", "laravel/framework": "^12.0", - "laravel/sanctum": "^4.2", + "laravel/sanctum": "^4.0", "laravel/tinker": "^2.10.1" }, "require-dev": { diff --git a/backend/composer.lock b/backend/composer.lock index a4d9098..7077164 100644 --- a/backend/composer.lock +++ b/backend/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "8f387a0734f3bf879214e4aa2fca6e2f", + "content-hash": "d3c16cb86c42230c6c023d9a5d9bcf42", "packages": [ { "name": "brick/math", diff --git a/backend/config/cors.php b/backend/config/cors.php new file mode 100644 index 0000000..ce5a23f --- /dev/null +++ b/backend/config/cors.php @@ -0,0 +1,34 @@ + ['api/*', 'sanctum/csrf-cookie'], + + 'allowed_methods' => ['*'], + + 'allowed_origins' => [env('FRONTEND_URL', 'http://localhost:5173')], + + 'allowed_origins_patterns' => [], + + 'allowed_headers' => ['*'], + + 'exposed_headers' => [], + + 'max_age' => 0, + + 'supports_credentials' => true, + +]; \ No newline at end of file diff --git a/backend/config/sanctum.php b/backend/config/sanctum.php new file mode 100644 index 0000000..44527d6 --- /dev/null +++ b/backend/config/sanctum.php @@ -0,0 +1,84 @@ + explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf( + '%s%s', + 'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1', + Sanctum::currentApplicationUrlWithPort(), + // Sanctum::currentRequestHost(), + ))), + + /* + |-------------------------------------------------------------------------- + | Sanctum Guards + |-------------------------------------------------------------------------- + | + | This array contains the authentication guards that will be checked when + | Sanctum is trying to authenticate a request. If none of these guards + | are able to authenticate the request, Sanctum will use the bearer + | token that's present on an incoming request for authentication. + | + */ + + 'guard' => ['web'], + + /* + |-------------------------------------------------------------------------- + | Expiration Minutes + |-------------------------------------------------------------------------- + | + | This value controls the number of minutes until an issued token will be + | considered expired. This will override any values set in the token's + | "expires_at" attribute, but first-party sessions are not affected. + | + */ + + 'expiration' => null, + + /* + |-------------------------------------------------------------------------- + | Token Prefix + |-------------------------------------------------------------------------- + | + | Sanctum can prefix new tokens in order to take advantage of numerous + | security scanning initiatives maintained by open source platforms + | that notify developers if they commit tokens into repositories. + | + | See: https://docs.github.com/en/code-security/secret-scanning/about-secret-scanning + | + */ + + 'token_prefix' => env('SANCTUM_TOKEN_PREFIX', ''), + + /* + |-------------------------------------------------------------------------- + | Sanctum Middleware + |-------------------------------------------------------------------------- + | + | When authenticating your first-party SPA with Sanctum you may need to + | customize some of the middleware Sanctum uses while processing the + | request. You may change the middleware listed below as required. + | + */ + + 'middleware' => [ + 'authenticate_session' => Laravel\Sanctum\Http\Middleware\AuthenticateSession::class, + 'encrypt_cookies' => Illuminate\Cookie\Middleware\EncryptCookies::class, + 'validate_csrf_token' => Illuminate\Foundation\Http\Middleware\ValidateCsrfToken::class, + ], + +]; diff --git a/backend/database/migrations/2025_09_25_234247_create_personal_access_tokens_table.php b/backend/database/migrations/2025_09_25_234247_create_personal_access_tokens_table.php new file mode 100644 index 0000000..40ff706 --- /dev/null +++ b/backend/database/migrations/2025_09_25_234247_create_personal_access_tokens_table.php @@ -0,0 +1,33 @@ +id(); + $table->morphs('tokenable'); + $table->text('name'); + $table->string('token', 64)->unique(); + $table->text('abilities')->nullable(); + $table->timestamp('last_used_at')->nullable(); + $table->timestamp('expires_at')->nullable()->index(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('personal_access_tokens'); + } +}; diff --git a/backend/routes/api.php b/backend/routes/api.php new file mode 100644 index 0000000..946570d --- /dev/null +++ b/backend/routes/api.php @@ -0,0 +1,23 @@ +where('any', '.*'); + +// Public routes +Route::post('/register', [AuthController::class, 'register']); +Route::post('/login', [AuthController::class, 'login']); + +// Protected routes +Route::middleware('auth:sanctum')->group(function () { + Route::get('/user', function (Request $request) { + return $request->user(); + }); + Route::get('/profile', [AuthController::class, 'profile']); + Route::post('/logout', [AuthController::class, 'logout']); +}); diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index fb6c208..7e4f380 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -9,10 +9,11 @@ services: ports: - "5173:5173" volumes: - - ./frontend:/app:z - - node_modules:/app/node_modules:Z + - ./frontend:/app:Z + - node_modules:/app/node_modules environment: - NODE_ENV=development + privileged: true networks: - trip-planner-network @@ -24,13 +25,14 @@ services: ports: - "8000:8000" volumes: - - ./backend:/var/www/html:z - - vendor:/var/www/html/vendor:Z + - ./backend:/var/www/html:Z + - vendor:/var/www/html/vendor env_file: - .env.local depends_on: - database - redis + privileged: true networks: - trip-planner-network @@ -45,7 +47,8 @@ services: MYSQL_USER: ${DB_USERNAME:-trip_user} MYSQL_PASSWORD: ${DB_PASSWORD:-secret} volumes: - - db-data:/var/lib/mysql:Z + - ./docker/data/mysql-data:/var/lib/mysql:Z + privileged: true networks: - trip-planner-network @@ -55,7 +58,8 @@ services: ports: - "6379:6379" volumes: - - redis-data:/data:Z + - redis-data:/data + privileged: true networks: - trip-planner-network @@ -76,4 +80,4 @@ volumes: db-data: redis-data: node_modules: - vendor: \ No newline at end of file + vendor: diff --git a/docker/backend/Dockerfile.dev b/docker/backend/Dockerfile.dev index ce59d40..4891c28 100644 --- a/docker/backend/Dockerfile.dev +++ b/docker/backend/Dockerfile.dev @@ -21,20 +21,11 @@ COPY --from=composer:latest /usr/bin/composer /usr/bin/composer WORKDIR /var/www/html -# Create developer user with UID 1000 (same as host user) -RUN adduser -u 1000 -s /bin/sh -D developer +# Create storage and bootstrap/cache directories with proper permissions +RUN mkdir -p storage/app/public storage/framework/cache storage/framework/sessions storage/framework/views storage/logs bootstrap/cache && \ + chmod -R 777 storage bootstrap/cache -# Create storage and bootstrap/cache directories -RUN mkdir -p storage/app/public storage/framework/cache storage/framework/sessions storage/framework/views storage/logs bootstrap/cache - -# Change ownership to developer user -RUN chown -R developer:developer /var/www/html - -# Set proper permissions for Laravel directories -RUN chmod -R 775 storage bootstrap/cache - -# Switch to developer user -USER developer +# Run as root to avoid permission issues with volume mounts # Expose port 8000 for artisan serve EXPOSE 8000 diff --git a/docker/frontend/Dockerfile.dev b/docker/frontend/Dockerfile.dev index 4f242bc..c3a9e47 100644 --- a/docker/frontend/Dockerfile.dev +++ b/docker/frontend/Dockerfile.dev @@ -5,11 +5,7 @@ RUN npm install -g vite WORKDIR /app -# Change ownership of /app to node user (UID 1000) -RUN chown -R node:node /app - -# Switch to node user (UID 1000, same as host user) -USER node +# Run as root to avoid permission issues with volume mounts # Expose Vite dev server port EXPOSE 5173 diff --git a/frontend/.env b/frontend/.env new file mode 100644 index 0000000..9c4b96e --- /dev/null +++ b/frontend/.env @@ -0,0 +1,2 @@ +VITE_API_URL=http://localhost:8000 +VITE_API_BASE_URL=http://localhost:8000/api \ No newline at end of file diff --git a/frontend/package-lock.json b/frontend/package-lock.json index d1365ea..6d9cf70 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,6 +8,7 @@ "name": "frontend", "version": "0.0.0", "dependencies": { + "axios": "^1.12.2", "react": "^19.1.1", "react-dom": "^19.1.1" }, @@ -1481,6 +1482,23 @@ "dev": true, "license": "Python-2.0" }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz", + "integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.4", + "proxy-from-env": "^1.1.0" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -1543,6 +1561,19 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -1611,6 +1642,18 @@ "dev": true, "license": "MIT" }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -1672,6 +1715,29 @@ "dev": true, "license": "MIT" }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.224", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.224.tgz", @@ -1679,6 +1745,51 @@ "dev": true, "license": "ISC" }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/esbuild": { "version": "0.25.10", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.10.tgz", @@ -2012,6 +2123,42 @@ "dev": true, "license": "ISC" }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", + "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -2027,6 +2174,15 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -2037,6 +2193,43 @@ "node": ">=6.9.0" } }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -2063,6 +2256,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -2073,6 +2278,45 @@ "node": ">=8" } }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -2264,6 +2508,36 @@ "yallist": "^3.0.2" } }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -2459,6 +2733,12 @@ "node": ">= 0.8.0" } }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 82deee1..e82bc18 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,6 +10,7 @@ "preview": "vite preview" }, "dependencies": { + "axios": "^1.12.2", "react": "^19.1.1", "react-dom": "^19.1.1" }, diff --git a/frontend/src/App.css b/frontend/src/App.css index b9d355d..1532ab9 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -1,42 +1,285 @@ -#root { - max-width: 1280px; +* { + box-sizing: border-box; +} + +body { + margin: 0; + padding: 0; +} + +.App { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + padding: 2rem; + width: 100vw; + box-sizing: border-box; +} + +.App-header { + text-align: center; + margin-bottom: 2rem; +} + +.registration-form, +.login-form { + background: #f8f9fa; + padding: 2rem; + border-radius: 8px; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); + width: 100%; + max-width: 400px; + margin: 0 auto; +} + +.registration-form h2, +.login-form h2 { + text-align: center; + margin-bottom: 1.5rem; + color: #333; +} + +.form-group { + margin-bottom: 1rem; +} + +.form-group label { + display: block; + margin-bottom: 0.5rem; + font-weight: 500; + color: #555; +} + +.form-group input { + width: 100%; + padding: 0.75rem; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 1rem; + box-sizing: border-box; +} + +.form-group input:focus { + outline: none; + border-color: #007bff; + box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25); +} + +.form-group input.error { + border-color: #dc3545; +} + +.error-message { + display: block; + color: #dc3545; + font-size: 0.875rem; + margin-top: 0.25rem; +} + +.alert { + padding: 0.75rem 1rem; + margin-bottom: 1rem; + border-radius: 4px; +} + +.alert-success { + color: #155724; + background-color: #d4edda; + border: 1px solid #c3e6cb; +} + +.alert-error { + color: #721c24; + background-color: #f8d7da; + border: 1px solid #f5c6cb; +} + +button[type="submit"] { + width: 100%; + padding: 0.75rem; + background-color: #007bff; + color: white; + border: none; + border-radius: 4px; + font-size: 1rem; + cursor: pointer; + transition: background-color 0.2s; +} + +button[type="submit"]:hover:not(:disabled) { + background-color: #0056b3; +} + +button[type="submit"]:disabled { + background-color: #6c757d; + cursor: not-allowed; +} + +/* Auth Guard Styles */ +.auth-container { + width: 100%; + display: flex; + justify-content: center; + align-items: center; + min-height: 100vh; +} + +.auth-content { + width: 100%; + max-width: 400px; + display: flex; + flex-direction: column; + align-items: center; +} + +.auth-toggle { + display: flex; + margin-bottom: 2rem; + border-radius: 8px; + overflow: hidden; + border: 1px solid #ddd; +} + +.auth-toggle button { + flex: 1; + padding: 0.75rem; + background: #f8f9fa; + border: none; + cursor: pointer; + transition: all 0.2s; +} + +.auth-toggle button.active { + background: #007bff; + color: white; +} + +.auth-toggle button:hover:not(.active) { + background: #e9ecef; +} + +.auth-switch { + text-align: center; + margin-top: 1rem; +} + +.link-button { + background: none; + border: none; + color: #007bff; + cursor: pointer; + text-decoration: underline; + padding: 0; + font-size: inherit; +} + +.link-button:hover { + color: #0056b3; +} + +/* Dashboard Styles */ +.dashboard { + max-width: 1200px; margin: 0 auto; padding: 2rem; +} + +.dashboard-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 2rem; + padding-bottom: 1rem; + border-bottom: 1px solid #eee; +} + +.dashboard-header h1 { + margin: 0; + color: #333; +} + +.user-info { + display: flex; + align-items: center; + gap: 1rem; +} + +.logout-btn { + padding: 0.5rem 1rem; + background: #dc3545; + color: white; + border: none; + border-radius: 4px; + cursor: pointer; + transition: background-color 0.2s; +} + +.logout-btn:hover { + background: #c82333; +} + +.welcome-section { + text-align: center; + margin-bottom: 3rem; +} + +.welcome-section h2 { + margin-bottom: 0.5rem; + color: #333; +} + +.features-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + gap: 2rem; + margin-top: 2rem; +} + +.feature-card { + background: #f8f9fa; + padding: 2rem; + border-radius: 8px; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); text-align: center; } -.logo { - height: 6em; - padding: 1.5em; - will-change: filter; - transition: filter 300ms; -} -.logo:hover { - filter: drop-shadow(0 0 2em #646cffaa); -} -.logo.react:hover { - filter: drop-shadow(0 0 2em #61dafbaa); +.feature-card h3 { + margin-bottom: 1rem; + color: #007bff; } -@keyframes logo-spin { - from { - transform: rotate(0deg); - } - to { - transform: rotate(360deg); - } +.feature-card p { + color: #666; + line-height: 1.6; } -@media (prefers-reduced-motion: no-preference) { - a:nth-of-type(2) .logo { - animation: logo-spin infinite 20s linear; - } +/* Loading Styles */ +.loading-container { + display: flex; + justify-content: center; + align-items: center; + min-height: 200px; } -.card { - padding: 2em; +.loading-spinner { + font-size: 1.2rem; + color: #666; } -.read-the-docs { - color: #888; +.unauthorized-container { + text-align: center; + padding: 3rem; + background: #f8f9fa; + border-radius: 8px; + margin: 2rem 0; +} + +.unauthorized-container h2 { + color: #dc3545; + margin-bottom: 1rem; +} + +.unauthorized-container p { + color: #666; } diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index f67355a..3e17bf1 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,34 +1,17 @@ -import { useState } from 'react' -import reactLogo from './assets/react.svg' -import viteLogo from '/vite.svg' +import { AuthProvider } from './contexts/AuthContext' +import AuthGuard from './components/auth/AuthGuard' +import Dashboard from './components/Dashboard' import './App.css' function App() { - const [count, setCount] = useState(0) - return ( - <> -
- - Vite logo - - - React logo - + +
+ + +
-

Vite + React

-
- -

- Edit src/App.jsx and save to test HMR -

-
-

- Click on the Vite and React logos to learn more -

- +
) } diff --git a/frontend/src/components/Dashboard.jsx b/frontend/src/components/Dashboard.jsx new file mode 100644 index 0000000..415914a --- /dev/null +++ b/frontend/src/components/Dashboard.jsx @@ -0,0 +1,49 @@ +import { useAuth } from '../contexts/AuthContext'; + +const Dashboard = () => { + const { user, logout } = useAuth(); + + const handleLogout = async () => { + await logout(); + }; + + return ( +
+
+

Trip Planner Dashboard

+
+ Welcome, {user?.name}! + +
+
+ +
+
+

Welcome to Your Trip Planner

+

Start planning your next adventure!

+
+ +
+
+

Plan Trips

+

Create and organize your travel itineraries

+
+ +
+

Save Destinations

+

Keep track of places you want to visit

+
+ +
+

Share Plans

+

Collaborate with friends and family

+
+
+
+
+ ); +}; + +export default Dashboard; \ No newline at end of file diff --git a/frontend/src/components/LoginForm.jsx b/frontend/src/components/LoginForm.jsx new file mode 100644 index 0000000..0b55853 --- /dev/null +++ b/frontend/src/components/LoginForm.jsx @@ -0,0 +1,120 @@ +import { useState } from 'react'; +import api from '../utils/api'; + +const LoginForm = ({ onLoginSuccess }) => { + const [formData, setFormData] = useState({ + email: '', + password: '' + }); + + const [errors, setErrors] = useState({}); + const [isLoading, setIsLoading] = useState(false); + + const handleChange = (e) => { + const { name, value } = e.target; + setFormData(prevState => ({ + ...prevState, + [name]: value + })); + + if (errors[name]) { + setErrors(prevErrors => ({ + ...prevErrors, + [name]: '' + })); + } + }; + + const handleSubmit = async (e) => { + e.preventDefault(); + setIsLoading(true); + setErrors({}); + + try { + const response = await api.post('/login', formData); + + if (response.data.success) { + localStorage.setItem('token', response.data.data.access_token); + localStorage.setItem('user', JSON.stringify(response.data.data.user)); + + if (onLoginSuccess) { + onLoginSuccess(response.data.data); + } + } + } catch (error) { + if (error.response && error.response.status === 422) { + // Validation errors - check both .data and .errors structure + const validationErrors = error.response.data.errors || error.response.data.data || {}; + + // If it's credential error, show as general message + if (validationErrors.email && validationErrors.email[0] === 'The provided credentials are incorrect.') { + setErrors({ general: 'Invalid email or password. Please try again.' }); + } else { + setErrors(validationErrors); + } + } else if (error.response && error.response.status === 401) { + // Unauthorized - wrong credentials + setErrors({ general: 'Invalid email or password. Please try again.' }); + } else if (error.response && error.response.data.message) { + // Other server errors + setErrors({ general: error.response.data.message }); + } else if (error.request) { + // Network error + setErrors({ general: 'Unable to connect to server. Please check your connection.' }); + } else { + // Unknown error + setErrors({ general: 'Unknown error occurred. Please try again.' }); + } + } finally { + setIsLoading(false); + } + }; + + return ( +
+

Login

+ + {errors.general && ( +
+ {errors.general} +
+ )} + +
+
+ + + {errors.email && {errors.email[0]}} +
+ +
+ + + {errors.password && {errors.password[0]}} +
+ + +
+
+ ); +}; + +export default LoginForm; \ No newline at end of file diff --git a/frontend/src/components/RegistrationForm.jsx b/frontend/src/components/RegistrationForm.jsx new file mode 100644 index 0000000..35ba987 --- /dev/null +++ b/frontend/src/components/RegistrationForm.jsx @@ -0,0 +1,148 @@ +import { useState } from 'react'; +import api from '../utils/api'; + +const RegistrationForm = ({ onRegistrationSuccess }) => { + const [formData, setFormData] = useState({ + name: '', + email: '', + password: '', + password_confirmation: '' + }); + + const [errors, setErrors] = useState({}); + const [isLoading, setIsLoading] = useState(false); + const [successMessage, setSuccessMessage] = useState(''); + + const handleChange = (e) => { + const { name, value } = e.target; + setFormData(prevState => ({ + ...prevState, + [name]: value + })); + + // Clear specific field error when user starts typing + if (errors[name]) { + setErrors(prevErrors => ({ + ...prevErrors, + [name]: '' + })); + } + }; + + const handleSubmit = async (e) => { + e.preventDefault(); + setIsLoading(true); + setErrors({}); + setSuccessMessage(''); + + try { + const response = await api.post('/register', formData); + + if (response.data.success) { + setSuccessMessage('Registration successful! You are now logged in.'); + setFormData({ + name: '', + email: '', + password: '', + password_confirmation: '' + }); + + if (onRegistrationSuccess) { + onRegistrationSuccess(response.data.data); + } + } + } catch (error) { + if (error.response && error.response.status === 422) { + setErrors(error.response.data.data || {}); + } else { + setErrors({ general: 'Registration failed. Please try again.' }); + } + } finally { + setIsLoading(false); + } + }; + + return ( +
+

Register

+ + {successMessage && ( +
+ {successMessage} +
+ )} + + {errors.general && ( +
+ {errors.general} +
+ )} + +
+
+ + + {errors.name && {errors.name[0]}} +
+ +
+ + + {errors.email && {errors.email[0]}} +
+ +
+ + + {errors.password && {errors.password[0]}} +
+ +
+ + + {errors.password_confirmation && {errors.password_confirmation[0]}} +
+ + +
+
+ ); +}; + +export default RegistrationForm; \ No newline at end of file diff --git a/frontend/src/components/auth/AuthGuard.jsx b/frontend/src/components/auth/AuthGuard.jsx new file mode 100644 index 0000000..a03ab65 --- /dev/null +++ b/frontend/src/components/auth/AuthGuard.jsx @@ -0,0 +1,74 @@ +import { useAuth } from '../../contexts/AuthContext'; +import LoginForm from '../LoginForm'; +import RegistrationForm from '../RegistrationForm'; +import { useState } from 'react'; + +const AuthGuard = ({ children }) => { + const { isAuthenticated, isLoading, login, register } = useAuth(); + const [showLogin, setShowLogin] = useState(true); + + if (isLoading) { + return ( +
+
Loading...
+
+ ); + } + + if (!isAuthenticated) { + return ( +
+
+
+ + +
+ + {showLogin ? ( + + ) : ( + + )} + +
+ {showLogin ? ( +

+ Don't have an account?{' '} + +

+ ) : ( +

+ Already have an account?{' '} + +

+ )} +
+
+
+ ); + } + + return children; +}; + +export default AuthGuard; \ No newline at end of file diff --git a/frontend/src/components/auth/ProtectedRoute.jsx b/frontend/src/components/auth/ProtectedRoute.jsx new file mode 100644 index 0000000..75132f0 --- /dev/null +++ b/frontend/src/components/auth/ProtectedRoute.jsx @@ -0,0 +1,26 @@ +import { useAuth } from '../../contexts/AuthContext'; + +const ProtectedRoute = ({ children }) => { + const { isAuthenticated, isLoading } = useAuth(); + + if (isLoading) { + return ( +
+
Loading...
+
+ ); + } + + if (!isAuthenticated) { + return ( +
+

Access Denied

+

Please log in to access this page.

+
+ ); + } + + return children; +}; + +export default ProtectedRoute; \ No newline at end of file diff --git a/frontend/src/contexts/AuthContext.jsx b/frontend/src/contexts/AuthContext.jsx new file mode 100644 index 0000000..8952674 --- /dev/null +++ b/frontend/src/contexts/AuthContext.jsx @@ -0,0 +1,87 @@ +import { createContext, useContext, useState, useEffect } from 'react'; +import api from '../utils/api'; + +const AuthContext = createContext(); + +export const useAuth = () => { + const context = useContext(AuthContext); + if (!context) { + throw new Error('useAuth must be used within an AuthProvider'); + } + return context; +}; + +export const AuthProvider = ({ children }) => { + const [user, setUser] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [isAuthenticated, setIsAuthenticated] = useState(false); + + useEffect(() => { + const initializeAuth = () => { + const token = localStorage.getItem('token'); + const userData = localStorage.getItem('user'); + + if (token && userData) { + try { + const parsedUser = JSON.parse(userData); + setUser(parsedUser); + setIsAuthenticated(true); + + // Token will be automatically added by API interceptor + } catch (error) { + console.error('Error parsing user data:', error); + logout(); + } + } + setIsLoading(false); + }; + + initializeAuth(); + }, []); + + const login = (userData) => { + const { user: userInfo, access_token } = userData; + + setUser(userInfo); + setIsAuthenticated(true); + + localStorage.setItem('token', access_token); + localStorage.setItem('user', JSON.stringify(userInfo)); + }; + + const logout = async () => { + try { + const token = localStorage.getItem('token'); + if (token) { + await api.post('/logout'); + } + } catch (error) { + console.error('Logout error:', error); + } finally { + setUser(null); + setIsAuthenticated(false); + + localStorage.removeItem('token'); + localStorage.removeItem('user'); + } + }; + + const register = (userData) => { + login(userData); + }; + + const value = { + user, + isLoading, + isAuthenticated, + login, + logout, + register + }; + + return ( + + {children} + + ); +}; \ No newline at end of file diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js new file mode 100644 index 0000000..9a6148f --- /dev/null +++ b/frontend/src/utils/api.js @@ -0,0 +1,40 @@ +import axios from 'axios'; + +const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000/api'; + +const api = axios.create({ + baseURL: API_BASE_URL, + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json' + } +}); + +// Request interceptor to add auth token +api.interceptors.request.use( + (config) => { + const token = localStorage.getItem('token'); + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + return config; + }, + (error) => { + return Promise.reject(error); + } +); + +// Response interceptor to handle token expiration +api.interceptors.response.use( + (response) => response, + (error) => { + if (error.response?.status === 401) { + localStorage.removeItem('token'); + localStorage.removeItem('user'); + window.location.href = '/'; + } + return Promise.reject(error); + } +); + +export default api; \ No newline at end of file -- 2.45.2 From 8c68cdfe9f849e17e7f353da3be34d95e7d07113 Mon Sep 17 00:00:00 2001 From: myrmidex Date: Fri, 26 Sep 2025 21:50:44 +0200 Subject: [PATCH 02/14] Implement Sanctum (#23) Reviewed-on: https://codeberg.org/lvl0/trip-planner/pulls/23 Co-authored-by: myrmidex Co-committed-by: myrmidex --- .gitignore | 1 + .../Http/Controllers/API/AuthController.php | 103 ++++++ backend/app/Http/Middleware/Cors.php | 38 +++ backend/app/Models/User.php | 3 +- backend/bootstrap/app.php | 5 +- backend/composer.json | 2 +- backend/composer.lock | 2 +- backend/config/cors.php | 34 ++ backend/config/sanctum.php | 84 +++++ ...47_create_personal_access_tokens_table.php | 33 ++ backend/routes/api.php | 23 ++ docker-compose.dev.yml | 18 +- docker/backend/Dockerfile.dev | 17 +- docker/frontend/Dockerfile.dev | 6 +- frontend/.env | 2 + frontend/package-lock.json | 280 ++++++++++++++++ frontend/package.json | 1 + frontend/src/App.css | 299 ++++++++++++++++-- frontend/src/App.jsx | 35 +- frontend/src/components/Dashboard.jsx | 49 +++ frontend/src/components/LoginForm.jsx | 120 +++++++ frontend/src/components/RegistrationForm.jsx | 148 +++++++++ frontend/src/components/auth/AuthGuard.jsx | 74 +++++ .../src/components/auth/ProtectedRoute.jsx | 26 ++ frontend/src/contexts/AuthContext.jsx | 87 +++++ frontend/src/utils/api.js | 40 +++ 26 files changed, 1447 insertions(+), 83 deletions(-) create mode 100644 backend/app/Http/Controllers/API/AuthController.php create mode 100644 backend/app/Http/Middleware/Cors.php create mode 100644 backend/config/cors.php create mode 100644 backend/config/sanctum.php create mode 100644 backend/database/migrations/2025_09_25_234247_create_personal_access_tokens_table.php create mode 100644 backend/routes/api.php create mode 100644 frontend/.env create mode 100644 frontend/src/components/Dashboard.jsx create mode 100644 frontend/src/components/LoginForm.jsx create mode 100644 frontend/src/components/RegistrationForm.jsx create mode 100644 frontend/src/components/auth/AuthGuard.jsx create mode 100644 frontend/src/components/auth/ProtectedRoute.jsx create mode 100644 frontend/src/contexts/AuthContext.jsx create mode 100644 frontend/src/utils/api.js diff --git a/.gitignore b/.gitignore index a09c56d..70ea49a 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ /.idea +/docker/data \ No newline at end of file diff --git a/backend/app/Http/Controllers/API/AuthController.php b/backend/app/Http/Controllers/API/AuthController.php new file mode 100644 index 0000000..455393a --- /dev/null +++ b/backend/app/Http/Controllers/API/AuthController.php @@ -0,0 +1,103 @@ +all(), [ + 'name' => 'required|string|max:255', + 'email' => 'required|string|email|max:255|unique:users', + 'password' => 'required|string|min:8|confirmed', + ]); + + if ($validator->fails()) { + return response()->json([ + 'success' => false, + 'message' => 'Validation errors', + 'data' => $validator->errors() + ], 422); + } + + $user = User::create([ + 'name' => $request->name, + 'email' => $request->email, + 'password' => Hash::make($request->password), + ]); + + $token = $user->createToken('auth_token')->plainTextToken; + + return response()->json([ + 'success' => true, + 'message' => 'User registered successfully', + 'data' => [ + 'user' => $user, + 'access_token' => $token, + 'token_type' => 'Bearer' + ] + ], 201); + } + + public function login(Request $request) + { + $validator = Validator::make($request->all(), [ + 'email' => 'required|email', + 'password' => 'required', + ]); + + if ($validator->fails()) { + return response()->json([ + 'success' => false, + 'message' => 'Validation errors', + 'data' => $validator->errors() + ], 422); + } + + $user = User::where('email', $request->email)->first(); + + if (!$user || !Hash::check($request->password, $user->password)) { + throw ValidationException::withMessages([ + 'email' => ['The provided credentials are incorrect.'], + ]); + } + + $token = $user->createToken('auth_token')->plainTextToken; + + return response()->json([ + 'success' => true, + 'message' => 'Login successful', + 'data' => [ + 'user' => $user, + 'access_token' => $token, + 'token_type' => 'Bearer' + ] + ]); + } + + public function profile(Request $request) + { + return response()->json([ + 'success' => true, + 'message' => 'Profile retrieved successfully', + 'data' => $request->user() + ]); + } + + public function logout(Request $request) + { + $request->user()->currentAccessToken()->delete(); + + return response()->json([ + 'success' => true, + 'message' => 'Logout successful' + ]); + } +} \ No newline at end of file diff --git a/backend/app/Http/Middleware/Cors.php b/backend/app/Http/Middleware/Cors.php new file mode 100644 index 0000000..48e3ece --- /dev/null +++ b/backend/app/Http/Middleware/Cors.php @@ -0,0 +1,38 @@ +headers->get('Origin'); + $allowedOrigin = env('FRONTEND_URL', 'http://localhost:5173'); + + // Only set CORS headers if the origin matches our frontend + if ($origin === $allowedOrigin) { + $response->headers->set('Access-Control-Allow-Origin', $origin); + $response->headers->set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS'); + $response->headers->set('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Requested-With, Accept, X-XSRF-TOKEN'); + $response->headers->set('Access-Control-Allow-Credentials', 'true'); + } + + // Handle preflight OPTIONS requests + if ($request->getMethod() === 'OPTIONS') { + $response->setStatusCode(200); + } + + return $response; + } +} \ No newline at end of file diff --git a/backend/app/Models/User.php b/backend/app/Models/User.php index 749c7b7..91135d7 100644 --- a/backend/app/Models/User.php +++ b/backend/app/Models/User.php @@ -6,11 +6,12 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; +use Laravel\Sanctum\HasApiTokens; class User extends Authenticatable { /** @use HasFactory<\Database\Factories\UserFactory> */ - use HasFactory, Notifiable; + use HasApiTokens, HasFactory, Notifiable; /** * The attributes that are mass assignable. diff --git a/backend/bootstrap/app.php b/backend/bootstrap/app.php index c183276..3e248e3 100644 --- a/backend/bootstrap/app.php +++ b/backend/bootstrap/app.php @@ -7,11 +7,14 @@ return Application::configure(basePath: dirname(__DIR__)) ->withRouting( web: __DIR__.'/../routes/web.php', + api: __DIR__.'/../routes/api.php', commands: __DIR__.'/../routes/console.php', health: '/up', ) ->withMiddleware(function (Middleware $middleware): void { - // + $middleware->api(prepend: [ + \App\Http\Middleware\Cors::class, + ]); }) ->withExceptions(function (Exceptions $exceptions): void { // diff --git a/backend/composer.json b/backend/composer.json index 34284d9..40d10fc 100644 --- a/backend/composer.json +++ b/backend/composer.json @@ -11,7 +11,7 @@ "require": { "php": "^8.2", "laravel/framework": "^12.0", - "laravel/sanctum": "^4.2", + "laravel/sanctum": "^4.0", "laravel/tinker": "^2.10.1" }, "require-dev": { diff --git a/backend/composer.lock b/backend/composer.lock index a4d9098..7077164 100644 --- a/backend/composer.lock +++ b/backend/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "8f387a0734f3bf879214e4aa2fca6e2f", + "content-hash": "d3c16cb86c42230c6c023d9a5d9bcf42", "packages": [ { "name": "brick/math", diff --git a/backend/config/cors.php b/backend/config/cors.php new file mode 100644 index 0000000..ce5a23f --- /dev/null +++ b/backend/config/cors.php @@ -0,0 +1,34 @@ + ['api/*', 'sanctum/csrf-cookie'], + + 'allowed_methods' => ['*'], + + 'allowed_origins' => [env('FRONTEND_URL', 'http://localhost:5173')], + + 'allowed_origins_patterns' => [], + + 'allowed_headers' => ['*'], + + 'exposed_headers' => [], + + 'max_age' => 0, + + 'supports_credentials' => true, + +]; \ No newline at end of file diff --git a/backend/config/sanctum.php b/backend/config/sanctum.php new file mode 100644 index 0000000..44527d6 --- /dev/null +++ b/backend/config/sanctum.php @@ -0,0 +1,84 @@ + explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf( + '%s%s', + 'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1', + Sanctum::currentApplicationUrlWithPort(), + // Sanctum::currentRequestHost(), + ))), + + /* + |-------------------------------------------------------------------------- + | Sanctum Guards + |-------------------------------------------------------------------------- + | + | This array contains the authentication guards that will be checked when + | Sanctum is trying to authenticate a request. If none of these guards + | are able to authenticate the request, Sanctum will use the bearer + | token that's present on an incoming request for authentication. + | + */ + + 'guard' => ['web'], + + /* + |-------------------------------------------------------------------------- + | Expiration Minutes + |-------------------------------------------------------------------------- + | + | This value controls the number of minutes until an issued token will be + | considered expired. This will override any values set in the token's + | "expires_at" attribute, but first-party sessions are not affected. + | + */ + + 'expiration' => null, + + /* + |-------------------------------------------------------------------------- + | Token Prefix + |-------------------------------------------------------------------------- + | + | Sanctum can prefix new tokens in order to take advantage of numerous + | security scanning initiatives maintained by open source platforms + | that notify developers if they commit tokens into repositories. + | + | See: https://docs.github.com/en/code-security/secret-scanning/about-secret-scanning + | + */ + + 'token_prefix' => env('SANCTUM_TOKEN_PREFIX', ''), + + /* + |-------------------------------------------------------------------------- + | Sanctum Middleware + |-------------------------------------------------------------------------- + | + | When authenticating your first-party SPA with Sanctum you may need to + | customize some of the middleware Sanctum uses while processing the + | request. You may change the middleware listed below as required. + | + */ + + 'middleware' => [ + 'authenticate_session' => Laravel\Sanctum\Http\Middleware\AuthenticateSession::class, + 'encrypt_cookies' => Illuminate\Cookie\Middleware\EncryptCookies::class, + 'validate_csrf_token' => Illuminate\Foundation\Http\Middleware\ValidateCsrfToken::class, + ], + +]; diff --git a/backend/database/migrations/2025_09_25_234247_create_personal_access_tokens_table.php b/backend/database/migrations/2025_09_25_234247_create_personal_access_tokens_table.php new file mode 100644 index 0000000..40ff706 --- /dev/null +++ b/backend/database/migrations/2025_09_25_234247_create_personal_access_tokens_table.php @@ -0,0 +1,33 @@ +id(); + $table->morphs('tokenable'); + $table->text('name'); + $table->string('token', 64)->unique(); + $table->text('abilities')->nullable(); + $table->timestamp('last_used_at')->nullable(); + $table->timestamp('expires_at')->nullable()->index(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('personal_access_tokens'); + } +}; diff --git a/backend/routes/api.php b/backend/routes/api.php new file mode 100644 index 0000000..946570d --- /dev/null +++ b/backend/routes/api.php @@ -0,0 +1,23 @@ +where('any', '.*'); + +// Public routes +Route::post('/register', [AuthController::class, 'register']); +Route::post('/login', [AuthController::class, 'login']); + +// Protected routes +Route::middleware('auth:sanctum')->group(function () { + Route::get('/user', function (Request $request) { + return $request->user(); + }); + Route::get('/profile', [AuthController::class, 'profile']); + Route::post('/logout', [AuthController::class, 'logout']); +}); diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index fb6c208..7e4f380 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -9,10 +9,11 @@ services: ports: - "5173:5173" volumes: - - ./frontend:/app:z - - node_modules:/app/node_modules:Z + - ./frontend:/app:Z + - node_modules:/app/node_modules environment: - NODE_ENV=development + privileged: true networks: - trip-planner-network @@ -24,13 +25,14 @@ services: ports: - "8000:8000" volumes: - - ./backend:/var/www/html:z - - vendor:/var/www/html/vendor:Z + - ./backend:/var/www/html:Z + - vendor:/var/www/html/vendor env_file: - .env.local depends_on: - database - redis + privileged: true networks: - trip-planner-network @@ -45,7 +47,8 @@ services: MYSQL_USER: ${DB_USERNAME:-trip_user} MYSQL_PASSWORD: ${DB_PASSWORD:-secret} volumes: - - db-data:/var/lib/mysql:Z + - ./docker/data/mysql-data:/var/lib/mysql:Z + privileged: true networks: - trip-planner-network @@ -55,7 +58,8 @@ services: ports: - "6379:6379" volumes: - - redis-data:/data:Z + - redis-data:/data + privileged: true networks: - trip-planner-network @@ -76,4 +80,4 @@ volumes: db-data: redis-data: node_modules: - vendor: \ No newline at end of file + vendor: diff --git a/docker/backend/Dockerfile.dev b/docker/backend/Dockerfile.dev index ce59d40..4891c28 100644 --- a/docker/backend/Dockerfile.dev +++ b/docker/backend/Dockerfile.dev @@ -21,20 +21,11 @@ COPY --from=composer:latest /usr/bin/composer /usr/bin/composer WORKDIR /var/www/html -# Create developer user with UID 1000 (same as host user) -RUN adduser -u 1000 -s /bin/sh -D developer +# Create storage and bootstrap/cache directories with proper permissions +RUN mkdir -p storage/app/public storage/framework/cache storage/framework/sessions storage/framework/views storage/logs bootstrap/cache && \ + chmod -R 777 storage bootstrap/cache -# Create storage and bootstrap/cache directories -RUN mkdir -p storage/app/public storage/framework/cache storage/framework/sessions storage/framework/views storage/logs bootstrap/cache - -# Change ownership to developer user -RUN chown -R developer:developer /var/www/html - -# Set proper permissions for Laravel directories -RUN chmod -R 775 storage bootstrap/cache - -# Switch to developer user -USER developer +# Run as root to avoid permission issues with volume mounts # Expose port 8000 for artisan serve EXPOSE 8000 diff --git a/docker/frontend/Dockerfile.dev b/docker/frontend/Dockerfile.dev index 4f242bc..c3a9e47 100644 --- a/docker/frontend/Dockerfile.dev +++ b/docker/frontend/Dockerfile.dev @@ -5,11 +5,7 @@ RUN npm install -g vite WORKDIR /app -# Change ownership of /app to node user (UID 1000) -RUN chown -R node:node /app - -# Switch to node user (UID 1000, same as host user) -USER node +# Run as root to avoid permission issues with volume mounts # Expose Vite dev server port EXPOSE 5173 diff --git a/frontend/.env b/frontend/.env new file mode 100644 index 0000000..9c4b96e --- /dev/null +++ b/frontend/.env @@ -0,0 +1,2 @@ +VITE_API_URL=http://localhost:8000 +VITE_API_BASE_URL=http://localhost:8000/api \ No newline at end of file diff --git a/frontend/package-lock.json b/frontend/package-lock.json index d1365ea..6d9cf70 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,6 +8,7 @@ "name": "frontend", "version": "0.0.0", "dependencies": { + "axios": "^1.12.2", "react": "^19.1.1", "react-dom": "^19.1.1" }, @@ -1481,6 +1482,23 @@ "dev": true, "license": "Python-2.0" }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz", + "integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.4", + "proxy-from-env": "^1.1.0" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -1543,6 +1561,19 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -1611,6 +1642,18 @@ "dev": true, "license": "MIT" }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -1672,6 +1715,29 @@ "dev": true, "license": "MIT" }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.224", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.224.tgz", @@ -1679,6 +1745,51 @@ "dev": true, "license": "ISC" }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/esbuild": { "version": "0.25.10", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.10.tgz", @@ -2012,6 +2123,42 @@ "dev": true, "license": "ISC" }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", + "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -2027,6 +2174,15 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -2037,6 +2193,43 @@ "node": ">=6.9.0" } }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -2063,6 +2256,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -2073,6 +2278,45 @@ "node": ">=8" } }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -2264,6 +2508,36 @@ "yallist": "^3.0.2" } }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -2459,6 +2733,12 @@ "node": ">= 0.8.0" } }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 82deee1..e82bc18 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,6 +10,7 @@ "preview": "vite preview" }, "dependencies": { + "axios": "^1.12.2", "react": "^19.1.1", "react-dom": "^19.1.1" }, diff --git a/frontend/src/App.css b/frontend/src/App.css index b9d355d..1532ab9 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -1,42 +1,285 @@ -#root { - max-width: 1280px; +* { + box-sizing: border-box; +} + +body { + margin: 0; + padding: 0; +} + +.App { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + padding: 2rem; + width: 100vw; + box-sizing: border-box; +} + +.App-header { + text-align: center; + margin-bottom: 2rem; +} + +.registration-form, +.login-form { + background: #f8f9fa; + padding: 2rem; + border-radius: 8px; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); + width: 100%; + max-width: 400px; + margin: 0 auto; +} + +.registration-form h2, +.login-form h2 { + text-align: center; + margin-bottom: 1.5rem; + color: #333; +} + +.form-group { + margin-bottom: 1rem; +} + +.form-group label { + display: block; + margin-bottom: 0.5rem; + font-weight: 500; + color: #555; +} + +.form-group input { + width: 100%; + padding: 0.75rem; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 1rem; + box-sizing: border-box; +} + +.form-group input:focus { + outline: none; + border-color: #007bff; + box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25); +} + +.form-group input.error { + border-color: #dc3545; +} + +.error-message { + display: block; + color: #dc3545; + font-size: 0.875rem; + margin-top: 0.25rem; +} + +.alert { + padding: 0.75rem 1rem; + margin-bottom: 1rem; + border-radius: 4px; +} + +.alert-success { + color: #155724; + background-color: #d4edda; + border: 1px solid #c3e6cb; +} + +.alert-error { + color: #721c24; + background-color: #f8d7da; + border: 1px solid #f5c6cb; +} + +button[type="submit"] { + width: 100%; + padding: 0.75rem; + background-color: #007bff; + color: white; + border: none; + border-radius: 4px; + font-size: 1rem; + cursor: pointer; + transition: background-color 0.2s; +} + +button[type="submit"]:hover:not(:disabled) { + background-color: #0056b3; +} + +button[type="submit"]:disabled { + background-color: #6c757d; + cursor: not-allowed; +} + +/* Auth Guard Styles */ +.auth-container { + width: 100%; + display: flex; + justify-content: center; + align-items: center; + min-height: 100vh; +} + +.auth-content { + width: 100%; + max-width: 400px; + display: flex; + flex-direction: column; + align-items: center; +} + +.auth-toggle { + display: flex; + margin-bottom: 2rem; + border-radius: 8px; + overflow: hidden; + border: 1px solid #ddd; +} + +.auth-toggle button { + flex: 1; + padding: 0.75rem; + background: #f8f9fa; + border: none; + cursor: pointer; + transition: all 0.2s; +} + +.auth-toggle button.active { + background: #007bff; + color: white; +} + +.auth-toggle button:hover:not(.active) { + background: #e9ecef; +} + +.auth-switch { + text-align: center; + margin-top: 1rem; +} + +.link-button { + background: none; + border: none; + color: #007bff; + cursor: pointer; + text-decoration: underline; + padding: 0; + font-size: inherit; +} + +.link-button:hover { + color: #0056b3; +} + +/* Dashboard Styles */ +.dashboard { + max-width: 1200px; margin: 0 auto; padding: 2rem; +} + +.dashboard-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 2rem; + padding-bottom: 1rem; + border-bottom: 1px solid #eee; +} + +.dashboard-header h1 { + margin: 0; + color: #333; +} + +.user-info { + display: flex; + align-items: center; + gap: 1rem; +} + +.logout-btn { + padding: 0.5rem 1rem; + background: #dc3545; + color: white; + border: none; + border-radius: 4px; + cursor: pointer; + transition: background-color 0.2s; +} + +.logout-btn:hover { + background: #c82333; +} + +.welcome-section { + text-align: center; + margin-bottom: 3rem; +} + +.welcome-section h2 { + margin-bottom: 0.5rem; + color: #333; +} + +.features-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + gap: 2rem; + margin-top: 2rem; +} + +.feature-card { + background: #f8f9fa; + padding: 2rem; + border-radius: 8px; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); text-align: center; } -.logo { - height: 6em; - padding: 1.5em; - will-change: filter; - transition: filter 300ms; -} -.logo:hover { - filter: drop-shadow(0 0 2em #646cffaa); -} -.logo.react:hover { - filter: drop-shadow(0 0 2em #61dafbaa); +.feature-card h3 { + margin-bottom: 1rem; + color: #007bff; } -@keyframes logo-spin { - from { - transform: rotate(0deg); - } - to { - transform: rotate(360deg); - } +.feature-card p { + color: #666; + line-height: 1.6; } -@media (prefers-reduced-motion: no-preference) { - a:nth-of-type(2) .logo { - animation: logo-spin infinite 20s linear; - } +/* Loading Styles */ +.loading-container { + display: flex; + justify-content: center; + align-items: center; + min-height: 200px; } -.card { - padding: 2em; +.loading-spinner { + font-size: 1.2rem; + color: #666; } -.read-the-docs { - color: #888; +.unauthorized-container { + text-align: center; + padding: 3rem; + background: #f8f9fa; + border-radius: 8px; + margin: 2rem 0; +} + +.unauthorized-container h2 { + color: #dc3545; + margin-bottom: 1rem; +} + +.unauthorized-container p { + color: #666; } diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index f67355a..3e17bf1 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,34 +1,17 @@ -import { useState } from 'react' -import reactLogo from './assets/react.svg' -import viteLogo from '/vite.svg' +import { AuthProvider } from './contexts/AuthContext' +import AuthGuard from './components/auth/AuthGuard' +import Dashboard from './components/Dashboard' import './App.css' function App() { - const [count, setCount] = useState(0) - return ( - <> -
- - Vite logo - - - React logo - + +
+ + +
-

Vite + React

-
- -

- Edit src/App.jsx and save to test HMR -

-
-

- Click on the Vite and React logos to learn more -

- +
) } diff --git a/frontend/src/components/Dashboard.jsx b/frontend/src/components/Dashboard.jsx new file mode 100644 index 0000000..415914a --- /dev/null +++ b/frontend/src/components/Dashboard.jsx @@ -0,0 +1,49 @@ +import { useAuth } from '../contexts/AuthContext'; + +const Dashboard = () => { + const { user, logout } = useAuth(); + + const handleLogout = async () => { + await logout(); + }; + + return ( +
+
+

Trip Planner Dashboard

+
+ Welcome, {user?.name}! + +
+
+ +
+
+

Welcome to Your Trip Planner

+

Start planning your next adventure!

+
+ +
+
+

Plan Trips

+

Create and organize your travel itineraries

+
+ +
+

Save Destinations

+

Keep track of places you want to visit

+
+ +
+

Share Plans

+

Collaborate with friends and family

+
+
+
+
+ ); +}; + +export default Dashboard; \ No newline at end of file diff --git a/frontend/src/components/LoginForm.jsx b/frontend/src/components/LoginForm.jsx new file mode 100644 index 0000000..0b55853 --- /dev/null +++ b/frontend/src/components/LoginForm.jsx @@ -0,0 +1,120 @@ +import { useState } from 'react'; +import api from '../utils/api'; + +const LoginForm = ({ onLoginSuccess }) => { + const [formData, setFormData] = useState({ + email: '', + password: '' + }); + + const [errors, setErrors] = useState({}); + const [isLoading, setIsLoading] = useState(false); + + const handleChange = (e) => { + const { name, value } = e.target; + setFormData(prevState => ({ + ...prevState, + [name]: value + })); + + if (errors[name]) { + setErrors(prevErrors => ({ + ...prevErrors, + [name]: '' + })); + } + }; + + const handleSubmit = async (e) => { + e.preventDefault(); + setIsLoading(true); + setErrors({}); + + try { + const response = await api.post('/login', formData); + + if (response.data.success) { + localStorage.setItem('token', response.data.data.access_token); + localStorage.setItem('user', JSON.stringify(response.data.data.user)); + + if (onLoginSuccess) { + onLoginSuccess(response.data.data); + } + } + } catch (error) { + if (error.response && error.response.status === 422) { + // Validation errors - check both .data and .errors structure + const validationErrors = error.response.data.errors || error.response.data.data || {}; + + // If it's credential error, show as general message + if (validationErrors.email && validationErrors.email[0] === 'The provided credentials are incorrect.') { + setErrors({ general: 'Invalid email or password. Please try again.' }); + } else { + setErrors(validationErrors); + } + } else if (error.response && error.response.status === 401) { + // Unauthorized - wrong credentials + setErrors({ general: 'Invalid email or password. Please try again.' }); + } else if (error.response && error.response.data.message) { + // Other server errors + setErrors({ general: error.response.data.message }); + } else if (error.request) { + // Network error + setErrors({ general: 'Unable to connect to server. Please check your connection.' }); + } else { + // Unknown error + setErrors({ general: 'Unknown error occurred. Please try again.' }); + } + } finally { + setIsLoading(false); + } + }; + + return ( +
+

Login

+ + {errors.general && ( +
+ {errors.general} +
+ )} + +
+
+ + + {errors.email && {errors.email[0]}} +
+ +
+ + + {errors.password && {errors.password[0]}} +
+ + +
+
+ ); +}; + +export default LoginForm; \ No newline at end of file diff --git a/frontend/src/components/RegistrationForm.jsx b/frontend/src/components/RegistrationForm.jsx new file mode 100644 index 0000000..35ba987 --- /dev/null +++ b/frontend/src/components/RegistrationForm.jsx @@ -0,0 +1,148 @@ +import { useState } from 'react'; +import api from '../utils/api'; + +const RegistrationForm = ({ onRegistrationSuccess }) => { + const [formData, setFormData] = useState({ + name: '', + email: '', + password: '', + password_confirmation: '' + }); + + const [errors, setErrors] = useState({}); + const [isLoading, setIsLoading] = useState(false); + const [successMessage, setSuccessMessage] = useState(''); + + const handleChange = (e) => { + const { name, value } = e.target; + setFormData(prevState => ({ + ...prevState, + [name]: value + })); + + // Clear specific field error when user starts typing + if (errors[name]) { + setErrors(prevErrors => ({ + ...prevErrors, + [name]: '' + })); + } + }; + + const handleSubmit = async (e) => { + e.preventDefault(); + setIsLoading(true); + setErrors({}); + setSuccessMessage(''); + + try { + const response = await api.post('/register', formData); + + if (response.data.success) { + setSuccessMessage('Registration successful! You are now logged in.'); + setFormData({ + name: '', + email: '', + password: '', + password_confirmation: '' + }); + + if (onRegistrationSuccess) { + onRegistrationSuccess(response.data.data); + } + } + } catch (error) { + if (error.response && error.response.status === 422) { + setErrors(error.response.data.data || {}); + } else { + setErrors({ general: 'Registration failed. Please try again.' }); + } + } finally { + setIsLoading(false); + } + }; + + return ( +
+

Register

+ + {successMessage && ( +
+ {successMessage} +
+ )} + + {errors.general && ( +
+ {errors.general} +
+ )} + +
+
+ + + {errors.name && {errors.name[0]}} +
+ +
+ + + {errors.email && {errors.email[0]}} +
+ +
+ + + {errors.password && {errors.password[0]}} +
+ +
+ + + {errors.password_confirmation && {errors.password_confirmation[0]}} +
+ + +
+
+ ); +}; + +export default RegistrationForm; \ No newline at end of file diff --git a/frontend/src/components/auth/AuthGuard.jsx b/frontend/src/components/auth/AuthGuard.jsx new file mode 100644 index 0000000..a03ab65 --- /dev/null +++ b/frontend/src/components/auth/AuthGuard.jsx @@ -0,0 +1,74 @@ +import { useAuth } from '../../contexts/AuthContext'; +import LoginForm from '../LoginForm'; +import RegistrationForm from '../RegistrationForm'; +import { useState } from 'react'; + +const AuthGuard = ({ children }) => { + const { isAuthenticated, isLoading, login, register } = useAuth(); + const [showLogin, setShowLogin] = useState(true); + + if (isLoading) { + return ( +
+
Loading...
+
+ ); + } + + if (!isAuthenticated) { + return ( +
+
+
+ + +
+ + {showLogin ? ( + + ) : ( + + )} + +
+ {showLogin ? ( +

+ Don't have an account?{' '} + +

+ ) : ( +

+ Already have an account?{' '} + +

+ )} +
+
+
+ ); + } + + return children; +}; + +export default AuthGuard; \ No newline at end of file diff --git a/frontend/src/components/auth/ProtectedRoute.jsx b/frontend/src/components/auth/ProtectedRoute.jsx new file mode 100644 index 0000000..75132f0 --- /dev/null +++ b/frontend/src/components/auth/ProtectedRoute.jsx @@ -0,0 +1,26 @@ +import { useAuth } from '../../contexts/AuthContext'; + +const ProtectedRoute = ({ children }) => { + const { isAuthenticated, isLoading } = useAuth(); + + if (isLoading) { + return ( +
+
Loading...
+
+ ); + } + + if (!isAuthenticated) { + return ( +
+

Access Denied

+

Please log in to access this page.

+
+ ); + } + + return children; +}; + +export default ProtectedRoute; \ No newline at end of file diff --git a/frontend/src/contexts/AuthContext.jsx b/frontend/src/contexts/AuthContext.jsx new file mode 100644 index 0000000..8952674 --- /dev/null +++ b/frontend/src/contexts/AuthContext.jsx @@ -0,0 +1,87 @@ +import { createContext, useContext, useState, useEffect } from 'react'; +import api from '../utils/api'; + +const AuthContext = createContext(); + +export const useAuth = () => { + const context = useContext(AuthContext); + if (!context) { + throw new Error('useAuth must be used within an AuthProvider'); + } + return context; +}; + +export const AuthProvider = ({ children }) => { + const [user, setUser] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [isAuthenticated, setIsAuthenticated] = useState(false); + + useEffect(() => { + const initializeAuth = () => { + const token = localStorage.getItem('token'); + const userData = localStorage.getItem('user'); + + if (token && userData) { + try { + const parsedUser = JSON.parse(userData); + setUser(parsedUser); + setIsAuthenticated(true); + + // Token will be automatically added by API interceptor + } catch (error) { + console.error('Error parsing user data:', error); + logout(); + } + } + setIsLoading(false); + }; + + initializeAuth(); + }, []); + + const login = (userData) => { + const { user: userInfo, access_token } = userData; + + setUser(userInfo); + setIsAuthenticated(true); + + localStorage.setItem('token', access_token); + localStorage.setItem('user', JSON.stringify(userInfo)); + }; + + const logout = async () => { + try { + const token = localStorage.getItem('token'); + if (token) { + await api.post('/logout'); + } + } catch (error) { + console.error('Logout error:', error); + } finally { + setUser(null); + setIsAuthenticated(false); + + localStorage.removeItem('token'); + localStorage.removeItem('user'); + } + }; + + const register = (userData) => { + login(userData); + }; + + const value = { + user, + isLoading, + isAuthenticated, + login, + logout, + register + }; + + return ( + + {children} + + ); +}; \ No newline at end of file diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js new file mode 100644 index 0000000..9a6148f --- /dev/null +++ b/frontend/src/utils/api.js @@ -0,0 +1,40 @@ +import axios from 'axios'; + +const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000/api'; + +const api = axios.create({ + baseURL: API_BASE_URL, + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json' + } +}); + +// Request interceptor to add auth token +api.interceptors.request.use( + (config) => { + const token = localStorage.getItem('token'); + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + return config; + }, + (error) => { + return Promise.reject(error); + } +); + +// Response interceptor to handle token expiration +api.interceptors.response.use( + (response) => response, + (error) => { + if (error.response?.status === 401) { + localStorage.removeItem('token'); + localStorage.removeItem('user'); + window.location.href = '/'; + } + return Promise.reject(error); + } +); + +export default api; \ No newline at end of file -- 2.45.2 From f1b3c1c8acdb818626122639394ef272c5ff2d94 Mon Sep 17 00:00:00 2001 From: myrmidex Date: Sat, 27 Sep 2025 00:23:19 +0200 Subject: [PATCH 03/14] Working selenium on local --- docker-compose.dev.yml | 30 +++++ frontend/vite.config.js | 4 + tests/.gitignore | 2 + tests/README.md | 153 +++++++++++++++++++++ tests/config/jest.setup.js | 63 +++++++++ tests/config/jest.setup.local.js | 63 +++++++++ tests/config/test-utils.js | 82 +++++++++++ tests/e2e/auth.test.js | 225 +++++++++++++++++++++++++++++++ tests/e2e/debug.test.js | 34 +++++ tests/e2e/screenshot.test.js | 61 +++++++++ tests/e2e/simple-auth.test.js | 77 +++++++++++ tests/e2e/visual-auth.test.js | 136 +++++++++++++++++++ tests/fixtures/users.json | 58 ++++++++ tests/jest-local.json | 6 + tests/package.json | 38 ++++++ tests/pages/BasePage.js | 68 ++++++++++ tests/pages/DashboardPage.js | 64 +++++++++ tests/pages/LoginPage.js | 140 +++++++++++++++++++ tests/pages/RegistrationPage.js | 175 ++++++++++++++++++++++++ 19 files changed, 1479 insertions(+) create mode 100644 tests/.gitignore create mode 100644 tests/README.md create mode 100644 tests/config/jest.setup.js create mode 100644 tests/config/jest.setup.local.js create mode 100644 tests/config/test-utils.js create mode 100644 tests/e2e/auth.test.js create mode 100644 tests/e2e/debug.test.js create mode 100644 tests/e2e/screenshot.test.js create mode 100644 tests/e2e/simple-auth.test.js create mode 100644 tests/e2e/visual-auth.test.js create mode 100644 tests/fixtures/users.json create mode 100644 tests/jest-local.json create mode 100644 tests/package.json create mode 100644 tests/pages/BasePage.js create mode 100644 tests/pages/DashboardPage.js create mode 100644 tests/pages/LoginPage.js create mode 100644 tests/pages/RegistrationPage.js diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 7e4f380..a4f4c06 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -72,6 +72,36 @@ services: networks: - trip-planner-network + selenium-hub: + image: selenium/hub:latest + container_name: trip-planner-selenium-hub + ports: + - "4442:4442" + - "4443:4443" + - "4444:4444" + environment: + - GRID_MAX_SESSION=4 + - GRID_BROWSER_TIMEOUT=300 + - GRID_TIMEOUT=300 + networks: + - trip-planner-network + + selenium-chrome: + image: selenium/node-chrome:latest + container_name: trip-planner-selenium-chrome + environment: + - HUB_HOST=selenium-hub + - HUB_PORT=4444 + - SE_EVENT_BUS_HOST=selenium-hub + - SE_EVENT_BUS_PUBLISH_PORT=4442 + - SE_EVENT_BUS_SUBSCRIBE_PORT=4443 + - NODE_MAX_INSTANCES=4 + - NODE_MAX_SESSION=4 + depends_on: + - selenium-hub + networks: + - trip-planner-network + networks: trip-planner-network: driver: bridge diff --git a/frontend/vite.config.js b/frontend/vite.config.js index 8b0f57b..362566a 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -4,4 +4,8 @@ import react from '@vitejs/plugin-react' // https://vite.dev/config/ export default defineConfig({ plugins: [react()], + server: { + host: '0.0.0.0', // Allow external connections + port: 5173 + } }) diff --git a/tests/.gitignore b/tests/.gitignore new file mode 100644 index 0000000..d502512 --- /dev/null +++ b/tests/.gitignore @@ -0,0 +1,2 @@ +/node_modules +/package-lock.json diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..bbaa1d6 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,153 @@ +# E2E Testing Suite for Trip Planner + +This directory contains end-to-end tests using Selenium WebDriver for the Trip Planner application. + +## Setup + +### Prerequisites + +1. **Docker Setup (Recommended)** + - Docker/Podman installed and running + - All services running: `podman-compose -f ../docker-compose.dev.yml up -d` + +2. **Local Setup (For visible browser testing)** + - Chrome or Chromium browser installed locally + - Frontend running on `http://localhost:5173` + - Backend running on `http://localhost:8000` + +### Installation + +```bash +cd tests +npm install +``` + +## Running Tests + +### Using Docker Selenium (Headless - No visible browser) + +```bash +# Run all tests +npm test + +# Run all tests in headless mode (explicitly) +npm run test:headless + +# Run authentication tests only +npm run test:auth + +# Run with verbose output +npm run test:debug +``` + +### Using Local Chrome (Visible browser on your machine) + +```bash +# Run all tests with visible browser +npm run test:local + +# Run debug test with visible browser +npm run test:local:debug + +# Run specific test file locally +HEADLESS=false jest --config jest-local.json e2e/visual-auth.test.js +``` + +### Debugging Tests + +```bash +# Simple debug test +npm run test:debug-simple + +# Visual authentication test (slow, with pauses) +npm run test:local e2e/visual-auth.test.js + +# Screenshot test (saves screenshots to tests/screenshots/) +npm test screenshot.test.js +``` + +## Test Structure + +``` +tests/ +├── config/ +│ ├── jest.setup.js # Docker Selenium configuration +│ ├── jest.setup.local.js # Local Chrome configuration +│ └── test-utils.js # Helper utilities +├── e2e/ +│ ├── auth.test.js # Main authentication test suite +│ ├── debug.test.js # Simple connection test +│ ├── visual-auth.test.js # Slow visual test for debugging +│ └── screenshot.test.js # Takes screenshots for debugging +├── fixtures/ +│ └── users.json # Test user data +├── pages/ +│ ├── BasePage.js # Base page object +│ ├── LoginPage.js # Login page object +│ ├── RegistrationPage.js # Registration page object +│ └── DashboardPage.js # Dashboard page object +└── screenshots/ # Screenshot output directory +``` + +## Environment Variables + +- `HEADLESS=true/false` - Run Chrome in headless mode (Docker only) +- `BASE_URL` - Override default frontend URL (default: http://localhost:5173) +- `SELENIUM_HUB` - Override Selenium hub URL (default: http://localhost:4444) + +## Common Issues + +### Tests hang with no browser window +- You're using Docker Selenium. The browser runs inside a container (invisible) +- Use `npm run test:local` to see the browser on your machine + +### Connection refused errors +- Ensure all Docker services are running: `podman-compose -f ../docker-compose.dev.yml up -d` +- Check frontend is accessible: `curl http://localhost:5173` +- Check backend is accessible: `curl http://localhost:8000` + +### Chrome not found (local testing) +- Install Chrome: https://www.google.com/chrome/ +- Or install Chromium: `sudo dnf install chromium` (Fedora) or `sudo apt install chromium-browser` (Ubuntu) + +### Tests can't find auth forms +- The app uses a single-page auth guard, not separate /login and /register routes +- Forms are toggled on the same page using buttons + +## Writing New Tests + +1. Create test file in `e2e/` directory +2. Use page objects from `pages/` for better maintainability +3. Add test data to `fixtures/` as needed +4. Follow the pattern in existing tests + +Example: +```javascript +const LoginPage = require('../pages/LoginPage'); + +describe('My New Test', () => { + let driver; + let loginPage; + + beforeAll(async () => { + driver = await global.createDriver(); + loginPage = new LoginPage(driver); + }); + + afterAll(async () => { + await global.quitDriver(driver); + }); + + test('should do something', async () => { + await loginPage.navigateToLogin(); + // ... test logic + }); +}); +``` + +## Tips + +- Use `npm run test:local` when debugging to see what's happening +- Use `npm test screenshot.test.js` to capture screenshots when tests fail +- Add `await driver.sleep(2000)` to slow down tests for debugging +- Check Docker logs: `podman logs -f trip-planner-selenium-chrome` \ No newline at end of file diff --git a/tests/config/jest.setup.js b/tests/config/jest.setup.js new file mode 100644 index 0000000..355b633 --- /dev/null +++ b/tests/config/jest.setup.js @@ -0,0 +1,63 @@ +// Jest setup file for Selenium E2E tests +const { Builder } = require('selenium-webdriver'); +const chrome = require('selenium-webdriver/chrome'); + +// Global test configuration +global.testConfig = { + baseUrl: process.env.BASE_URL || 'http://host.docker.internal:5173', + seleniumHub: process.env.SELENIUM_HUB || 'http://localhost:4444', + timeout: 30000, + isHeadless: process.env.HEADLESS === 'true' +}; + +// Global test utilities +global.createDriver = async () => { + const chromeOptions = new chrome.Options(); + + if (global.testConfig.isHeadless) { + chromeOptions.addArguments('--headless'); + } + + chromeOptions.addArguments('--no-sandbox'); + chromeOptions.addArguments('--disable-dev-shm-usage'); + chromeOptions.addArguments('--disable-gpu'); + chromeOptions.addArguments('--window-size=1920,1080'); + + const driver = await new Builder() + .forBrowser('chrome') + .setChromeOptions(chromeOptions) + .usingServer(global.testConfig.seleniumHub) + .build(); + + // Set implicit wait + await driver.manage().setTimeouts({ implicit: 10000 }); + + return driver; +}; + +// Global cleanup function +global.quitDriver = async (driver) => { + if (driver) { + await driver.quit(); + } +}; + +// Extend Jest matchers for better assertions +expect.extend({ + async toBeDisplayed(element) { + const isDisplayed = await element.isDisplayed(); + return { + message: () => `expected element to ${this.isNot ? 'not ' : ''}be displayed`, + pass: isDisplayed + }; + }, + + async toHaveText(element, expectedText) { + const actualText = await element.getText(); + const pass = actualText.includes(expectedText); + return { + message: () => `expected element to contain text "${expectedText}", but got "${actualText}"`, + pass + }; + } +}); \ No newline at end of file diff --git a/tests/config/jest.setup.local.js b/tests/config/jest.setup.local.js new file mode 100644 index 0000000..1c1ef8b --- /dev/null +++ b/tests/config/jest.setup.local.js @@ -0,0 +1,63 @@ +// Jest setup file for LOCAL Selenium tests (browser runs on host) +const { Builder } = require('selenium-webdriver'); +const chrome = require('selenium-webdriver/chrome'); + +// Global test configuration for local testing +global.testConfig = { + baseUrl: process.env.BASE_URL || 'http://localhost:5173', + seleniumHub: null, // Direct connection, no hub + timeout: 30000, + isHeadless: process.env.HEADLESS === 'true' +}; + +// Create driver directly without Selenium Grid +global.createDriver = async () => { + const chromeOptions = new chrome.Options(); + + if (global.testConfig.isHeadless) { + chromeOptions.addArguments('--headless'); + } + + chromeOptions.addArguments('--no-sandbox'); + chromeOptions.addArguments('--disable-dev-shm-usage'); + chromeOptions.addArguments('--disable-gpu'); + chromeOptions.addArguments('--window-size=1920,1080'); + + // Direct connection to Chrome on host + const driver = await new Builder() + .forBrowser('chrome') + .setChromeOptions(chromeOptions) + .build(); + + // Set implicit wait + await driver.manage().setTimeouts({ implicit: 10000 }); + + return driver; +}; + +// Global cleanup function +global.quitDriver = async (driver) => { + if (driver) { + await driver.quit(); + } +}; + +// Extend Jest matchers for better assertions +expect.extend({ + async toBeDisplayed(element) { + const isDisplayed = await element.isDisplayed(); + return { + message: () => `expected element to ${this.isNot ? 'not ' : ''}be displayed`, + pass: isDisplayed + }; + }, + + async toHaveText(element, expectedText) { + const actualText = await element.getText(); + const pass = actualText.includes(expectedText); + return { + message: () => `expected element to contain text "${expectedText}", but got "${actualText}"`, + pass + }; + } +}); \ No newline at end of file diff --git a/tests/config/test-utils.js b/tests/config/test-utils.js new file mode 100644 index 0000000..ef05d5b --- /dev/null +++ b/tests/config/test-utils.js @@ -0,0 +1,82 @@ +const { By, until } = require('selenium-webdriver'); + +class TestUtils { + constructor(driver) { + this.driver = driver; + } + + async waitForElement(selector, timeout = 10000) { + const element = await this.driver.wait( + until.elementLocated(By.css(selector)), + timeout, + `Element with selector '${selector}' not found within ${timeout}ms` + ); + return element; + } + + async waitForElementVisible(selector, timeout = 10000) { + const element = await this.waitForElement(selector, timeout); + await this.driver.wait( + until.elementIsVisible(element), + timeout, + `Element with selector '${selector}' not visible within ${timeout}ms` + ); + return element; + } + + async waitForElementClickable(selector, timeout = 10000) { + const element = await this.waitForElementVisible(selector, timeout); + await this.driver.wait( + until.elementIsEnabled(element), + timeout, + `Element with selector '${selector}' not clickable within ${timeout}ms` + ); + return element; + } + + async waitForText(selector, text, timeout = 10000) { + await this.driver.wait( + until.elementTextContains( + this.driver.findElement(By.css(selector)), + text + ), + timeout, + `Text '${text}' not found in element '${selector}' within ${timeout}ms` + ); + } + + async clearAndType(element, text) { + await element.clear(); + await element.sendKeys(text); + } + + async scrollToElement(element) { + await this.driver.executeScript('arguments[0].scrollIntoView(true);', element); + // Small delay to ensure scroll completes + await this.driver.sleep(500); + } + + async takeScreenshot(filename) { + const screenshot = await this.driver.takeScreenshot(); + require('fs').writeFileSync(`screenshots/${filename}`, screenshot, 'base64'); + } + + async getCurrentUrl() { + return await this.driver.getCurrentUrl(); + } + + async navigateTo(path) { + const url = `${global.testConfig.baseUrl}${path}`; + await this.driver.get(url); + } + + async refreshPage() { + await this.driver.navigate().refresh(); + } + + async goBack() { + await this.driver.navigate().back(); + } +} + +module.exports = TestUtils; \ No newline at end of file diff --git a/tests/e2e/auth.test.js b/tests/e2e/auth.test.js new file mode 100644 index 0000000..0c92155 --- /dev/null +++ b/tests/e2e/auth.test.js @@ -0,0 +1,225 @@ +const RegistrationPage = require('../pages/RegistrationPage'); +const LoginPage = require('../pages/LoginPage'); +const DashboardPage = require('../pages/DashboardPage'); +const users = require('../fixtures/users.json'); + +describe('Authentication E2E Tests', () => { + let driver; + let registrationPage; + let loginPage; + let dashboardPage; + + beforeAll(async () => { + driver = await global.createDriver(); + registrationPage = new RegistrationPage(driver); + loginPage = new LoginPage(driver); + dashboardPage = new DashboardPage(driver); + }); + + afterAll(async () => { + await global.quitDriver(driver); + }); + + beforeEach(async () => { + // Navigate to home page before each test to ensure clean state + await driver.get(global.testConfig.baseUrl); + }); + + describe('User Registration', () => { + test('should successfully register a new user', async () => { + const testUser = users.registrationTestUser; + + await registrationPage.navigateToRegistration(); + + expect(await registrationPage.isRegistrationFormDisplayed()).toBe(true); + expect(await registrationPage.getRegistrationHeading()).toBe('Register'); + + await registrationPage.register( + testUser.name, + testUser.email, + testUser.password + ); + + await registrationPage.waitForSuccessfulRegistration(); + + const successMessage = await registrationPage.getSuccessMessage(); + expect(successMessage).toContain('Registration successful'); + expect(await registrationPage.isFormCleared()).toBe(true); + }); + + test('should show validation errors for invalid registration data', async () => { + const invalidUser = users.invalidUsers.shortPassword; + + await registrationPage.navigateToRegistration(); + await registrationPage.register( + invalidUser.name, + invalidUser.email, + invalidUser.password + ); + + await registrationPage.waitForRegistrationError(); + + // Check that form shows validation errors + const hasErrors = await registrationPage.hasPasswordFieldError() || + await registrationPage.getPasswordErrorMessage() !== null || + await registrationPage.getGeneralErrorMessage() !== null; + + expect(hasErrors).toBe(true); + }); + + test('should show error for password mismatch', async () => { + const mismatchUser = users.invalidUsers.passwordMismatch; + + await registrationPage.navigateToRegistration(); + await registrationPage.register( + mismatchUser.name, + mismatchUser.email, + mismatchUser.password, + mismatchUser.passwordConfirmation + ); + + await registrationPage.waitForRegistrationError(); + + const hasPasswordConfirmationError = await registrationPage.hasPasswordConfirmationFieldError() || + await registrationPage.getPasswordConfirmationErrorMessage() !== null; + + expect(hasPasswordConfirmationError).toBe(true); + }); + + test('should show error for empty required fields', async () => { + await registrationPage.navigateToRegistration(); + await registrationPage.clickSubmit(); + + // The form should prevent submission with empty required fields + // or show validation errors + const submitButtonText = await registrationPage.getSubmitButtonText(); + expect(submitButtonText).toBe('Register'); // Button text shouldn't change to "Registering..." + }); + + test('should show error for invalid email format', async () => { + const invalidEmailUser = users.invalidUsers.invalidEmail; + + await registrationPage.navigateToRegistration(); + await registrationPage.register( + invalidEmailUser.name, + invalidEmailUser.email, + invalidEmailUser.password + ); + + // The HTML5 email validation should prevent form submission + // or backend should return validation error + const currentUrl = await registrationPage.getCurrentUrl(); + expect(currentUrl).toContain('/register'); + }); + }); + + describe('User Login', () => { + test('should successfully login with valid credentials', async () => { + const validCredentials = users.loginTestCases.validCredentials; + + await loginPage.navigateToLogin(); + + expect(await loginPage.isLoginFormDisplayed()).toBe(true); + expect(await loginPage.getLoginHeading()).toBe('Login'); + + await loginPage.login( + validCredentials.email, + validCredentials.password + ); + + await loginPage.waitForSuccessfulLogin(); + + // Should see dashboard after successful login + const dashboardVisible = await dashboardPage.isDashboardDisplayed(); + expect(dashboardVisible).toBe(true); + }); + + test('should show error for invalid credentials', async () => { + const invalidCredentials = users.loginTestCases.invalidCredentials; + + await loginPage.navigateToLogin(); + await loginPage.login( + invalidCredentials.email, + invalidCredentials.password + ); + + await loginPage.waitForLoginError(); + + const generalError = await loginPage.getGeneralErrorMessage(); + expect(generalError).toContain('Invalid email or password'); + }); + + test('should show validation errors for empty fields', async () => { + await loginPage.navigateToLogin(); + await loginPage.clickSubmit(); + + // The form should prevent submission with empty required fields + const submitButtonText = await loginPage.getSubmitButtonText(); + expect(submitButtonText).toBe('Login'); // Button text shouldn't change to "Logging in..." + }); + + test('should show error for invalid email format', async () => { + const invalidEmailCredentials = users.loginTestCases.invalidEmailFormat; + + await loginPage.navigateToLogin(); + await loginPage.login( + invalidEmailCredentials.email, + invalidEmailCredentials.password + ); + + // The HTML5 email validation should prevent form submission + // or backend should return validation error + const currentUrl = await loginPage.getCurrentUrl(); + expect(currentUrl).toContain('/login'); + }); + + test('should disable submit button while login is in progress', async () => { + const validCredentials = users.loginTestCases.validCredentials; + + await loginPage.navigateToLogin(); + await loginPage.enterEmail(validCredentials.email); + await loginPage.enterPassword(validCredentials.password); + + // Click submit and immediately check if button is disabled + await loginPage.clickSubmit(); + + // Check if button text changes to "Logging in..." (indicating loading state) + const submitButtonText = await loginPage.getSubmitButtonText(); + const isButtonDisabled = await loginPage.isSubmitButtonDisabled(); + + expect(submitButtonText === 'Logging in...' || isButtonDisabled).toBe(true); + }); + }); + + describe('Authentication Flow Integration', () => { + test('should allow registration followed by immediate login', async () => { + // Use a unique email for this test to avoid conflicts + const timestamp = Date.now(); + const testUser = { + name: 'Integration Test User', + email: `integration.test.${timestamp}@example.com`, + password: 'password123' + }; + + // Register new user + await registrationPage.navigateToRegistration(); + await registrationPage.register( + testUser.name, + testUser.email, + testUser.password + ); + + await registrationPage.waitForSuccessfulRegistration(); + + // Navigate to login and use the same credentials + await loginPage.navigateToLogin(); + await loginPage.login(testUser.email, testUser.password); + + await loginPage.waitForSuccessfulLogin(); + + // Should be successfully logged in + const currentUrl = await loginPage.getCurrentUrl(); + expect(currentUrl).not.toContain('/login'); + }); + }); +}); \ No newline at end of file diff --git a/tests/e2e/debug.test.js b/tests/e2e/debug.test.js new file mode 100644 index 0000000..9a1ff9d --- /dev/null +++ b/tests/e2e/debug.test.js @@ -0,0 +1,34 @@ +describe('Debug Test', () => { + let driver; + + beforeAll(async () => { + console.log('Creating driver...'); + driver = await global.createDriver(); + console.log('Driver created successfully'); + }); + + afterAll(async () => { + console.log('Quitting driver...'); + await global.quitDriver(driver); + }); + + test('should connect to frontend', async () => { + console.log('Attempting to navigate to:', global.testConfig.baseUrl); + + try { + await driver.get(global.testConfig.baseUrl); + console.log('Navigation successful'); + + const title = await driver.getTitle(); + console.log('Page title:', title); + + const currentUrl = await driver.getCurrentUrl(); + console.log('Current URL:', currentUrl); + + expect(title).toBeDefined(); + } catch (error) { + console.error('Error during navigation:', error.message); + throw error; + } + }, 60000); // 60 second timeout +}); \ No newline at end of file diff --git a/tests/e2e/screenshot.test.js b/tests/e2e/screenshot.test.js new file mode 100644 index 0000000..4c5bc64 --- /dev/null +++ b/tests/e2e/screenshot.test.js @@ -0,0 +1,61 @@ +const fs = require('fs'); +const path = require('path'); + +describe('Screenshot Debug Test', () => { + let driver; + + beforeAll(async () => { + driver = await global.createDriver(); + + // Create screenshots directory if it doesn't exist + const screenshotsDir = path.join(__dirname, '../screenshots'); + if (!fs.existsSync(screenshotsDir)) { + fs.mkdirSync(screenshotsDir); + } + }); + + afterAll(async () => { + await global.quitDriver(driver); + }); + + test('take screenshot of homepage', async () => { + console.log('Navigating to:', global.testConfig.baseUrl); + await driver.get(global.testConfig.baseUrl); + + // Wait for page to load + await driver.sleep(3000); + + // Take screenshot + const screenshot = await driver.takeScreenshot(); + const screenshotPath = path.join(__dirname, '../screenshots/homepage.png'); + fs.writeFileSync(screenshotPath, screenshot, 'base64'); + console.log('Screenshot saved to:', screenshotPath); + + // Get page info + const title = await driver.getTitle(); + const url = await driver.getCurrentUrl(); + console.log('Page title:', title); + console.log('Current URL:', url); + + // Try to find and screenshot the auth container + try { + const authContainer = await driver.findElement({ className: 'auth-container' }); + console.log('Found auth container'); + + // Click register button and take screenshot + const registerButton = await driver.findElement({ css: '.auth-toggle button:last-child' }); + await registerButton.click(); + await driver.sleep(1000); + + const screenshot2 = await driver.takeScreenshot(); + const screenshotPath2 = path.join(__dirname, '../screenshots/register-form.png'); + fs.writeFileSync(screenshotPath2, screenshot2, 'base64'); + console.log('Register form screenshot saved to:', screenshotPath2); + + } catch (e) { + console.log('Auth container not found:', e.message); + } + + expect(true).toBe(true); + }, 60000); +}); \ No newline at end of file diff --git a/tests/e2e/simple-auth.test.js b/tests/e2e/simple-auth.test.js new file mode 100644 index 0000000..8a839f0 --- /dev/null +++ b/tests/e2e/simple-auth.test.js @@ -0,0 +1,77 @@ +const { By, until } = require('selenium-webdriver'); + +describe('Simple Authentication Test', () => { + let driver; + + beforeAll(async () => { + driver = await global.createDriver(); + }); + + afterAll(async () => { + await global.quitDriver(driver); + }); + + test('should display auth forms', async () => { + console.log('Navigating to:', global.testConfig.baseUrl); + await driver.get(global.testConfig.baseUrl); + + // Wait for page to load + await driver.sleep(2000); + + // Log page source to see what's actually there + const pageSource = await driver.getPageSource(); + console.log('Page contains auth-container?', pageSource.includes('auth-container')); + console.log('Page contains login-form?', pageSource.includes('login-form')); + console.log('Page contains registration-form?', pageSource.includes('registration-form')); + + // Try to find auth container + try { + const authContainer = await driver.findElement(By.className('auth-container')); + console.log('Found auth-container'); + + // Check for toggle buttons + const toggleButtons = await driver.findElements(By.css('.auth-toggle button')); + console.log('Found toggle buttons:', toggleButtons.length); + + // Check which form is visible + try { + const loginForm = await driver.findElement(By.className('login-form')); + const isLoginVisible = await loginForm.isDisplayed(); + console.log('Login form visible:', isLoginVisible); + } catch (e) { + console.log('Login form not found'); + } + + try { + const regForm = await driver.findElement(By.className('registration-form')); + const isRegVisible = await regForm.isDisplayed(); + console.log('Registration form visible:', isRegVisible); + } catch (e) { + console.log('Registration form not found'); + } + + } catch (error) { + console.log('Auth container not found'); + + // Check if we're seeing the dashboard instead + try { + const dashboard = await driver.findElement(By.className('dashboard')); + console.log('Dashboard found - user might already be logged in'); + } catch (e) { + console.log('Dashboard not found either'); + } + + // Log the actual page title and URL + const title = await driver.getTitle(); + const url = await driver.getCurrentUrl(); + console.log('Page title:', title); + console.log('Current URL:', url); + + // Get first 500 chars of body text + const bodyText = await driver.findElement(By.tagName('body')).getText(); + console.log('Page text (first 500 chars):', bodyText.substring(0, 500)); + } + + expect(true).toBe(true); // Just to make test pass while debugging + }, 60000); +}); \ No newline at end of file diff --git a/tests/e2e/visual-auth.test.js b/tests/e2e/visual-auth.test.js new file mode 100644 index 0000000..6cf5360 --- /dev/null +++ b/tests/e2e/visual-auth.test.js @@ -0,0 +1,136 @@ +const { By, until } = require('selenium-webdriver'); + +describe('Visual Authentication Test', () => { + let driver; + + beforeAll(async () => { + console.log('Starting Chrome browser...'); + driver = await global.createDriver(); + console.log('Browser started!'); + }); + + afterAll(async () => { + console.log('Test complete - keeping browser open for 5 seconds...'); + await driver.sleep(5000); + await global.quitDriver(driver); + }); + + test('should test registration and login visually', async () => { + console.log('Navigating to app...'); + await driver.get(global.testConfig.baseUrl); + + // Wait for page load + console.log('Waiting for page to load...'); + await driver.sleep(2000); + + // Check if auth container exists + try { + const authContainer = await driver.findElement(By.className('auth-container')); + console.log('✓ Found auth container'); + + // Click Register tab + console.log('Clicking Register tab...'); + const registerButton = await driver.findElement(By.css('.auth-toggle button:last-child')); + await registerButton.click(); + await driver.sleep(1000); + + // Fill registration form + console.log('Filling registration form...'); + const nameInput = await driver.findElement(By.id('name')); + await nameInput.sendKeys('Test User'); + await driver.sleep(500); + + const emailInput = await driver.findElement(By.id('email')); + await emailInput.sendKeys('test' + Date.now() + '@example.com'); + await driver.sleep(500); + + const passwordInput = await driver.findElement(By.id('password')); + await passwordInput.sendKeys('password123'); + await driver.sleep(500); + + const confirmPasswordInput = await driver.findElement(By.id('password_confirmation')); + await confirmPasswordInput.sendKeys('password123'); + await driver.sleep(1000); + + console.log('Submitting registration form...'); + const submitButton = await driver.findElement(By.css('button[type="submit"]')); + await submitButton.click(); + + // Wait to see the result + console.log('Waiting for registration response...'); + await driver.sleep(3000); + + // Check for success message + try { + const successMessage = await driver.findElement(By.className('alert-success')); + const text = await successMessage.getText(); + console.log('✓ Registration successful:', text); + } catch (e) { + console.log('No success message found'); + + // Check for errors + try { + const errorMessage = await driver.findElement(By.className('alert-error')); + const errorText = await errorMessage.getText(); + console.log('✗ Error:', errorText); + } catch (e2) { + console.log('No error message found either'); + } + } + + // Now test login + console.log('\nSwitching to Login form...'); + const loginButton = await driver.findElement(By.css('.auth-toggle button:first-child')); + await loginButton.click(); + await driver.sleep(1000); + + console.log('Filling login form...'); + const loginEmail = await driver.findElement(By.css('.login-form #email')); + await loginEmail.clear(); + await loginEmail.sendKeys('test@example.com'); + await driver.sleep(500); + + const loginPassword = await driver.findElement(By.css('.login-form #password')); + await loginPassword.clear(); + await loginPassword.sendKeys('password123'); + await driver.sleep(1000); + + console.log('Submitting login form...'); + const loginSubmit = await driver.findElement(By.css('.login-form button[type="submit"]')); + await loginSubmit.click(); + + console.log('Waiting for login response...'); + await driver.sleep(3000); + + // Check if we reached dashboard + try { + const dashboard = await driver.findElement(By.className('dashboard')); + console.log('✓ Successfully logged in - Dashboard visible'); + } catch (e) { + console.log('Dashboard not found - checking for login errors...'); + + try { + const errorMessage = await driver.findElement(By.className('alert-error')); + const errorText = await errorMessage.getText(); + console.log('✗ Login error:', errorText); + } catch (e2) { + console.log('Still on login form'); + } + } + + } catch (error) { + console.log('✗ Auth container not found'); + console.log('Error:', error.message); + + // Check if already logged in + try { + const dashboard = await driver.findElement(By.className('dashboard')); + console.log('User appears to be already logged in (dashboard visible)'); + } catch (e) { + console.log('Dashboard not found either - page structure might be different'); + } + } + + expect(true).toBe(true); + }, 120000); +}); \ No newline at end of file diff --git a/tests/fixtures/users.json b/tests/fixtures/users.json new file mode 100644 index 0000000..cbe20d4 --- /dev/null +++ b/tests/fixtures/users.json @@ -0,0 +1,58 @@ +{ + "validUser": { + "name": "Test User", + "email": "test@example.com", + "password": "password123" + }, + "registrationTestUser": { + "name": "Registration Test", + "email": "registration.test@example.com", + "password": "securepass123" + }, + "invalidUsers": { + "invalidEmail": { + "name": "Invalid Email User", + "email": "invalid-email", + "password": "password123" + }, + "shortPassword": { + "name": "Short Password User", + "email": "shortpass@example.com", + "password": "123" + }, + "emptyFields": { + "name": "", + "email": "", + "password": "" + }, + "missingName": { + "name": "", + "email": "noname@example.com", + "password": "password123" + }, + "passwordMismatch": { + "name": "Mismatch User", + "email": "mismatch@example.com", + "password": "password123", + "passwordConfirmation": "differentpass" + } + }, + "loginTestCases": { + "validCredentials": { + "email": "test@example.com", + "password": "password123" + }, + "invalidCredentials": { + "email": "nonexistent@example.com", + "password": "wrongpassword" + }, + "emptyCredentials": { + "email": "", + "password": "" + }, + "invalidEmailFormat": { + "email": "invalid-email-format", + "password": "password123" + } + } +} \ No newline at end of file diff --git a/tests/jest-local.json b/tests/jest-local.json new file mode 100644 index 0000000..829ded7 --- /dev/null +++ b/tests/jest-local.json @@ -0,0 +1,6 @@ +{ + "testEnvironment": "node", + "testMatch": ["**/e2e/**/*.test.js"], + "setupFilesAfterEnv": ["/config/jest.setup.local.js"], + "testTimeout": 30000 +} \ No newline at end of file diff --git a/tests/package.json b/tests/package.json new file mode 100644 index 0000000..e0534ba --- /dev/null +++ b/tests/package.json @@ -0,0 +1,38 @@ +{ + "name": "trip-planner-e2e-tests", + "version": "1.0.0", + "description": "End-to-end tests for Trip Planner application", + "main": "index.js", + "scripts": { + "test": "jest", + "test:headless": "HEADLESS=true jest", + "test:watch": "jest --watch", + "test:debug": "HEADLESS=false jest --detectOpenHandles", + "test:auth": "jest e2e/auth.test.js", + "test:debug-simple": "HEADLESS=false jest e2e/debug.test.js --verbose", + "test:local": "jest --config jest-local.json", + "test:local:debug": "HEADLESS=false jest --config jest-local.json e2e/debug.test.js --verbose" + }, + "keywords": ["selenium", "e2e", "testing"], + "author": "", + "license": "MIT", + "dependencies": { + "selenium-webdriver": "^4.24.0" + }, + "devDependencies": { + "jest": "^29.7.0", + "@types/jest": "^29.5.12" + }, + "jest": { + "testEnvironment": "node", + "testMatch": ["**/e2e/**/*.test.js"], + "setupFilesAfterEnv": ["/config/jest.setup.js"], + "testTimeout": 30000 + }, + "jest-local": { + "testEnvironment": "node", + "testMatch": ["**/e2e/**/*.test.js"], + "setupFilesAfterEnv": ["/config/jest.setup.local.js"], + "testTimeout": 30000 + } +} \ No newline at end of file diff --git a/tests/pages/BasePage.js b/tests/pages/BasePage.js new file mode 100644 index 0000000..f537b02 --- /dev/null +++ b/tests/pages/BasePage.js @@ -0,0 +1,68 @@ +const { By } = require('selenium-webdriver'); +const TestUtils = require('../config/test-utils'); + +class BasePage { + constructor(driver) { + this.driver = driver; + this.utils = new TestUtils(driver); + } + + async navigateTo(path = '/') { + await this.utils.navigateTo(path); + await this.waitForPageLoad(); + } + + async waitForPageLoad() { + // Wait for React to mount + await this.driver.wait( + () => this.driver.executeScript('return document.readyState === "complete"'), + 10000 + ); + + // Additional wait for React components to render + await this.driver.sleep(1000); + } + + async getCurrentUrl() { + return await this.utils.getCurrentUrl(); + } + + async getPageTitle() { + return await this.driver.getTitle(); + } + + async isElementPresent(selector) { + try { + await this.driver.findElement(By.css(selector)); + return true; + } catch (error) { + return false; + } + } + + async isElementVisible(selector) { + try { + const element = await this.driver.findElement(By.css(selector)); + return await element.isDisplayed(); + } catch (error) { + return false; + } + } + + async getElementText(selector) { + const element = await this.utils.waitForElement(selector); + return await element.getText(); + } + + async clickElement(selector) { + const element = await this.utils.waitForElementClickable(selector); + await element.click(); + } + + async typeIntoElement(selector, text) { + const element = await this.utils.waitForElement(selector); + await this.utils.clearAndType(element, text); + } +} + +module.exports = BasePage; \ No newline at end of file diff --git a/tests/pages/DashboardPage.js b/tests/pages/DashboardPage.js new file mode 100644 index 0000000..e0208ba --- /dev/null +++ b/tests/pages/DashboardPage.js @@ -0,0 +1,64 @@ +const BasePage = require('./BasePage'); + +class DashboardPage extends BasePage { + constructor(driver) { + super(driver); + + // Selectors for the Dashboard component + this.selectors = { + dashboard: '.dashboard', + welcomeMessage: '.dashboard h2', + userInfo: '.user-info', + logoutButton: 'button[onclick*="logout"]' + }; + } + + async navigateToDashboard() { + await this.navigateTo('/dashboard'); + await this.waitForDashboard(); + } + + async waitForDashboard() { + await this.utils.waitForElementVisible(this.selectors.dashboard); + } + + async isDashboardDisplayed() { + return await this.isElementVisible(this.selectors.dashboard); + } + + async getWelcomeMessage() { + try { + return await this.getElementText(this.selectors.welcomeMessage); + } catch (error) { + return null; + } + } + + async getUserInfo() { + try { + return await this.getElementText(this.selectors.userInfo); + } catch (error) { + return null; + } + } + + async logout() { + if (await this.isElementVisible(this.selectors.logoutButton)) { + await this.clickElement(this.selectors.logoutButton); + } + } + + async waitForLogout() { + // Wait for redirect away from dashboard + await this.driver.wait( + async () => { + const currentUrl = await this.getCurrentUrl(); + return !currentUrl.includes('/dashboard'); + }, + 10000, + 'Logout did not redirect within expected time' + ); + } +} + +module.exports = DashboardPage; \ No newline at end of file diff --git a/tests/pages/LoginPage.js b/tests/pages/LoginPage.js new file mode 100644 index 0000000..9479f25 --- /dev/null +++ b/tests/pages/LoginPage.js @@ -0,0 +1,140 @@ +const BasePage = require('./BasePage'); + +class LoginPage extends BasePage { + constructor(driver) { + super(driver); + + // Selectors based on the LoginForm component + this.selectors = { + form: '.login-form', + heading: '.login-form h2', + emailInput: '#email', + passwordInput: '#password', + submitButton: 'button[type="submit"]', + errorMessage: '.error-message', + generalError: '.alert-error', + fieldError: '.error-message' + }; + } + + async navigateToLogin() { + await this.navigateTo('/'); + // Click the Login tab if not already active + const loginButton = await this.driver.findElement({ css: '.auth-toggle button:first-child' }); + await loginButton.click(); + await this.waitForLoginForm(); + } + + async waitForLoginForm() { + await this.utils.waitForElementVisible(this.selectors.form); + } + + async isLoginFormDisplayed() { + return await this.isElementVisible(this.selectors.form); + } + + async getLoginHeading() { + return await this.getElementText(this.selectors.heading); + } + + async enterEmail(email) { + await this.typeIntoElement(this.selectors.emailInput, email); + } + + async enterPassword(password) { + await this.typeIntoElement(this.selectors.passwordInput, password); + } + + async clickSubmit() { + await this.clickElement(this.selectors.submitButton); + } + + async getSubmitButtonText() { + return await this.getElementText(this.selectors.submitButton); + } + + async isSubmitButtonDisabled() { + const button = await this.utils.waitForElement(this.selectors.submitButton); + return await button.getAttribute('disabled') !== null; + } + + async login(email, password) { + await this.enterEmail(email); + await this.enterPassword(password); + await this.clickSubmit(); + } + + async getGeneralErrorMessage() { + try { + return await this.getElementText(this.selectors.generalError); + } catch (error) { + return null; + } + } + + async getEmailErrorMessage() { + try { + const emailField = await this.utils.waitForElement(this.selectors.emailInput); + const parent = await emailField.findElement({ xpath: '..' }); + const errorElement = await parent.findElement({ css: this.selectors.fieldError }); + return await errorElement.getText(); + } catch (error) { + return null; + } + } + + async getPasswordErrorMessage() { + try { + const passwordField = await this.utils.waitForElement(this.selectors.passwordInput); + const parent = await passwordField.findElement({ xpath: '..' }); + const errorElement = await parent.findElement({ css: this.selectors.fieldError }); + return await errorElement.getText(); + } catch (error) { + return null; + } + } + + async hasEmailFieldError() { + const emailField = await this.utils.waitForElement(this.selectors.emailInput); + const className = await emailField.getAttribute('class'); + return className.includes('error'); + } + + async hasPasswordFieldError() { + const passwordField = await this.utils.waitForElement(this.selectors.passwordInput); + const className = await passwordField.getAttribute('class'); + return className.includes('error'); + } + + async waitForSuccessfulLogin() { + // Wait for dashboard to appear (auth guard passes) + await this.driver.wait( + async () => { + try { + const dashboard = await this.driver.findElement({ css: '.dashboard' }); + return await dashboard.isDisplayed(); + } catch (error) { + return false; + } + }, + 10000, + 'Login did not show dashboard within expected time' + ); + } + + async waitForLoginError() { + // Wait for either general error or field errors to appear + await this.driver.wait( + async () => { + const hasGeneralError = await this.isElementVisible(this.selectors.generalError); + const hasEmailError = await this.hasEmailFieldError(); + const hasPasswordError = await this.hasPasswordFieldError(); + return hasGeneralError || hasEmailError || hasPasswordError; + }, + 10000, + 'No error message appeared within expected time' + ); + } +} + +module.exports = LoginPage; \ No newline at end of file diff --git a/tests/pages/RegistrationPage.js b/tests/pages/RegistrationPage.js new file mode 100644 index 0000000..da40614 --- /dev/null +++ b/tests/pages/RegistrationPage.js @@ -0,0 +1,175 @@ +const BasePage = require('./BasePage'); + +class RegistrationPage extends BasePage { + constructor(driver) { + super(driver); + + // Selectors based on the RegistrationForm component + this.selectors = { + form: '.registration-form', + heading: '.registration-form h2', + nameInput: '#name', + emailInput: '#email', + passwordInput: '#password', + passwordConfirmationInput: '#password_confirmation', + submitButton: 'button[type="submit"]', + successMessage: '.alert-success', + generalError: '.alert-error', + fieldError: '.error-message' + }; + } + + async navigateToRegistration() { + await this.navigateTo('/'); + // Click the Register tab + const registerButton = await this.driver.findElement({ css: '.auth-toggle button:last-child' }); + await registerButton.click(); + await this.waitForRegistrationForm(); + } + + async waitForRegistrationForm() { + await this.utils.waitForElementVisible(this.selectors.form); + } + + async isRegistrationFormDisplayed() { + return await this.isElementVisible(this.selectors.form); + } + + async getRegistrationHeading() { + return await this.getElementText(this.selectors.heading); + } + + async enterName(name) { + await this.typeIntoElement(this.selectors.nameInput, name); + } + + async enterEmail(email) { + await this.typeIntoElement(this.selectors.emailInput, email); + } + + async enterPassword(password) { + await this.typeIntoElement(this.selectors.passwordInput, password); + } + + async enterPasswordConfirmation(password) { + await this.typeIntoElement(this.selectors.passwordConfirmationInput, password); + } + + async clickSubmit() { + await this.clickElement(this.selectors.submitButton); + } + + async getSubmitButtonText() { + return await this.getElementText(this.selectors.submitButton); + } + + async isSubmitButtonDisabled() { + const button = await this.utils.waitForElement(this.selectors.submitButton); + return await button.getAttribute('disabled') !== null; + } + + async register(name, email, password, passwordConfirmation = null) { + await this.enterName(name); + await this.enterEmail(email); + await this.enterPassword(password); + await this.enterPasswordConfirmation(passwordConfirmation || password); + await this.clickSubmit(); + } + + async getSuccessMessage() { + try { + return await this.getElementText(this.selectors.successMessage); + } catch (error) { + return null; + } + } + + async getGeneralErrorMessage() { + try { + return await this.getElementText(this.selectors.generalError); + } catch (error) { + return null; + } + } + + async getFieldErrorMessage(fieldSelector) { + try { + const field = await this.utils.waitForElement(fieldSelector); + const parent = await field.findElement({ xpath: '..' }); + const errorElement = await parent.findElement({ css: this.selectors.fieldError }); + return await errorElement.getText(); + } catch (error) { + return null; + } + } + + async getNameErrorMessage() { + return await this.getFieldErrorMessage(this.selectors.nameInput); + } + + async getEmailErrorMessage() { + return await this.getFieldErrorMessage(this.selectors.emailInput); + } + + async getPasswordErrorMessage() { + return await this.getFieldErrorMessage(this.selectors.passwordInput); + } + + async getPasswordConfirmationErrorMessage() { + return await this.getFieldErrorMessage(this.selectors.passwordConfirmationInput); + } + + async hasFieldError(fieldSelector) { + const field = await this.utils.waitForElement(fieldSelector); + const className = await field.getAttribute('class'); + return className.includes('error'); + } + + async hasNameFieldError() { + return await this.hasFieldError(this.selectors.nameInput); + } + + async hasEmailFieldError() { + return await this.hasFieldError(this.selectors.emailInput); + } + + async hasPasswordFieldError() { + return await this.hasFieldError(this.selectors.passwordInput); + } + + async hasPasswordConfirmationFieldError() { + return await this.hasFieldError(this.selectors.passwordConfirmationInput); + } + + async waitForSuccessfulRegistration() { + await this.utils.waitForElementVisible(this.selectors.successMessage); + } + + async waitForRegistrationError() { + // Wait for either general error or field errors to appear + await this.driver.wait( + async () => { + const hasGeneralError = await this.isElementVisible(this.selectors.generalError); + const hasNameError = await this.hasNameFieldError(); + const hasEmailError = await this.hasEmailFieldError(); + const hasPasswordError = await this.hasPasswordFieldError(); + const hasPasswordConfirmationError = await this.hasPasswordConfirmationFieldError(); + + return hasGeneralError || hasNameError || hasEmailError || hasPasswordError || hasPasswordConfirmationError; + }, + 10000, + 'No error message appeared within expected time' + ); + } + + async isFormCleared() { + const nameValue = await (await this.utils.waitForElement(this.selectors.nameInput)).getAttribute('value'); + const emailValue = await (await this.utils.waitForElement(this.selectors.emailInput)).getAttribute('value'); + const passwordValue = await (await this.utils.waitForElement(this.selectors.passwordInput)).getAttribute('value'); + const passwordConfirmationValue = await (await this.utils.waitForElement(this.selectors.passwordConfirmationInput)).getAttribute('value'); + + return nameValue === '' && emailValue === '' && passwordValue === '' && passwordConfirmationValue === ''; + } +} + +module.exports = RegistrationPage; \ No newline at end of file -- 2.45.2 From 938869c53b34f67cc8e7b3fc9c45b63ca1030bf9 Mon Sep 17 00:00:00 2001 From: myrmidex Date: Sat, 27 Sep 2025 01:26:58 +0200 Subject: [PATCH 04/14] Add authentication tests --- .../Controllers/API/TestSetupController.php | 67 + backend/database/seeders/TestUserSeeder.php | 36 + docker-compose.dev.yml | 29 - tests/.env.example | 19 + tests/.gitignore | 9 +- tests/README.md | 37 +- tests/e2e/auth.test.js | 225 - tests/e2e/debug.test.js | 34 - tests/e2e/screenshot.test.js | 61 - tests/e2e/simple-auth.test.js | 77 - tests/e2e/visual-auth.test.js | 136 - tests/jest-local.json | 4 +- tests/package-lock.json | 4074 +++++++++++++++++ tests/package.json | 26 +- tests/run-clean.sh | 65 + tests/run-tests.sh | 111 + tests/specs/auth/auth-clean.test.js | 179 + .../specs/integration/full-auth-flow.test.js | 244 + tests/{ => support}/config/jest.setup.js | 0 .../{ => support}/config/jest.setup.local.js | 2 +- tests/{ => support}/config/test-utils.js | 0 tests/{ => support}/fixtures/users.json | 0 tests/support/helpers/test-data.js | 92 + tests/{ => support}/pages/BasePage.js | 5 +- tests/{ => support}/pages/DashboardPage.js | 0 tests/{ => support}/pages/LoginPage.js | 0 tests/{ => support}/pages/RegistrationPage.js | 4 +- 27 files changed, 4936 insertions(+), 600 deletions(-) create mode 100644 backend/app/Http/Controllers/API/TestSetupController.php create mode 100644 backend/database/seeders/TestUserSeeder.php create mode 100644 tests/.env.example delete mode 100644 tests/e2e/auth.test.js delete mode 100644 tests/e2e/debug.test.js delete mode 100644 tests/e2e/screenshot.test.js delete mode 100644 tests/e2e/simple-auth.test.js delete mode 100644 tests/e2e/visual-auth.test.js create mode 100644 tests/package-lock.json create mode 100755 tests/run-clean.sh create mode 100755 tests/run-tests.sh create mode 100644 tests/specs/auth/auth-clean.test.js create mode 100644 tests/specs/integration/full-auth-flow.test.js rename tests/{ => support}/config/jest.setup.js (100%) rename tests/{ => support}/config/jest.setup.local.js (96%) rename tests/{ => support}/config/test-utils.js (100%) rename tests/{ => support}/fixtures/users.json (100%) create mode 100644 tests/support/helpers/test-data.js rename tests/{ => support}/pages/BasePage.js (89%) rename tests/{ => support}/pages/DashboardPage.js (100%) rename tests/{ => support}/pages/LoginPage.js (100%) rename tests/{ => support}/pages/RegistrationPage.js (96%) diff --git a/backend/app/Http/Controllers/API/TestSetupController.php b/backend/app/Http/Controllers/API/TestSetupController.php new file mode 100644 index 0000000..4bea703 --- /dev/null +++ b/backend/app/Http/Controllers/API/TestSetupController.php @@ -0,0 +1,67 @@ +environment('production')) { + return response()->json(['error' => 'Not available in production'], 403); + } + + $validated = $request->validate([ + 'email' => 'required|email', + 'password' => 'required|min:8', + 'name' => 'required|string' + ]); + + $user = User::firstOrCreate( + ['email' => $validated['email']], + [ + 'name' => $validated['name'], + 'password' => Hash::make($validated['password']), + 'email_verified_at' => now(), + ] + ); + + return response()->json([ + 'success' => true, + 'message' => $user->wasRecentlyCreated ? 'Test user created' : 'Test user already exists', + 'data' => [ + 'id' => $user->id, + 'email' => $user->email, + 'name' => $user->name + ] + ]); + } + + /** + * Clean up test data + */ + public function cleanup(Request $request) + { + if (app()->environment('production')) { + return response()->json(['error' => 'Not available in production'], 403); + } + + // Delete test users (those with specific test email patterns) + $deleted = User::where('email', 'LIKE', 'test%@example.com') + ->orWhere('email', 'LIKE', 'test.user.%@example.com') + ->delete(); + + return response()->json([ + 'success' => true, + 'message' => "Deleted $deleted test users" + ]); + } +} \ No newline at end of file diff --git a/backend/database/seeders/TestUserSeeder.php b/backend/database/seeders/TestUserSeeder.php new file mode 100644 index 0000000..36d4d6b --- /dev/null +++ b/backend/database/seeders/TestUserSeeder.php @@ -0,0 +1,36 @@ + 'test@example.com'], + [ + 'name' => 'Test User', + 'password' => Hash::make('password123'), + 'email_verified_at' => now(), + ] + ); + + // Create additional test users if needed + User::firstOrCreate( + ['email' => 'admin@example.com'], + [ + 'name' => 'Admin User', + 'password' => Hash::make('admin123'), + 'email_verified_at' => now(), + ] + ); + } +} \ No newline at end of file diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index a4f4c06..9478523 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -72,35 +72,6 @@ services: networks: - trip-planner-network - selenium-hub: - image: selenium/hub:latest - container_name: trip-planner-selenium-hub - ports: - - "4442:4442" - - "4443:4443" - - "4444:4444" - environment: - - GRID_MAX_SESSION=4 - - GRID_BROWSER_TIMEOUT=300 - - GRID_TIMEOUT=300 - networks: - - trip-planner-network - - selenium-chrome: - image: selenium/node-chrome:latest - container_name: trip-planner-selenium-chrome - environment: - - HUB_HOST=selenium-hub - - HUB_PORT=4444 - - SE_EVENT_BUS_HOST=selenium-hub - - SE_EVENT_BUS_PUBLISH_PORT=4442 - - SE_EVENT_BUS_SUBSCRIBE_PORT=4443 - - NODE_MAX_INSTANCES=4 - - NODE_MAX_SESSION=4 - depends_on: - - selenium-hub - networks: - - trip-planner-network networks: trip-planner-network: diff --git a/tests/.env.example b/tests/.env.example new file mode 100644 index 0000000..bb034f8 --- /dev/null +++ b/tests/.env.example @@ -0,0 +1,19 @@ +# Test Environment Configuration + +# Frontend URL (default: http://localhost:5173) +BASE_URL=http://localhost:5173 + +# Selenium Hub URL (default: http://localhost:4444) +# Use http://localhost:4444 for Docker Selenium +SELENIUM_HUB=http://localhost:4444 + +# Test Mode +# Set to "true" to run tests in headless mode +HEADLESS=false + +# Test User Credentials (for existing user tests) +TEST_USER_EMAIL=test@example.com +TEST_USER_PASSWORD=password123 + +# Test Timeout (in milliseconds) +TEST_TIMEOUT=30000 \ No newline at end of file diff --git a/tests/.gitignore b/tests/.gitignore index d502512..88ad741 100644 --- a/tests/.gitignore +++ b/tests/.gitignore @@ -1,2 +1,7 @@ -/node_modules -/package-lock.json +node_modules/ +screenshots/ +*.log +.env +.env.local +coverage/ +test-results/ diff --git a/tests/README.md b/tests/README.md index bbaa1d6..2a36728 100644 --- a/tests/README.md +++ b/tests/README.md @@ -70,23 +70,26 @@ npm test screenshot.test.js ``` tests/ -├── config/ -│ ├── jest.setup.js # Docker Selenium configuration -│ ├── jest.setup.local.js # Local Chrome configuration -│ └── test-utils.js # Helper utilities -├── e2e/ -│ ├── auth.test.js # Main authentication test suite -│ ├── debug.test.js # Simple connection test -│ ├── visual-auth.test.js # Slow visual test for debugging -│ └── screenshot.test.js # Takes screenshots for debugging -├── fixtures/ -│ └── users.json # Test user data -├── pages/ -│ ├── BasePage.js # Base page object -│ ├── LoginPage.js # Login page object -│ ├── RegistrationPage.js # Registration page object -│ └── DashboardPage.js # Dashboard page object -└── screenshots/ # Screenshot output directory +├── specs/ +│ ├── auth/ +│ │ └── auth-clean.test.js # Clean authentication tests +│ └── integration/ +│ └── full-auth-flow.test.js # Full user journey test +├── support/ +│ ├── config/ +│ │ ├── jest.setup.js # Docker Selenium configuration +│ │ ├── jest.setup.local.js # Local Chrome configuration +│ │ └── test-utils.js # Helper utilities +│ ├── pages/ +│ │ ├── BasePage.js # Base page object +│ │ ├── LoginPage.js # Login page object +│ │ ├── RegistrationPage.js # Registration page object +│ │ └── DashboardPage.js # Dashboard page object +│ ├── fixtures/ +│ │ └── users.json # Test user data +│ └── helpers/ +│ └── test-data.js # Test data management +└── screenshots/ # Screenshot output directory ``` ## Environment Variables diff --git a/tests/e2e/auth.test.js b/tests/e2e/auth.test.js deleted file mode 100644 index 0c92155..0000000 --- a/tests/e2e/auth.test.js +++ /dev/null @@ -1,225 +0,0 @@ -const RegistrationPage = require('../pages/RegistrationPage'); -const LoginPage = require('../pages/LoginPage'); -const DashboardPage = require('../pages/DashboardPage'); -const users = require('../fixtures/users.json'); - -describe('Authentication E2E Tests', () => { - let driver; - let registrationPage; - let loginPage; - let dashboardPage; - - beforeAll(async () => { - driver = await global.createDriver(); - registrationPage = new RegistrationPage(driver); - loginPage = new LoginPage(driver); - dashboardPage = new DashboardPage(driver); - }); - - afterAll(async () => { - await global.quitDriver(driver); - }); - - beforeEach(async () => { - // Navigate to home page before each test to ensure clean state - await driver.get(global.testConfig.baseUrl); - }); - - describe('User Registration', () => { - test('should successfully register a new user', async () => { - const testUser = users.registrationTestUser; - - await registrationPage.navigateToRegistration(); - - expect(await registrationPage.isRegistrationFormDisplayed()).toBe(true); - expect(await registrationPage.getRegistrationHeading()).toBe('Register'); - - await registrationPage.register( - testUser.name, - testUser.email, - testUser.password - ); - - await registrationPage.waitForSuccessfulRegistration(); - - const successMessage = await registrationPage.getSuccessMessage(); - expect(successMessage).toContain('Registration successful'); - expect(await registrationPage.isFormCleared()).toBe(true); - }); - - test('should show validation errors for invalid registration data', async () => { - const invalidUser = users.invalidUsers.shortPassword; - - await registrationPage.navigateToRegistration(); - await registrationPage.register( - invalidUser.name, - invalidUser.email, - invalidUser.password - ); - - await registrationPage.waitForRegistrationError(); - - // Check that form shows validation errors - const hasErrors = await registrationPage.hasPasswordFieldError() || - await registrationPage.getPasswordErrorMessage() !== null || - await registrationPage.getGeneralErrorMessage() !== null; - - expect(hasErrors).toBe(true); - }); - - test('should show error for password mismatch', async () => { - const mismatchUser = users.invalidUsers.passwordMismatch; - - await registrationPage.navigateToRegistration(); - await registrationPage.register( - mismatchUser.name, - mismatchUser.email, - mismatchUser.password, - mismatchUser.passwordConfirmation - ); - - await registrationPage.waitForRegistrationError(); - - const hasPasswordConfirmationError = await registrationPage.hasPasswordConfirmationFieldError() || - await registrationPage.getPasswordConfirmationErrorMessage() !== null; - - expect(hasPasswordConfirmationError).toBe(true); - }); - - test('should show error for empty required fields', async () => { - await registrationPage.navigateToRegistration(); - await registrationPage.clickSubmit(); - - // The form should prevent submission with empty required fields - // or show validation errors - const submitButtonText = await registrationPage.getSubmitButtonText(); - expect(submitButtonText).toBe('Register'); // Button text shouldn't change to "Registering..." - }); - - test('should show error for invalid email format', async () => { - const invalidEmailUser = users.invalidUsers.invalidEmail; - - await registrationPage.navigateToRegistration(); - await registrationPage.register( - invalidEmailUser.name, - invalidEmailUser.email, - invalidEmailUser.password - ); - - // The HTML5 email validation should prevent form submission - // or backend should return validation error - const currentUrl = await registrationPage.getCurrentUrl(); - expect(currentUrl).toContain('/register'); - }); - }); - - describe('User Login', () => { - test('should successfully login with valid credentials', async () => { - const validCredentials = users.loginTestCases.validCredentials; - - await loginPage.navigateToLogin(); - - expect(await loginPage.isLoginFormDisplayed()).toBe(true); - expect(await loginPage.getLoginHeading()).toBe('Login'); - - await loginPage.login( - validCredentials.email, - validCredentials.password - ); - - await loginPage.waitForSuccessfulLogin(); - - // Should see dashboard after successful login - const dashboardVisible = await dashboardPage.isDashboardDisplayed(); - expect(dashboardVisible).toBe(true); - }); - - test('should show error for invalid credentials', async () => { - const invalidCredentials = users.loginTestCases.invalidCredentials; - - await loginPage.navigateToLogin(); - await loginPage.login( - invalidCredentials.email, - invalidCredentials.password - ); - - await loginPage.waitForLoginError(); - - const generalError = await loginPage.getGeneralErrorMessage(); - expect(generalError).toContain('Invalid email or password'); - }); - - test('should show validation errors for empty fields', async () => { - await loginPage.navigateToLogin(); - await loginPage.clickSubmit(); - - // The form should prevent submission with empty required fields - const submitButtonText = await loginPage.getSubmitButtonText(); - expect(submitButtonText).toBe('Login'); // Button text shouldn't change to "Logging in..." - }); - - test('should show error for invalid email format', async () => { - const invalidEmailCredentials = users.loginTestCases.invalidEmailFormat; - - await loginPage.navigateToLogin(); - await loginPage.login( - invalidEmailCredentials.email, - invalidEmailCredentials.password - ); - - // The HTML5 email validation should prevent form submission - // or backend should return validation error - const currentUrl = await loginPage.getCurrentUrl(); - expect(currentUrl).toContain('/login'); - }); - - test('should disable submit button while login is in progress', async () => { - const validCredentials = users.loginTestCases.validCredentials; - - await loginPage.navigateToLogin(); - await loginPage.enterEmail(validCredentials.email); - await loginPage.enterPassword(validCredentials.password); - - // Click submit and immediately check if button is disabled - await loginPage.clickSubmit(); - - // Check if button text changes to "Logging in..." (indicating loading state) - const submitButtonText = await loginPage.getSubmitButtonText(); - const isButtonDisabled = await loginPage.isSubmitButtonDisabled(); - - expect(submitButtonText === 'Logging in...' || isButtonDisabled).toBe(true); - }); - }); - - describe('Authentication Flow Integration', () => { - test('should allow registration followed by immediate login', async () => { - // Use a unique email for this test to avoid conflicts - const timestamp = Date.now(); - const testUser = { - name: 'Integration Test User', - email: `integration.test.${timestamp}@example.com`, - password: 'password123' - }; - - // Register new user - await registrationPage.navigateToRegistration(); - await registrationPage.register( - testUser.name, - testUser.email, - testUser.password - ); - - await registrationPage.waitForSuccessfulRegistration(); - - // Navigate to login and use the same credentials - await loginPage.navigateToLogin(); - await loginPage.login(testUser.email, testUser.password); - - await loginPage.waitForSuccessfulLogin(); - - // Should be successfully logged in - const currentUrl = await loginPage.getCurrentUrl(); - expect(currentUrl).not.toContain('/login'); - }); - }); -}); \ No newline at end of file diff --git a/tests/e2e/debug.test.js b/tests/e2e/debug.test.js deleted file mode 100644 index 9a1ff9d..0000000 --- a/tests/e2e/debug.test.js +++ /dev/null @@ -1,34 +0,0 @@ -describe('Debug Test', () => { - let driver; - - beforeAll(async () => { - console.log('Creating driver...'); - driver = await global.createDriver(); - console.log('Driver created successfully'); - }); - - afterAll(async () => { - console.log('Quitting driver...'); - await global.quitDriver(driver); - }); - - test('should connect to frontend', async () => { - console.log('Attempting to navigate to:', global.testConfig.baseUrl); - - try { - await driver.get(global.testConfig.baseUrl); - console.log('Navigation successful'); - - const title = await driver.getTitle(); - console.log('Page title:', title); - - const currentUrl = await driver.getCurrentUrl(); - console.log('Current URL:', currentUrl); - - expect(title).toBeDefined(); - } catch (error) { - console.error('Error during navigation:', error.message); - throw error; - } - }, 60000); // 60 second timeout -}); \ No newline at end of file diff --git a/tests/e2e/screenshot.test.js b/tests/e2e/screenshot.test.js deleted file mode 100644 index 4c5bc64..0000000 --- a/tests/e2e/screenshot.test.js +++ /dev/null @@ -1,61 +0,0 @@ -const fs = require('fs'); -const path = require('path'); - -describe('Screenshot Debug Test', () => { - let driver; - - beforeAll(async () => { - driver = await global.createDriver(); - - // Create screenshots directory if it doesn't exist - const screenshotsDir = path.join(__dirname, '../screenshots'); - if (!fs.existsSync(screenshotsDir)) { - fs.mkdirSync(screenshotsDir); - } - }); - - afterAll(async () => { - await global.quitDriver(driver); - }); - - test('take screenshot of homepage', async () => { - console.log('Navigating to:', global.testConfig.baseUrl); - await driver.get(global.testConfig.baseUrl); - - // Wait for page to load - await driver.sleep(3000); - - // Take screenshot - const screenshot = await driver.takeScreenshot(); - const screenshotPath = path.join(__dirname, '../screenshots/homepage.png'); - fs.writeFileSync(screenshotPath, screenshot, 'base64'); - console.log('Screenshot saved to:', screenshotPath); - - // Get page info - const title = await driver.getTitle(); - const url = await driver.getCurrentUrl(); - console.log('Page title:', title); - console.log('Current URL:', url); - - // Try to find and screenshot the auth container - try { - const authContainer = await driver.findElement({ className: 'auth-container' }); - console.log('Found auth container'); - - // Click register button and take screenshot - const registerButton = await driver.findElement({ css: '.auth-toggle button:last-child' }); - await registerButton.click(); - await driver.sleep(1000); - - const screenshot2 = await driver.takeScreenshot(); - const screenshotPath2 = path.join(__dirname, '../screenshots/register-form.png'); - fs.writeFileSync(screenshotPath2, screenshot2, 'base64'); - console.log('Register form screenshot saved to:', screenshotPath2); - - } catch (e) { - console.log('Auth container not found:', e.message); - } - - expect(true).toBe(true); - }, 60000); -}); \ No newline at end of file diff --git a/tests/e2e/simple-auth.test.js b/tests/e2e/simple-auth.test.js deleted file mode 100644 index 8a839f0..0000000 --- a/tests/e2e/simple-auth.test.js +++ /dev/null @@ -1,77 +0,0 @@ -const { By, until } = require('selenium-webdriver'); - -describe('Simple Authentication Test', () => { - let driver; - - beforeAll(async () => { - driver = await global.createDriver(); - }); - - afterAll(async () => { - await global.quitDriver(driver); - }); - - test('should display auth forms', async () => { - console.log('Navigating to:', global.testConfig.baseUrl); - await driver.get(global.testConfig.baseUrl); - - // Wait for page to load - await driver.sleep(2000); - - // Log page source to see what's actually there - const pageSource = await driver.getPageSource(); - console.log('Page contains auth-container?', pageSource.includes('auth-container')); - console.log('Page contains login-form?', pageSource.includes('login-form')); - console.log('Page contains registration-form?', pageSource.includes('registration-form')); - - // Try to find auth container - try { - const authContainer = await driver.findElement(By.className('auth-container')); - console.log('Found auth-container'); - - // Check for toggle buttons - const toggleButtons = await driver.findElements(By.css('.auth-toggle button')); - console.log('Found toggle buttons:', toggleButtons.length); - - // Check which form is visible - try { - const loginForm = await driver.findElement(By.className('login-form')); - const isLoginVisible = await loginForm.isDisplayed(); - console.log('Login form visible:', isLoginVisible); - } catch (e) { - console.log('Login form not found'); - } - - try { - const regForm = await driver.findElement(By.className('registration-form')); - const isRegVisible = await regForm.isDisplayed(); - console.log('Registration form visible:', isRegVisible); - } catch (e) { - console.log('Registration form not found'); - } - - } catch (error) { - console.log('Auth container not found'); - - // Check if we're seeing the dashboard instead - try { - const dashboard = await driver.findElement(By.className('dashboard')); - console.log('Dashboard found - user might already be logged in'); - } catch (e) { - console.log('Dashboard not found either'); - } - - // Log the actual page title and URL - const title = await driver.getTitle(); - const url = await driver.getCurrentUrl(); - console.log('Page title:', title); - console.log('Current URL:', url); - - // Get first 500 chars of body text - const bodyText = await driver.findElement(By.tagName('body')).getText(); - console.log('Page text (first 500 chars):', bodyText.substring(0, 500)); - } - - expect(true).toBe(true); // Just to make test pass while debugging - }, 60000); -}); \ No newline at end of file diff --git a/tests/e2e/visual-auth.test.js b/tests/e2e/visual-auth.test.js deleted file mode 100644 index 6cf5360..0000000 --- a/tests/e2e/visual-auth.test.js +++ /dev/null @@ -1,136 +0,0 @@ -const { By, until } = require('selenium-webdriver'); - -describe('Visual Authentication Test', () => { - let driver; - - beforeAll(async () => { - console.log('Starting Chrome browser...'); - driver = await global.createDriver(); - console.log('Browser started!'); - }); - - afterAll(async () => { - console.log('Test complete - keeping browser open for 5 seconds...'); - await driver.sleep(5000); - await global.quitDriver(driver); - }); - - test('should test registration and login visually', async () => { - console.log('Navigating to app...'); - await driver.get(global.testConfig.baseUrl); - - // Wait for page load - console.log('Waiting for page to load...'); - await driver.sleep(2000); - - // Check if auth container exists - try { - const authContainer = await driver.findElement(By.className('auth-container')); - console.log('✓ Found auth container'); - - // Click Register tab - console.log('Clicking Register tab...'); - const registerButton = await driver.findElement(By.css('.auth-toggle button:last-child')); - await registerButton.click(); - await driver.sleep(1000); - - // Fill registration form - console.log('Filling registration form...'); - const nameInput = await driver.findElement(By.id('name')); - await nameInput.sendKeys('Test User'); - await driver.sleep(500); - - const emailInput = await driver.findElement(By.id('email')); - await emailInput.sendKeys('test' + Date.now() + '@example.com'); - await driver.sleep(500); - - const passwordInput = await driver.findElement(By.id('password')); - await passwordInput.sendKeys('password123'); - await driver.sleep(500); - - const confirmPasswordInput = await driver.findElement(By.id('password_confirmation')); - await confirmPasswordInput.sendKeys('password123'); - await driver.sleep(1000); - - console.log('Submitting registration form...'); - const submitButton = await driver.findElement(By.css('button[type="submit"]')); - await submitButton.click(); - - // Wait to see the result - console.log('Waiting for registration response...'); - await driver.sleep(3000); - - // Check for success message - try { - const successMessage = await driver.findElement(By.className('alert-success')); - const text = await successMessage.getText(); - console.log('✓ Registration successful:', text); - } catch (e) { - console.log('No success message found'); - - // Check for errors - try { - const errorMessage = await driver.findElement(By.className('alert-error')); - const errorText = await errorMessage.getText(); - console.log('✗ Error:', errorText); - } catch (e2) { - console.log('No error message found either'); - } - } - - // Now test login - console.log('\nSwitching to Login form...'); - const loginButton = await driver.findElement(By.css('.auth-toggle button:first-child')); - await loginButton.click(); - await driver.sleep(1000); - - console.log('Filling login form...'); - const loginEmail = await driver.findElement(By.css('.login-form #email')); - await loginEmail.clear(); - await loginEmail.sendKeys('test@example.com'); - await driver.sleep(500); - - const loginPassword = await driver.findElement(By.css('.login-form #password')); - await loginPassword.clear(); - await loginPassword.sendKeys('password123'); - await driver.sleep(1000); - - console.log('Submitting login form...'); - const loginSubmit = await driver.findElement(By.css('.login-form button[type="submit"]')); - await loginSubmit.click(); - - console.log('Waiting for login response...'); - await driver.sleep(3000); - - // Check if we reached dashboard - try { - const dashboard = await driver.findElement(By.className('dashboard')); - console.log('✓ Successfully logged in - Dashboard visible'); - } catch (e) { - console.log('Dashboard not found - checking for login errors...'); - - try { - const errorMessage = await driver.findElement(By.className('alert-error')); - const errorText = await errorMessage.getText(); - console.log('✗ Login error:', errorText); - } catch (e2) { - console.log('Still on login form'); - } - } - - } catch (error) { - console.log('✗ Auth container not found'); - console.log('Error:', error.message); - - // Check if already logged in - try { - const dashboard = await driver.findElement(By.className('dashboard')); - console.log('User appears to be already logged in (dashboard visible)'); - } catch (e) { - console.log('Dashboard not found either - page structure might be different'); - } - } - - expect(true).toBe(true); - }, 120000); -}); \ No newline at end of file diff --git a/tests/jest-local.json b/tests/jest-local.json index 829ded7..fb834c5 100644 --- a/tests/jest-local.json +++ b/tests/jest-local.json @@ -1,6 +1,6 @@ { "testEnvironment": "node", - "testMatch": ["**/e2e/**/*.test.js"], - "setupFilesAfterEnv": ["/config/jest.setup.local.js"], + "testMatch": ["**/specs/**/*.test.js"], + "setupFilesAfterEnv": ["/support/config/jest.setup.local.js"], "testTimeout": 30000 } \ No newline at end of file diff --git a/tests/package-lock.json b/tests/package-lock.json new file mode 100644 index 0000000..d14ff3d --- /dev/null +++ b/tests/package-lock.json @@ -0,0 +1,4074 @@ +{ + "name": "trip-planner-e2e-tests", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "trip-planner-e2e-tests", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "axios": "^1.6.0", + "selenium-webdriver": "^4.24.0" + }, + "devDependencies": { + "@types/jest": "^29.5.12", + "jest": "^29.7.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.27.1", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.4.tgz", + "integrity": "sha512-YsmSKC29MJwf0gF8Rjjrg5LQCmyh+j/nD8/eP7f+BeoQTKYqs9RoWbjGOdy0+1Ekr68RJZMUOPVQaQisnIo4Rw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.4.tgz", + "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.3", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-module-transforms": "^7.28.3", + "@babel/helpers": "^7.28.4", + "@babel/parser": "^7.28.4", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.4", + "@babel/types": "^7.28.4", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.3.tgz", + "integrity": "sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.3", + "@babel/types": "^7.28.2", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", + "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.27.2", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", + "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", + "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1", + "@babel/traverse": "^7.28.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", + "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", + "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", + "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.4" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.4.tgz", + "integrity": "sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.4" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-syntax-async-generators": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", + "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-bigint": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz", + "integrity": "sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-properties": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz", + "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.12.13" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-static-block": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz", + "integrity": "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-attributes": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.27.1.tgz", + "integrity": "sha512-oFT0FrKHgF53f4vOsZGi2Hh3I35PfSmVs4IBFLFj4dnafP+hIWDLg3VyKmUHfLoLHlyxY4C7DGtmHuJgn+IGww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-meta": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz", + "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-json-strings": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", + "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.27.1.tgz", + "integrity": "sha512-y8YTNIeKoyhGd9O0Jiyzyyqk8gdjnumGTQPsz0xOZOQ2RmkVJeZ1vmmfIvFEKqucBG6axJGBZDE/7iI5suUI/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-logical-assignment-operators": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", + "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", + "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-numeric-separator": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", + "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-object-rest-spread": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", + "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-catch-binding": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", + "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-chaining": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", + "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-private-property-in-object": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz", + "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-top-level-await": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", + "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-typescript": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.27.1.tgz", + "integrity": "sha512-xfYCBMxveHrRMnAWl1ZlPXOZjzkN82THFvLhQhFXFt81Z5HnN+EtUkZhv/zcKpmT3fzmWZB0ywiBrbC3vogbwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", + "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/parser": "^7.27.2", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.4.tgz", + "integrity": "sha512-YEzuboP2qvQavAcjgQNVgsvHIDv6ZpwXvcvjmyySP2DIMuByS/6ioU5G9pYrWHM6T2YDfc7xga9iNzYOs12CFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.3", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.4", + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.4", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.4.tgz", + "integrity": "sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bazel/runfiles": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@bazel/runfiles/-/runfiles-6.5.0.tgz", + "integrity": "sha512-RzahvqTkfpY2jsDxo8YItPX+/iZ6hbiikw1YhE0bA9EKBR5Og8Pa6FHn9PO9M0zaXRVsr0GFQLKbB/0rzy9SzA==", + "license": "Apache-2.0" + }, + "node_modules/@bcoe/v8-coverage": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", + "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@istanbuljs/load-nyc-config": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", + "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "camelcase": "^5.3.1", + "find-up": "^4.1.0", + "get-package-type": "^0.1.0", + "js-yaml": "^3.13.1", + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", + "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/console": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/console/-/console-29.7.0.tgz", + "integrity": "sha512-5Ni4CU7XHQi32IJ398EEP4RrB8eV09sXP2ROqD4bksHrnTree52PsxvX8tpL8LvTZ3pFzXyPbNQReSN41CAhOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/core": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/core/-/core-29.7.0.tgz", + "integrity": "sha512-n7aeXWKMnGtDA48y8TLWJPJmLmmZ642Ceo78cYWEpiD7FzDgmNDV/GCVRorPABdXLJZ/9wzzgZAlHjXjxDHGsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/reporters": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "ansi-escapes": "^4.2.1", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "exit": "^0.1.2", + "graceful-fs": "^4.2.9", + "jest-changed-files": "^29.7.0", + "jest-config": "^29.7.0", + "jest-haste-map": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-resolve-dependencies": "^29.7.0", + "jest-runner": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "jest-watcher": "^29.7.0", + "micromatch": "^4.0.4", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/environment": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.7.0.tgz", + "integrity": "sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/fake-timers": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-mock": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/expect": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-29.7.0.tgz", + "integrity": "sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "expect": "^29.7.0", + "jest-snapshot": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/expect-utils": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-29.7.0.tgz", + "integrity": "sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-get-type": "^29.6.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/fake-timers": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-29.7.0.tgz", + "integrity": "sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@sinonjs/fake-timers": "^10.0.2", + "@types/node": "*", + "jest-message-util": "^29.7.0", + "jest-mock": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/globals": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-29.7.0.tgz", + "integrity": "sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/expect": "^29.7.0", + "@jest/types": "^29.6.3", + "jest-mock": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/reporters": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-29.7.0.tgz", + "integrity": "sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^0.2.3", + "@jest/console": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@jridgewell/trace-mapping": "^0.3.18", + "@types/node": "*", + "chalk": "^4.0.0", + "collect-v8-coverage": "^1.0.0", + "exit": "^0.1.2", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "istanbul-lib-coverage": "^3.0.0", + "istanbul-lib-instrument": "^6.0.0", + "istanbul-lib-report": "^3.0.0", + "istanbul-lib-source-maps": "^4.0.0", + "istanbul-reports": "^3.1.3", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "jest-worker": "^29.7.0", + "slash": "^3.0.0", + "string-length": "^4.0.1", + "strip-ansi": "^6.0.0", + "v8-to-istanbul": "^9.0.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/schemas": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", + "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.27.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/source-map": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-29.6.3.tgz", + "integrity": "sha512-MHjT95QuipcPrpLM+8JMSzFx6eHp5Bm+4XeFDJlwsvVBjmKNiIAvasGK2fxz2WbGRlnvqehFbh07MMa7n3YJnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.18", + "callsites": "^3.0.0", + "graceful-fs": "^4.2.9" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/test-result": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-29.7.0.tgz", + "integrity": "sha512-Fdx+tv6x1zlkJPcWXmMDAG2HBnaR9XPSd5aDWQVsfrZmLVT3lU1cwyxLgRmXR9yrq4NBoEm9BMsfgFzTQAbJYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "collect-v8-coverage": "^1.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/test-sequencer": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-29.7.0.tgz", + "integrity": "sha512-GQwJ5WZVrKnOJuiYiAF52UNUJXgTZx1NHjFSEB0qEMmSZKAkdMoIzw/Cj6x6NF4AvV23AUqDpFzQkN/eYCYTxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/test-result": "^29.7.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/transform": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-29.7.0.tgz", + "integrity": "sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.11.6", + "@jest/types": "^29.6.3", + "@jridgewell/trace-mapping": "^0.3.18", + "babel-plugin-istanbul": "^6.1.1", + "chalk": "^4.0.0", + "convert-source-map": "^2.0.0", + "fast-json-stable-stringify": "^2.1.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-util": "^29.7.0", + "micromatch": "^4.0.4", + "pirates": "^4.0.4", + "slash": "^3.0.0", + "write-file-atomic": "^4.0.2" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/types": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", + "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^17.0.8", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@sinclair/typebox": { + "version": "0.27.8", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", + "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sinonjs/commons": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/fake-timers": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz", + "integrity": "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.0" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/graceful-fs": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", + "integrity": "sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/istanbul-lib-coverage": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/istanbul-lib-report": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz", + "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-coverage": "*" + } + }, + "node_modules/@types/istanbul-reports": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", + "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-report": "*" + } + }, + "node_modules/@types/jest": { + "version": "29.5.14", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.14.tgz", + "integrity": "sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "expect": "^29.0.0", + "pretty-format": "^29.0.0" + } + }, + "node_modules/@types/node": { + "version": "24.5.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.5.2.tgz", + "integrity": "sha512-FYxk1I7wPv3K2XBaoyH2cTnocQEu8AOZ60hPbsyukMPLv5/5qr7V1i8PLHdl6Zf87I+xZXFvPCXYjiTFq+YSDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.12.0" + } + }, + "node_modules/@types/stack-utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", + "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/yargs": { + "version": "17.0.33", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz", + "integrity": "sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/@types/yargs-parser": { + "version": "21.0.3", + "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", + "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/ansi-escapes": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", + "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.21.3" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz", + "integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.4", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/babel-jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", + "integrity": "sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/transform": "^29.7.0", + "@types/babel__core": "^7.1.14", + "babel-plugin-istanbul": "^6.1.1", + "babel-preset-jest": "^29.6.3", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.8.0" + } + }, + "node_modules/babel-plugin-istanbul": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-6.1.1.tgz", + "integrity": "sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "@istanbuljs/load-nyc-config": "^1.0.0", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-instrument": "^5.0.4", + "test-exclude": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/babel-plugin-istanbul/node_modules/istanbul-lib-instrument": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-5.2.1.tgz", + "integrity": "sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/core": "^7.12.3", + "@babel/parser": "^7.14.7", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^6.3.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/babel-plugin-jest-hoist": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-29.6.3.tgz", + "integrity": "sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.3.3", + "@babel/types": "^7.3.3", + "@types/babel__core": "^7.1.14", + "@types/babel__traverse": "^7.0.6" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/babel-preset-current-node-syntax": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.2.0.tgz", + "integrity": "sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/plugin-syntax-async-generators": "^7.8.4", + "@babel/plugin-syntax-bigint": "^7.8.3", + "@babel/plugin-syntax-class-properties": "^7.12.13", + "@babel/plugin-syntax-class-static-block": "^7.14.5", + "@babel/plugin-syntax-import-attributes": "^7.24.7", + "@babel/plugin-syntax-import-meta": "^7.10.4", + "@babel/plugin-syntax-json-strings": "^7.8.3", + "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", + "@babel/plugin-syntax-numeric-separator": "^7.10.4", + "@babel/plugin-syntax-object-rest-spread": "^7.8.3", + "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", + "@babel/plugin-syntax-optional-chaining": "^7.8.3", + "@babel/plugin-syntax-private-property-in-object": "^7.14.5", + "@babel/plugin-syntax-top-level-await": "^7.14.5" + }, + "peerDependencies": { + "@babel/core": "^7.0.0 || ^8.0.0-0" + } + }, + "node_modules/babel-preset-jest": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-29.6.3.tgz", + "integrity": "sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA==", + "dev": true, + "license": "MIT", + "dependencies": { + "babel-plugin-jest-hoist": "^29.6.3", + "babel-preset-current-node-syntax": "^1.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.8.7", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.7.tgz", + "integrity": "sha512-bxxN2M3a4d1CRoQC//IqsR5XrLh0IJ8TCv2x6Y9N0nckNz/rTjZB3//GGscZziZOxmjP55rzxg/ze7usFI9FqQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.26.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.26.2.tgz", + "integrity": "sha512-ECFzp6uFOSB+dcZ5BK/IBaGWssbSYBHvuMeMt3MMFyhI0Z8SqGgEkBLARgpRH3hutIgPVsALcMwbDrJqPxQ65A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.8.3", + "caniuse-lite": "^1.0.30001741", + "electron-to-chromium": "^1.5.218", + "node-releases": "^2.0.21", + "update-browserslist-db": "^1.1.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/bser": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", + "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "node-int64": "^0.4.0" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001745", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001745.tgz", + "integrity": "sha512-ywt6i8FzvdgrrrGbr1jZVObnVv6adj+0if2/omv9cmR2oiZs30zL4DIyaptKcbOrBdOIc74QTMoJvSE2QHh5UQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/char-regex": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", + "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cjs-module-lexer": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.4.3.tgz", + "integrity": "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/co": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", + "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">= 1.0.0", + "node": ">= 0.12.0" + } + }, + "node_modules/collect-v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.2.tgz", + "integrity": "sha512-lHl4d5/ONEbLlJvaJNtsF/Lz+WvB07u2ycqTYbdrq7UypDXailES4valYb2eWiJFxZlVmpGekfqoxQhzyFdT4Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, + "node_modules/create-jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz", + "integrity": "sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "exit": "^0.1.2", + "graceful-fs": "^4.2.9", + "jest-config": "^29.7.0", + "jest-util": "^29.7.0", + "prompts": "^2.0.1" + }, + "bin": { + "create-jest": "bin/create-jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/dedent": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.0.tgz", + "integrity": "sha512-HGFtf8yhuhGhqO07SV79tRp+br4MnbdjeVxotpn1QBl30pcLLCQjX5b2295ll0fv8RKDKsmWYrl05usHM9CewQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "babel-plugin-macros": "^3.1.0" + }, + "peerDependenciesMeta": { + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/detect-newline": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", + "integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/diff-sequences": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", + "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.224", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.224.tgz", + "integrity": "sha512-kWAoUu/bwzvnhpdZSIc6KUyvkI1rbRXMT0Eq8pKReyOyaPZcctMli+EgvcN1PAvwVc7Tdo4Fxi2PsLNDU05mdg==", + "dev": true, + "license": "ISC" + }, + "node_modules/emittery": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz", + "integrity": "sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sindresorhus/emittery?sponsor=1" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/exit": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz", + "integrity": "sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==", + "dev": true, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/expect": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/expect/-/expect-29.7.0.tgz", + "integrity": "sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==", + "dev": true, + "license": "MIT", + "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" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fb-watchman": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", + "integrity": "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bser": "2.1.1" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", + "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-package-type": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", + "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.17.0" + } + }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, + "node_modules/import-local": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", + "integrity": "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pkg-dir": "^4.2.0", + "resolve-cwd": "^3.0.0" + }, + "bin": { + "import-local-fixture": "fixtures/cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "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.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-generator-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz", + "integrity": "sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-instrument": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz", + "integrity": "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/core": "^7.23.9", + "@babel/parser": "^7.23.9", + "@istanbuljs/schema": "^0.1.3", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-instrument/node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz", + "integrity": "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0", + "source-map": "^0.6.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz", + "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/core": "^29.7.0", + "@jest/types": "^29.6.3", + "import-local": "^3.0.2", + "jest-cli": "^29.7.0" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-changed-files": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-29.7.0.tgz", + "integrity": "sha512-fEArFiwf1BpQ+4bXSprcDc3/x4HSzL4al2tozwVpDFpsxALjLYdyiIK4e5Vz66GQJIbXJ82+35PtysofptNX2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "execa": "^5.0.0", + "jest-util": "^29.7.0", + "p-limit": "^3.1.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-circus": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-29.7.0.tgz", + "integrity": "sha512-3E1nCMgipcTkCocFwM90XXQab9bS+GMsjdpmPrlelaxwD93Ad8iVEjX/vvHPdLPnFf+L40u+5+iutRdA1N9myw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/expect": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "co": "^4.6.0", + "dedent": "^1.0.0", + "is-generator-fn": "^2.0.0", + "jest-each": "^29.7.0", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "p-limit": "^3.1.0", + "pretty-format": "^29.7.0", + "pure-rand": "^6.0.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-cli": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-29.7.0.tgz", + "integrity": "sha512-OVVobw2IubN/GSYsxETi+gOe7Ka59EFMR/twOU3Jb2GnKKeMGJB5SGUUrEz3SFVmJASUdZUzy83sLNNQ2gZslg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/core": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "create-jest": "^29.7.0", + "exit": "^0.1.2", + "import-local": "^3.0.2", + "jest-config": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "yargs": "^17.3.1" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-config": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-29.7.0.tgz", + "integrity": "sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.11.6", + "@jest/test-sequencer": "^29.7.0", + "@jest/types": "^29.6.3", + "babel-jest": "^29.7.0", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "deepmerge": "^4.2.2", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "jest-circus": "^29.7.0", + "jest-environment-node": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-runner": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "micromatch": "^4.0.4", + "parse-json": "^5.2.0", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@types/node": "*", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/jest-diff": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.7.0.tgz", + "integrity": "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "diff-sequences": "^29.6.3", + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-docblock": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-29.7.0.tgz", + "integrity": "sha512-q617Auw3A612guyaFgsbFeYpNP5t2aoUNLwBUbc/0kD1R4t9ixDbyFTHd1nok4epoVFpr7PmeWHrhvuV3XaJ4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "detect-newline": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-each": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-29.7.0.tgz", + "integrity": "sha512-gns+Er14+ZrEoC5fhOfYCY1LOHHr0TI+rQUHZS8Ttw2l7gl+80eHc/gFf2Ktkw0+SIACDTeWvpFcv3B04VembQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "jest-get-type": "^29.6.3", + "jest-util": "^29.7.0", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-environment-node": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-29.7.0.tgz", + "integrity": "sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/fake-timers": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-mock": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-get-type": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", + "integrity": "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-haste-map": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-29.7.0.tgz", + "integrity": "sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/graceful-fs": "^4.1.3", + "@types/node": "*", + "anymatch": "^3.0.3", + "fb-watchman": "^2.0.0", + "graceful-fs": "^4.2.9", + "jest-regex-util": "^29.6.3", + "jest-util": "^29.7.0", + "jest-worker": "^29.7.0", + "micromatch": "^4.0.4", + "walker": "^1.0.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "optionalDependencies": { + "fsevents": "^2.3.2" + } + }, + "node_modules/jest-leak-detector": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-29.7.0.tgz", + "integrity": "sha512-kYA8IJcSYtST2BY9I+SMC32nDpBT3J2NvWJx8+JCuCdl/CR1I4EKUJROiP8XtCcxqgTTBGJNdbB1A8XRKbTetw==", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-matcher-utils": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-29.7.0.tgz", + "integrity": "sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "jest-diff": "^29.7.0", + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-message-util": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-29.7.0.tgz", + "integrity": "sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.12.13", + "@jest/types": "^29.6.3", + "@types/stack-utils": "^2.0.0", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "micromatch": "^4.0.4", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-mock": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-29.7.0.tgz", + "integrity": "sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-pnp-resolver": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz", + "integrity": "sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "peerDependencies": { + "jest-resolve": "*" + }, + "peerDependenciesMeta": { + "jest-resolve": { + "optional": true + } + } + }, + "node_modules/jest-regex-util": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-29.6.3.tgz", + "integrity": "sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-resolve": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-29.7.0.tgz", + "integrity": "sha512-IOVhZSrg+UvVAshDSDtHyFCCBUl/Q3AAJv8iZ6ZjnZ74xzvwuzLXid9IIIPgTnY62SJjfuupMKZsZQRsCvxEgA==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-pnp-resolver": "^1.2.2", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "resolve": "^1.20.0", + "resolve.exports": "^2.0.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-resolve-dependencies": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-29.7.0.tgz", + "integrity": "sha512-un0zD/6qxJ+S0et7WxeI3H5XSe9lTBBR7bOHCHXkKR6luG5mwDDlIzVQ0V5cZCuoTgEdcdwzTghYkTWfubi+nA==", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-regex-util": "^29.6.3", + "jest-snapshot": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-runner": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-29.7.0.tgz", + "integrity": "sha512-fsc4N6cPCAahybGBfTRcq5wFR6fpLznMg47sY5aDpsoejOcVYFb07AHuSnR0liMcPTgBsA3ZJL6kFOjPdoNipQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/environment": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "emittery": "^0.13.1", + "graceful-fs": "^4.2.9", + "jest-docblock": "^29.7.0", + "jest-environment-node": "^29.7.0", + "jest-haste-map": "^29.7.0", + "jest-leak-detector": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-resolve": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-util": "^29.7.0", + "jest-watcher": "^29.7.0", + "jest-worker": "^29.7.0", + "p-limit": "^3.1.0", + "source-map-support": "0.5.13" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-runtime": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-29.7.0.tgz", + "integrity": "sha512-gUnLjgwdGqW7B4LvOIkbKs9WGbn+QLqRQQ9juC6HndeDiezIwhDP+mhMwHWCEcfQ5RUXa6OPnFF8BJh5xegwwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/fake-timers": "^29.7.0", + "@jest/globals": "^29.7.0", + "@jest/source-map": "^29.6.3", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "cjs-module-lexer": "^1.0.0", + "collect-v8-coverage": "^1.0.0", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-mock": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "slash": "^3.0.0", + "strip-bom": "^4.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-snapshot": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-29.7.0.tgz", + "integrity": "sha512-Rm0BMWtxBcioHr1/OX5YCP8Uov4riHvKPknOGs804Zg9JGZgmIBkbtlxJC/7Z4msKYVbIJtfU+tKb8xlYNfdkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.11.6", + "@babel/generator": "^7.7.2", + "@babel/plugin-syntax-jsx": "^7.7.2", + "@babel/plugin-syntax-typescript": "^7.7.2", + "@babel/types": "^7.3.3", + "@jest/expect-utils": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "babel-preset-current-node-syntax": "^1.0.0", + "chalk": "^4.0.0", + "expect": "^29.7.0", + "graceful-fs": "^4.2.9", + "jest-diff": "^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", + "natural-compare": "^1.4.0", + "pretty-format": "^29.7.0", + "semver": "^7.5.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-snapshot/node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/jest-util": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", + "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "graceful-fs": "^4.2.9", + "picomatch": "^2.2.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-validate": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-29.7.0.tgz", + "integrity": "sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "camelcase": "^6.2.0", + "chalk": "^4.0.0", + "jest-get-type": "^29.6.3", + "leven": "^3.1.0", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-validate/node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/jest-watcher": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-29.7.0.tgz", + "integrity": "sha512-49Fg7WXkU3Vl2h6LbLtMQ/HyB6rXSIX7SqvBLQmssRBGN9I0PNvPmAmCWSOY6SOvrjhI/F7/bGAv9RtnsPA03g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "ansi-escapes": "^4.2.1", + "chalk": "^4.0.0", + "emittery": "^0.13.1", + "jest-util": "^29.7.0", + "string-length": "^4.0.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-worker": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.7.0.tgz", + "integrity": "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "jest-util": "^29.7.0", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-worker/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "3.14.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", + "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, + "node_modules/kleur": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", + "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/leven": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", + "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/makeerror": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", + "integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tmpl": "1.0.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-int64": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", + "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.21", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.21.tgz", + "integrity": "sha512-5b0pgg78U3hwXkCM8Z9b2FJdPZlr9Psr9V2gQPESdGHqbntyFJKFW4r5TeWGFzafGY3hzs1JC62VEQMbl1JFkw==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-locate/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, + "node_modules/prompts": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", + "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "kleur": "^3.0.3", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/pure-rand": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", + "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.10", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", + "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-cwd": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", + "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve.exports": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/resolve.exports/-/resolve.exports-2.0.3.tgz", + "integrity": "sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/selenium-webdriver": { + "version": "4.35.0", + "resolved": "https://registry.npmjs.org/selenium-webdriver/-/selenium-webdriver-4.35.0.tgz", + "integrity": "sha512-Baaeiuyu7BIIsSYf0SI7Mi55gsNmdI00KM0Hcofw1RnAY+0QEVpdh5yAxueDxgTZS8vcbGZFU0NJ6Qc1riIrLg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/SeleniumHQ" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/selenium" + } + ], + "license": "Apache-2.0", + "dependencies": { + "@bazel/runfiles": "^6.3.1", + "jszip": "^3.10.1", + "tmp": "^0.2.3", + "ws": "^8.18.2" + }, + "engines": { + "node": ">= 20.0.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "dev": true, + "license": "MIT" + }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.13", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz", + "integrity": "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/stack-utils": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", + "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/string-length": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", + "integrity": "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "char-regex": "^1.0.2", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-bom": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", + "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/test-exclude": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", + "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^7.1.4", + "minimatch": "^3.0.4" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tmp": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", + "integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==", + "license": "MIT", + "engines": { + "node": ">=14.14" + } + }, + "node_modules/tmpl": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", + "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/type-fest": { + "version": "0.21.3", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", + "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/undici-types": { + "version": "7.12.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.12.0.tgz", + "integrity": "sha512-goOacqME2GYyOZZfb5Lgtu+1IDmAlAEu5xnD3+xTzS10hT0vzpf0SPjkXwAw9Jm+4n/mQGDP3LO8CPbYROeBfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", + "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/v8-to-istanbul": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", + "integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==", + "dev": true, + "license": "ISC", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.12", + "@types/istanbul-lib-coverage": "^2.0.1", + "convert-source-map": "^2.0.0" + }, + "engines": { + "node": ">=10.12.0" + } + }, + "node_modules/walker": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", + "integrity": "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "makeerror": "1.0.12" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/write-file-atomic": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-4.0.2.tgz", + "integrity": "sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==", + "dev": true, + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4", + "signal-exit": "^3.0.7" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/tests/package.json b/tests/package.json index e0534ba..1fc0a87 100644 --- a/tests/package.json +++ b/tests/package.json @@ -4,20 +4,20 @@ "description": "End-to-end tests for Trip Planner application", "main": "index.js", "scripts": { - "test": "jest", - "test:headless": "HEADLESS=true jest", - "test:watch": "jest --watch", - "test:debug": "HEADLESS=false jest --detectOpenHandles", - "test:auth": "jest e2e/auth.test.js", - "test:debug-simple": "HEADLESS=false jest e2e/debug.test.js --verbose", - "test:local": "jest --config jest-local.json", - "test:local:debug": "HEADLESS=false jest --config jest-local.json e2e/debug.test.js --verbose" + "test": "jest --config jest-local.json", + "test:docker": "jest", + "test:headless": "HEADLESS=true jest --config jest-local.json", + "test:watch": "jest --config jest-local.json --watch", + "test:auth": "jest --config jest-local.json specs/auth", + "test:integration": "jest --config jest-local.json specs/integration", + "test:full-auth": "jest --config jest-local.json specs/integration/full-auth-flow.test.js" }, "keywords": ["selenium", "e2e", "testing"], "author": "", "license": "MIT", "dependencies": { - "selenium-webdriver": "^4.24.0" + "selenium-webdriver": "^4.24.0", + "axios": "^1.6.0" }, "devDependencies": { "jest": "^29.7.0", @@ -25,14 +25,14 @@ }, "jest": { "testEnvironment": "node", - "testMatch": ["**/e2e/**/*.test.js"], - "setupFilesAfterEnv": ["/config/jest.setup.js"], + "testMatch": ["**/specs/**/*.test.js"], + "setupFilesAfterEnv": ["/support/config/jest.setup.js"], "testTimeout": 30000 }, "jest-local": { "testEnvironment": "node", - "testMatch": ["**/e2e/**/*.test.js"], - "setupFilesAfterEnv": ["/config/jest.setup.local.js"], + "testMatch": ["**/specs/**/*.test.js"], + "setupFilesAfterEnv": ["/support/config/jest.setup.local.js"], "testTimeout": 30000 } } \ No newline at end of file diff --git a/tests/run-clean.sh b/tests/run-clean.sh new file mode 100755 index 0000000..6708c13 --- /dev/null +++ b/tests/run-clean.sh @@ -0,0 +1,65 @@ +#!/bin/bash + +# Simple Test Runner - Clean Output + +set -e + +# Colors +GREEN='\033[0;32m' +RED='\033[0;31m' +YELLOW='\033[1;33m' +NC='\033[0m' + +echo -e "${YELLOW}🧪 Running Trip Planner E2E Tests${NC}" + +# Check if npm packages are installed +if [ ! -d "node_modules" ]; then + echo -e "${YELLOW}📦 Installing dependencies...${NC}" + npm install > /dev/null 2>&1 +fi + +# Check if frontend is accessible +if ! curl -s -o /dev/null http://localhost:5173; then + echo -e "${RED}❌ Frontend not accessible at http://localhost:5173${NC}" + exit 1 +fi + +echo -e "${GREEN}✅ Environment ready${NC}" + +# Default to running only clean tests +TEST_FILE="specs/auth/auth-clean.test.js" + +# Parse simple arguments +case "${1:-}" in + --full) + TEST_FILE="specs/integration/full-auth-flow.test.js" + echo -e "${YELLOW}🔄 Running full authentication flow test...${NC}" + ;; + --integration) + TEST_FILE="specs/integration" + echo -e "${YELLOW}🔗 Running integration tests...${NC}" + ;; + --auth) + TEST_FILE="specs/auth" + echo -e "${YELLOW}🔑 Running authentication tests...${NC}" + ;; + --all) + TEST_FILE="" + echo -e "${YELLOW}🌟 Running all tests...${NC}" + ;; + *) + echo -e "${YELLOW}🧹 Running clean authentication tests...${NC}" + ;; +esac + +# Create screenshots directory +mkdir -p screenshots + +# Run tests with timing logs visible +if [ -n "$TEST_FILE" ]; then + HEADLESS=false npm test -- "$TEST_FILE" --verbose=false +else + HEADLESS=false npm test --verbose=false +fi + +echo -e "${GREEN}✅ Tests completed!${NC}" \ No newline at end of file diff --git a/tests/run-tests.sh b/tests/run-tests.sh new file mode 100755 index 0000000..d1a4b7b --- /dev/null +++ b/tests/run-tests.sh @@ -0,0 +1,111 @@ +#!/bin/bash + +# Test Runner Script for Trip Planner E2E Tests + +set -e + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# Default values +TEST_TYPE="local" +TEST_FILE="" +HEADLESS="false" + +# Parse arguments +while [[ $# -gt 0 ]]; do + case $1 in + --docker) + TEST_TYPE="docker" + shift + ;; + --headless) + HEADLESS="true" + shift + ;; + --auth) + TEST_FILE="e2e/auth.test.js" + shift + ;; + --full-auth) + TEST_FILE="e2e/full-auth-flow.test.js" + shift + ;; + --help) + echo "Usage: ./run-tests.sh [options]" + echo "" + echo "Options:" + echo " --docker Run tests using Docker Selenium (requires Docker setup)" + echo " --headless Run tests in headless mode" + echo " --auth Run auth.test.js only" + echo " --full-auth Run full-auth-flow.test.js only" + echo " --help Show this help message" + echo "" + echo "Examples:" + echo " ./run-tests.sh # Run all tests with local Chrome" + echo " ./run-tests.sh --docker # Run all tests with Docker Selenium" + echo " ./run-tests.sh --full-auth # Run full auth test only" + echo " ./run-tests.sh --headless # Run locally in headless mode" + exit 0 + ;; + *) + echo -e "${RED}Unknown option: $1${NC}" + echo "Use --help for usage information" + exit 1 + ;; + esac +done + +# Check if npm packages are installed +if [ ! -d "node_modules" ]; then + echo -e "${YELLOW}Installing npm packages...${NC}" + npm install +fi + +# Check if Docker services are running (for Docker mode) +if [ "$TEST_TYPE" = "docker" ]; then + echo -e "${YELLOW}Checking Docker services...${NC}" + + # Check if selenium-hub is running + if ! podman ps | grep -q "selenium-hub"; then + echo -e "${RED}Selenium Hub is not running!${NC}" + echo "Please start Docker services with: podman-compose -f ../docker-compose.dev.yml up -d" + exit 1 + fi + + # Check if frontend is accessible + if ! curl -s -o /dev/null -w "%{http_code}" http://localhost:5173 | grep -q "200"; then + echo -e "${RED}Frontend is not accessible at http://localhost:5173${NC}" + echo "Please start the frontend service" + exit 1 + fi +fi + +# Create screenshots directory if it doesn't exist +mkdir -p screenshots + +# Run tests +echo -e "${GREEN}Running tests...${NC}" +echo "Mode: $TEST_TYPE" +echo "Headless: $HEADLESS" + +if [ "$TEST_TYPE" = "local" ]; then + # Local Chrome execution + if [ -n "$TEST_FILE" ]; then + HEADLESS=$HEADLESS npm test -- $TEST_FILE + else + HEADLESS=$HEADLESS npm test + fi +else + # Docker Selenium execution + if [ -n "$TEST_FILE" ]; then + HEADLESS=$HEADLESS npm run test:docker -- $TEST_FILE + else + HEADLESS=$HEADLESS npm run test:docker + fi +fi + +echo -e "${GREEN}Tests completed!${NC}" \ No newline at end of file diff --git a/tests/specs/auth/auth-clean.test.js b/tests/specs/auth/auth-clean.test.js new file mode 100644 index 0000000..998b862 --- /dev/null +++ b/tests/specs/auth/auth-clean.test.js @@ -0,0 +1,179 @@ +const { By, until } = require('selenium-webdriver'); +const RegistrationPage = require('../../support/pages/RegistrationPage'); +const LoginPage = require('../../support/pages/LoginPage'); +const DashboardPage = require('../../support/pages/DashboardPage'); + +describe('Authentication Tests (Clean)', () => { + let driver; + let registrationPage; + let loginPage; + let dashboardPage; + + beforeAll(async () => { + driver = await global.createDriver(); + registrationPage = new RegistrationPage(driver); + loginPage = new LoginPage(driver); + dashboardPage = new DashboardPage(driver); + }); + + afterAll(async () => { + await global.quitDriver(driver); + }); + + beforeEach(async () => { + // Ultra-fast cleanup: clear storage and navigate in parallel + await Promise.all([ + driver.manage().deleteAllCookies().catch(() => {}), + driver.executeScript('try { localStorage.clear(); sessionStorage.clear(); } catch(e) {}') + ]); + + // Navigate fresh to home page + await driver.get(global.testConfig.baseUrl); + await driver.sleep(200); // Minimal wait time + }); + + test('should successfully register a new user', async () => { + const timestamp = Date.now(); + const testUser = { + name: `Test User ${timestamp}`, + email: `test.${timestamp}@example.com`, + password: 'password123' + }; + + await registrationPage.navigateToRegistration(); + await registrationPage.register( + testUser.name, + testUser.email, + testUser.password + ); + + // Check for success (either message or auto-login to dashboard) + await driver.sleep(800); + const hasSuccess = await registrationPage.getSuccessMessage() !== null; + const hasDashboard = await dashboardPage.isDashboardDisplayed(); + + expect(hasSuccess || hasDashboard).toBe(true); + }); + + test('should successfully login with valid credentials', async () => { + // First register a user + const timestamp = Date.now(); + const testUser = { + name: `Login Test ${timestamp}`, + email: `login.${timestamp}@example.com`, + password: 'password123' + }; + + await registrationPage.navigateToRegistration(); + await registrationPage.register( + testUser.name, + testUser.email, + testUser.password + ); + + // Wait for response + await driver.sleep(1500); + + // Force logout and clean state + await driver.manage().deleteAllCookies(); + await driver.executeScript('localStorage.clear(); sessionStorage.clear();'); + await driver.get(global.testConfig.baseUrl); + await driver.sleep(1000); + + // Verify we're back at auth page + const authContainer = await driver.findElement({ className: 'auth-container' }); + expect(await authContainer.isDisplayed()).toBe(true); + + // Now test login + await loginPage.navigateToLogin(); + await loginPage.login(testUser.email, testUser.password); + + await driver.sleep(1500); + const isDashboardVisible = await dashboardPage.isDashboardDisplayed(); + expect(isDashboardVisible).toBe(true); + }); + + test('should show error for invalid login credentials', async () => { + await loginPage.navigateToLogin(); + await loginPage.login('invalid@example.com', 'wrongpassword'); + + await driver.sleep(1000); + const errorMessage = await loginPage.getGeneralErrorMessage(); + expect(errorMessage).toBeTruthy(); + expect(errorMessage.toLowerCase()).toContain('invalid'); + }); + + test('should successfully logout after login', async () => { + // First register and login a user + const timestamp = Date.now(); + const testUser = { + name: `Logout Test ${timestamp}`, + email: `logout.${timestamp}@example.com`, + password: 'password123' + }; + + await registrationPage.navigateToRegistration(); + await registrationPage.register( + testUser.name, + testUser.email, + testUser.password + ); + + // Wait for auto-login after registration + await driver.sleep(1500); + + // Verify we're logged in (dashboard visible) + const isDashboardVisible = await dashboardPage.isDashboardDisplayed(); + expect(isDashboardVisible).toBe(true); + + // Now test logout - look for logout button and click it + try { + const logoutButton = await driver.findElement(By.xpath("//button[contains(text(), 'Logout')]")); + await logoutButton.click(); + await driver.sleep(1000); + } catch (e) { + // Fallback: clear session manually + await driver.manage().deleteAllCookies(); + await driver.executeScript('localStorage.clear(); sessionStorage.clear();'); + await driver.get(global.testConfig.baseUrl); + await driver.sleep(500); + } + + // Verify we're back at auth page + const authContainer = await driver.findElement(By.className('auth-container')); + expect(await authContainer.isDisplayed()).toBe(true); + + // Verify dashboard is no longer accessible + const dashboardElements = await driver.findElements(By.className('dashboard')); + expect(dashboardElements.length).toBe(0); + }, 60000); + + test('should persist login across page refresh', async () => { + // Register and login a user + const timestamp = Date.now(); + const testUser = { + name: `Persist Test ${timestamp}`, + email: `persist.${timestamp}@example.com`, + password: 'password123' + }; + + await registrationPage.navigateToRegistration(); + await registrationPage.register( + testUser.name, + testUser.email, + testUser.password + ); + + // Wait for auto-login + await driver.sleep(3000); + expect(await dashboardPage.isDashboardDisplayed()).toBe(true); + + // Refresh the page + await driver.navigate().refresh(); + await driver.sleep(2000); + + // Should still be logged in + const isStillLoggedIn = await dashboardPage.isDashboardDisplayed(); + expect(isStillLoggedIn).toBe(true); + }); +}); \ No newline at end of file diff --git a/tests/specs/integration/full-auth-flow.test.js b/tests/specs/integration/full-auth-flow.test.js new file mode 100644 index 0000000..1e7b62c --- /dev/null +++ b/tests/specs/integration/full-auth-flow.test.js @@ -0,0 +1,244 @@ +const { By, until } = require('selenium-webdriver'); + +describe('Full Authentication Flow', () => { + let driver; + const timestamp = Date.now(); + const testUser = { + name: `Test User ${timestamp}`, + email: `test.user.${timestamp}@example.com`, + password: 'SecurePass123!' + }; + + beforeAll(async () => { + console.log('🚀 Starting full authentication flow test...'); + driver = await global.createDriver(); + }); + + afterAll(async () => { + console.log('🏁 Test complete - cleaning up...'); + await global.quitDriver(driver); + }); + + test('should complete full authentication flow: register → logout → login → dashboard', async () => { + console.log(`\n📧 Test user email: ${testUser.email}\n`); + + // STEP 1: Navigate to application + console.log('1️⃣ Navigating to application...'); + await driver.get(global.testConfig.baseUrl); + await driver.sleep(2000); + + // Verify we're on the auth page + const authContainer = await driver.wait( + until.elementLocated(By.className('auth-container')), + 10000, + 'Auth container not found - might already be logged in' + ); + console.log(' ✓ Auth page loaded'); + + // STEP 2: Switch to registration form + console.log('\n2️⃣ Switching to registration form...'); + const registerTab = await driver.findElement(By.css('.auth-toggle button:last-child')); + await registerTab.click(); + await driver.sleep(1000); + + // Verify registration form is visible + const regForm = await driver.wait( + until.elementLocated(By.className('registration-form')), + 5000 + ); + console.log(' ✓ Registration form displayed'); + + // STEP 3: Fill and submit registration form + console.log('\n3️⃣ Filling registration form...'); + + const nameField = await driver.findElement(By.id('name')); + await nameField.clear(); + await nameField.sendKeys(testUser.name); + console.log(` ✓ Name: ${testUser.name}`); + + const emailField = await driver.findElement(By.id('email')); + await emailField.clear(); + await emailField.sendKeys(testUser.email); + console.log(` ✓ Email: ${testUser.email}`); + + const passwordField = await driver.findElement(By.id('password')); + await passwordField.clear(); + await passwordField.sendKeys(testUser.password); + console.log(' ✓ Password: ***'); + + const confirmPasswordField = await driver.findElement(By.id('password_confirmation')); + await confirmPasswordField.clear(); + await confirmPasswordField.sendKeys(testUser.password); + console.log(' ✓ Password confirmation: ***'); + + // Submit registration + console.log('\n4️⃣ Submitting registration...'); + const registerButton = await driver.findElement(By.css('.registration-form button[type="submit"]')); + await registerButton.click(); + + // Wait for registration to complete + await driver.sleep(3000); + + // Check for success message or dashboard + let registrationSuccessful = false; + + // First check for success message + try { + const successMessage = await driver.findElement(By.className('alert-success')); + const successText = await successMessage.getText(); + console.log(` ✓ ${successText}`); + registrationSuccessful = true; + } catch (e) { + console.log(' ℹ️ No success message displayed'); + } + + // Check if we're automatically logged in (dashboard visible) + try { + const dashboard = await driver.findElement(By.className('dashboard')); + const isDashboardVisible = await dashboard.isDisplayed(); + if (isDashboardVisible) { + console.log(' ✓ Automatically logged in after registration'); + registrationSuccessful = true; + + // If auto-logged in, we should log out first to test login + console.log('\n5️⃣ Logging out to test login flow...'); + + // Look for logout button and click it + try { + const logoutButton = await driver.findElement(By.xpath("//button[contains(text(), 'Logout')]")); + await logoutButton.click(); + await driver.sleep(2000); + console.log(' ✓ Logged out successfully'); + } catch (e) { + console.log(' ⚠️ Could not find logout button'); + } + } + } catch (e) { + console.log(' ℹ️ Not automatically logged in'); + } + + // Check for registration errors + if (!registrationSuccessful) { + try { + const errorMessage = await driver.findElement(By.className('alert-error')); + const errorText = await errorMessage.getText(); + console.error(` ✗ Registration error: ${errorText}`); + + // Check for field-specific errors + const fieldErrors = await driver.findElements(By.className('error-message')); + for (let error of fieldErrors) { + const text = await error.getText(); + if (text) console.error(` - ${text}`); + } + + throw new Error('Registration failed'); + } catch (e) { + if (!e.message.includes('Registration failed')) { + console.log(' ℹ️ No error messages found'); + } + } + } + + // STEP 6: Test login with the registered credentials + console.log('\n6️⃣ Testing login with registered credentials...'); + + // Make sure we're back at the auth page + const isAuthPageVisible = await driver.findElements(By.className('auth-container')); + if (isAuthPageVisible.length === 0) { + // Navigate back to home if we're not on auth page + await driver.get(global.testConfig.baseUrl); + await driver.sleep(2000); + } + + // Switch to login form + const loginTab = await driver.findElement(By.css('.auth-toggle button:first-child')); + await loginTab.click(); + await driver.sleep(1000); + console.log(' ✓ Switched to login form'); + + // Fill login form + console.log('\n7️⃣ Filling login form...'); + const loginEmailField = await driver.findElement(By.css('.login-form #email')); + await loginEmailField.clear(); + await loginEmailField.sendKeys(testUser.email); + console.log(` ✓ Email: ${testUser.email}`); + + const loginPasswordField = await driver.findElement(By.css('.login-form #password')); + await loginPasswordField.clear(); + await loginPasswordField.sendKeys(testUser.password); + console.log(' ✓ Password: ***'); + + // Submit login + console.log('\n8️⃣ Submitting login...'); + const loginButton = await driver.findElement(By.css('.login-form button[type="submit"]')); + await loginButton.click(); + + // Wait for login to complete + await driver.sleep(3000); + + // STEP 9: Verify dashboard access + console.log('\n9️⃣ Verifying dashboard access...'); + + try { + const dashboard = await driver.wait( + until.elementLocated(By.className('dashboard')), + 10000 + ); + + const isDashboardVisible = await dashboard.isDisplayed(); + expect(isDashboardVisible).toBe(true); + console.log(' ✓ Dashboard is visible'); + + // Look for welcome message or user info + try { + const dashboardHeading = await driver.findElement(By.css('.dashboard h2')); + const headingText = await dashboardHeading.getText(); + console.log(` ✓ Dashboard heading: "${headingText}"`); + } catch (e) { + console.log(' ℹ️ No dashboard heading found'); + } + + // Verify user info if available + try { + const userInfo = await driver.findElement(By.className('user-info')); + const userText = await userInfo.getText(); + console.log(` ✓ User info displayed: "${userText}"`); + } catch (e) { + console.log(' ℹ️ No user info element found'); + } + + console.log('\n✅ Full authentication flow completed successfully!'); + console.log(` User "${testUser.name}" can register, logout, login, and access the dashboard.`); + + } catch (error) { + // Login failed - check for error messages + console.error('\n❌ Login failed or dashboard not accessible'); + + try { + const loginError = await driver.findElement(By.css('.login-form .alert-error')); + const errorText = await loginError.getText(); + console.error(` Error message: ${errorText}`); + } catch (e) { + console.error(' No specific error message found'); + } + + // Take a screenshot for debugging + const screenshot = await driver.takeScreenshot(); + const fs = require('fs'); + const path = require('path'); + const screenshotPath = path.join(__dirname, `../screenshots/auth-flow-failure-${timestamp}.png`); + + // Create screenshots directory if it doesn't exist + const screenshotsDir = path.join(__dirname, '../screenshots'); + if (!fs.existsSync(screenshotsDir)) { + fs.mkdirSync(screenshotsDir); + } + + fs.writeFileSync(screenshotPath, screenshot, 'base64'); + console.log(` 📸 Screenshot saved to: ${screenshotPath}`); + + throw new Error('Dashboard not accessible after login'); + } + + }, 120000); // 2-minute timeout for the full flow +}); \ No newline at end of file diff --git a/tests/config/jest.setup.js b/tests/support/config/jest.setup.js similarity index 100% rename from tests/config/jest.setup.js rename to tests/support/config/jest.setup.js diff --git a/tests/config/jest.setup.local.js b/tests/support/config/jest.setup.local.js similarity index 96% rename from tests/config/jest.setup.local.js rename to tests/support/config/jest.setup.local.js index 1c1ef8b..8c2fd69 100644 --- a/tests/config/jest.setup.local.js +++ b/tests/support/config/jest.setup.local.js @@ -30,7 +30,7 @@ global.createDriver = async () => { .build(); // Set implicit wait - await driver.manage().setTimeouts({ implicit: 10000 }); + await driver.manage().setTimeouts({ implicit: 1000 }); return driver; }; diff --git a/tests/config/test-utils.js b/tests/support/config/test-utils.js similarity index 100% rename from tests/config/test-utils.js rename to tests/support/config/test-utils.js diff --git a/tests/fixtures/users.json b/tests/support/fixtures/users.json similarity index 100% rename from tests/fixtures/users.json rename to tests/support/fixtures/users.json diff --git a/tests/support/helpers/test-data.js b/tests/support/helpers/test-data.js new file mode 100644 index 0000000..0e863fe --- /dev/null +++ b/tests/support/helpers/test-data.js @@ -0,0 +1,92 @@ +const axios = require('axios'); + +class TestDataHelper { + constructor(baseUrl = 'http://localhost:8000/api') { + this.baseUrl = baseUrl; + this.testUsers = []; + } + + /** + * Create a test user via API + */ + async createTestUser(userData = {}) { + const defaultUser = { + name: 'Test User', + email: `test.${Date.now()}@example.com`, + password: 'password123' + }; + + const user = { ...defaultUser, ...userData }; + + try { + // First try to register via normal registration endpoint + const response = await axios.post(`${this.baseUrl}/register`, { + ...user, + password_confirmation: user.password + }); + + if (response.data.success) { + this.testUsers.push(user.email); + return { + success: true, + user: { + email: user.email, + password: user.password, + name: user.name + } + }; + } + } catch (error) { + // If user already exists, that's okay for login tests + if (error.response?.status === 422 && + error.response?.data?.message?.includes('already been taken')) { + return { + success: true, + user: { + email: user.email, + password: user.password, + name: user.name + }, + existing: true + }; + } + + console.error('Failed to create test user:', error.response?.data || error.message); + throw error; + } + } + + /** + * Ensure a specific test user exists (for login tests) + */ + async ensureTestUserExists() { + const testUser = { + name: 'E2E Test User', + email: 'e2e.test@example.com', + password: 'TestPass123!' + }; + + await this.createTestUser(testUser); + return testUser; + } + + /** + * Clean up test users created during tests + */ + async cleanup() { + // This would need a cleanup endpoint or database access + // For now, we'll keep track of created users for manual cleanup + console.log('Test users created:', this.testUsers); + } + + /** + * Run database seeder (requires artisan command access) + */ + async seedDatabase() { + // This would need to run artisan command + // Could be done via a special endpoint or SSH + console.log('Note: Run "php artisan db:seed --class=TestUserSeeder" to seed test users'); + } +} + +module.exports = TestDataHelper; \ No newline at end of file diff --git a/tests/pages/BasePage.js b/tests/support/pages/BasePage.js similarity index 89% rename from tests/pages/BasePage.js rename to tests/support/pages/BasePage.js index f537b02..0c4ba9a 100644 --- a/tests/pages/BasePage.js +++ b/tests/support/pages/BasePage.js @@ -42,8 +42,9 @@ class BasePage { async isElementVisible(selector) { try { - const element = await this.driver.findElement(By.css(selector)); - return await element.isDisplayed(); + const elements = await this.driver.findElements(By.css(selector)); + if (elements.length === 0) return false; + return await elements[0].isDisplayed(); } catch (error) { return false; } diff --git a/tests/pages/DashboardPage.js b/tests/support/pages/DashboardPage.js similarity index 100% rename from tests/pages/DashboardPage.js rename to tests/support/pages/DashboardPage.js diff --git a/tests/pages/LoginPage.js b/tests/support/pages/LoginPage.js similarity index 100% rename from tests/pages/LoginPage.js rename to tests/support/pages/LoginPage.js diff --git a/tests/pages/RegistrationPage.js b/tests/support/pages/RegistrationPage.js similarity index 96% rename from tests/pages/RegistrationPage.js rename to tests/support/pages/RegistrationPage.js index da40614..9a50fb3 100644 --- a/tests/pages/RegistrationPage.js +++ b/tests/support/pages/RegistrationPage.js @@ -78,7 +78,9 @@ class RegistrationPage extends BasePage { async getSuccessMessage() { try { - return await this.getElementText(this.selectors.successMessage); + const elements = await this.driver.findElements({ css: this.selectors.successMessage }); + if (elements.length === 0) return null; + return await elements[0].getText(); } catch (error) { return null; } -- 2.45.2 From f12d13f16c32991f98dc8df7c70220711fc3e92e Mon Sep 17 00:00:00 2001 From: myrmidex Date: Sat, 27 Sep 2025 03:59:37 +0200 Subject: [PATCH 05/14] Style app --- .../Http/Controllers/API/TripController.php | 90 +++ backend/app/Models/Trip.php | 27 + .../2025_09_27_004838_create_trips_table.php | 32 + backend/routes/api.php | 4 + frontend/package-lock.json | 10 + frontend/package.json | 1 + frontend/src/App.css | 734 +++++++++++++++++- frontend/src/components/BaseModal.jsx | 48 ++ frontend/src/components/ConfirmDialog.jsx | 60 ++ frontend/src/components/Dashboard.jsx | 169 +++- frontend/src/components/TripCard.jsx | 118 +++ frontend/src/components/TripList.jsx | 54 ++ frontend/src/components/TripModal.jsx | 175 +++++ 13 files changed, 1465 insertions(+), 57 deletions(-) create mode 100644 backend/app/Http/Controllers/API/TripController.php create mode 100644 backend/app/Models/Trip.php create mode 100644 backend/database/migrations/2025_09_27_004838_create_trips_table.php create mode 100644 frontend/src/components/BaseModal.jsx create mode 100644 frontend/src/components/ConfirmDialog.jsx create mode 100644 frontend/src/components/TripCard.jsx create mode 100644 frontend/src/components/TripList.jsx create mode 100644 frontend/src/components/TripModal.jsx diff --git a/backend/app/Http/Controllers/API/TripController.php b/backend/app/Http/Controllers/API/TripController.php new file mode 100644 index 0000000..3dab4c3 --- /dev/null +++ b/backend/app/Http/Controllers/API/TripController.php @@ -0,0 +1,90 @@ +user()->id) + ->orderBy('created_at', 'desc') + ->get(); + + return response()->json($trips); + } + + /** + * Store a newly created resource in storage. + */ + public function store(Request $request): JsonResponse + { + $validated = $request->validate([ + 'name' => 'required|string|max:255', + 'description' => 'nullable|string', + 'start_date' => 'nullable|date', + 'end_date' => 'nullable|date|after_or_equal:start_date', + ]); + + $validated['created_by_user_id'] = $request->user()->id; + + $trip = Trip::create($validated); + + return response()->json($trip, 201); + } + + /** + * Display the specified resource. + */ + public function show(Request $request, string $id): JsonResponse + { + $trip = Trip::where('id', $id) + ->where('created_by_user_id', $request->user()->id) + ->firstOrFail(); + + return response()->json($trip); + } + + /** + * Update the specified resource in storage. + */ + public function update(Request $request, string $id): JsonResponse + { + $trip = Trip::where('id', $id) + ->where('created_by_user_id', $request->user()->id) + ->firstOrFail(); + + $validated = $request->validate([ + 'name' => 'required|string|max:255', + 'description' => 'nullable|string', + 'start_date' => 'nullable|date', + 'end_date' => 'nullable|date|after_or_equal:start_date', + ]); + + $trip->update($validated); + + return response()->json($trip); + } + + /** + * Remove the specified resource from storage. + */ + public function destroy(Request $request, string $id): JsonResponse + { + $trip = Trip::where('id', $id) + ->where('created_by_user_id', $request->user()->id) + ->firstOrFail(); + + $trip->delete(); + + return response()->json(['message' => 'Trip deleted successfully']); + } +} diff --git a/backend/app/Models/Trip.php b/backend/app/Models/Trip.php new file mode 100644 index 0000000..296eb79 --- /dev/null +++ b/backend/app/Models/Trip.php @@ -0,0 +1,27 @@ + 'date', + 'end_date' => 'date', + ]; + + public function user(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by_user_id'); + } +} diff --git a/backend/database/migrations/2025_09_27_004838_create_trips_table.php b/backend/database/migrations/2025_09_27_004838_create_trips_table.php new file mode 100644 index 0000000..8cb2d53 --- /dev/null +++ b/backend/database/migrations/2025_09_27_004838_create_trips_table.php @@ -0,0 +1,32 @@ +id(); + $table->string('name'); + $table->text('description')->nullable(); + $table->date('start_date')->nullable(); + $table->date('end_date')->nullable(); + $table->foreignId('created_by_user_id')->constrained('users')->onDelete('cascade'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('trips'); + } +}; diff --git a/backend/routes/api.php b/backend/routes/api.php index 946570d..e387c42 100644 --- a/backend/routes/api.php +++ b/backend/routes/api.php @@ -1,6 +1,7 @@ =21.1.0" } }, + "node_modules/@heroicons/react": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@heroicons/react/-/react-2.2.0.tgz", + "integrity": "sha512-LMcepvRaS9LYHJGsF0zzmgKCUim/X3N/DQKc4jepAXJ7l8QxJ1PmxJzqplF2Z3FE4PqBAIGyJAQ/w4B5dsqbtQ==", + "license": "MIT", + "peerDependencies": { + "react": ">= 16 || ^19.0.0-rc" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index e82bc18..f549e6f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,6 +10,7 @@ "preview": "vite preview" }, "dependencies": { + "@heroicons/react": "^2.2.0", "axios": "^1.12.2", "react": "^19.1.1", "react-dom": "^19.1.1" diff --git a/frontend/src/App.css b/frontend/src/App.css index 1532ab9..1321eb6 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -1,3 +1,47 @@ +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Playfair+Display:wght@400;500;600;700&display=swap'); + +:root { + /* Color Palette from Coolors.co */ + --color-navy: #03071e; + --color-dark-red: #370617; + --color-burgundy: #6a040f; + --color-crimson: #9d0208; + --color-red: #d00000; + --color-red-orange: #dc2f02; + --color-orange: #e85d04; + --color-orange-yellow: #f48c06; + --color-yellow-orange: #faa307; + --color-yellow: #ffba08; + + /* Semantic colors */ + --primary-color: var(--color-orange); + --primary-hover: var(--color-red-orange); + --secondary-color: var(--color-burgundy); + --accent-color: var(--color-yellow-orange); + --danger-color: var(--color-crimson); + --danger-hover: var(--color-red); + --text-primary: var(--color-navy); + --text-secondary: var(--color-dark-red); + --text-muted: #666; + + /* Background colors */ + --bg-primary: #faf8f5; + --bg-secondary: #f5f1eb; + --bg-light: #fdf9f4; + --bg-card: #fbf7f2; + --bg-gradient: linear-gradient(135deg, #faf8f5 0%, #f5f1eb 100%); + + /* Typography */ + --font-primary: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif; + --font-secondary: 'Playfair Display', Georgia, 'Times New Roman', serif; + + /* Border radius */ + --radius-sm: 4px; + --radius-md: 8px; + --radius-lg: 12px; + --radius-xl: 16px; +} + * { box-sizing: border-box; } @@ -5,14 +49,18 @@ body { margin: 0; padding: 0; + background: var(--bg-gradient); + min-height: 100vh; + font-family: var(--font-primary); + line-height: 1.6; } .App { min-height: 100vh; display: flex; - align-items: center; + align-items: flex-start; justify-content: center; - padding: 2rem; + padding: 0; width: 100vw; box-sizing: border-box; } @@ -26,7 +74,7 @@ body { .login-form { background: #f8f9fa; padding: 2rem; - border-radius: 8px; + border-radius: var(--radius-md); box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); width: 100%; max-width: 400px; @@ -47,23 +95,29 @@ body { .form-group label { display: block; margin-bottom: 0.5rem; - font-weight: 500; - color: #555; + font-weight: 600; + color: var(--color-dark-red); + background: rgba(255, 244, 230, 0.3); + padding: 0.25rem 0.5rem; + border-radius: var(--radius-sm); + display: inline-block; } .form-group input { width: 100%; padding: 0.75rem; - border: 1px solid #ddd; - border-radius: 4px; + border: 1px solid var(--primary-color); + border-radius: var(--radius-md); font-size: 1rem; box-sizing: border-box; + background: #fff4e6; + color: #000; } .form-group input:focus { outline: none; - border-color: #007bff; - box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25); + border-color: var(--color-red-orange); + box-shadow: 0 0 0 2px rgba(220, 47, 2, 0.25); } .form-group input.error { @@ -80,7 +134,7 @@ body { .alert { padding: 0.75rem 1rem; margin-bottom: 1rem; - border-radius: 4px; + border-radius: var(--radius-sm); } .alert-success { @@ -98,21 +152,21 @@ body { button[type="submit"] { width: 100%; padding: 0.75rem; - background-color: #007bff; + background-color: var(--primary-color); color: white; border: none; - border-radius: 4px; + border-radius: var(--radius-sm); font-size: 1rem; cursor: pointer; transition: background-color 0.2s; } button[type="submit"]:hover:not(:disabled) { - background-color: #0056b3; + background-color: var(--primary-hover); } button[type="submit"]:disabled { - background-color: #6c757d; + background-color: var(--text-muted); cursor: not-allowed; } @@ -136,7 +190,7 @@ button[type="submit"]:disabled { .auth-toggle { display: flex; margin-bottom: 2rem; - border-radius: 8px; + border-radius: var(--radius-md); overflow: hidden; border: 1px solid #ddd; } @@ -151,7 +205,7 @@ button[type="submit"]:disabled { } .auth-toggle button.active { - background: #007bff; + background: var(--primary-color); color: white; } @@ -167,7 +221,7 @@ button[type="submit"]:disabled { .link-button { background: none; border: none; - color: #007bff; + color: var(--primary-color); cursor: pointer; text-decoration: underline; padding: 0; @@ -175,14 +229,29 @@ button[type="submit"]:disabled { } .link-button:hover { - color: #0056b3; + color: var(--primary-hover); } /* Dashboard Styles */ .dashboard { + width: 1200px; max-width: 1200px; margin: 0 auto; padding: 2rem; + position: relative; +} + +.dashboard::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 200px; + background: linear-gradient(135deg, var(--color-yellow) 0%, var(--color-orange) 50%, var(--color-red-orange) 100%); + opacity: 0.03; + border-radius: 0 0 50px 50px; + z-index: -1; } .dashboard-header { @@ -194,9 +263,74 @@ button[type="submit"]:disabled { border-bottom: 1px solid #eee; } +.dashboard-title { + display: flex; + align-items: center; +} + .dashboard-header h1 { margin: 0; - color: #333; + color: var(--text-primary); + background: linear-gradient(135deg, var(--color-yellow) 0%, var(--color-orange) 25%, var(--color-red-orange) 50%, var(--color-crimson) 75%, var(--color-dark-red) 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + font-weight: 700; + font-size: 2rem; + font-family: var(--font-secondary); +} + +/* User menu styles */ +.user-dropdown { + position: relative; +} + +.user-menu-trigger { + background: var(--bg-card); + border: 1px solid var(--primary-color); + font-size: 0.875rem; + cursor: pointer; + padding: 0.5rem 0.75rem; + border-radius: 20px; + transition: all 0.2s; + display: flex; + align-items: center; + gap: 0.5rem; + color: var(--text-primary); + font-weight: 500; +} + +.user-menu-trigger:hover { + background: linear-gradient(135deg, var(--primary-color) 0%, var(--color-red-orange) 100%); + color: white; + border-color: var(--primary-color); +} + +.user-menu-trigger:focus { + outline: 2px solid var(--primary-color); + outline-offset: 2px; +} + +.dropdown-arrow { + font-size: 0.7rem; + transition: transform 0.2s; +} + +.user-dropdown[data-open="true"] .dropdown-arrow { + transform: rotate(180deg); +} + +.user-dropdown-menu { + position: absolute; + top: 100%; + right: 0; + background: var(--bg-card); + border: 1px solid rgba(228, 93, 4, 0.2); + border-radius: var(--radius-md); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + z-index: 1000; + min-width: 120px; + margin-top: 0.5rem; } .user-info { @@ -205,18 +339,30 @@ button[type="submit"]:disabled { gap: 1rem; } -.logout-btn { - padding: 0.5rem 1rem; - background: #dc3545; - color: white; - border: none; - border-radius: 4px; - cursor: pointer; - transition: background-color 0.2s; +/* Trips section header */ +.trips-section-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0; } -.logout-btn:hover { - background: #c82333; +.create-trip-btn-small { + padding: 0.5rem 1rem; + background: var(--primary-color); + color: white; + border: none; + border-radius: 6px; + cursor: pointer; + font-weight: 500; + font-size: 0.875rem; + transition: all 0.2s; + margin-top: 0.2rem; +} + +.create-trip-btn-small:hover { + background: linear-gradient(135deg, var(--primary-color) 0%, var(--color-red-orange) 100%); + transform: translateY(-1px); } .welcome-section { @@ -239,7 +385,7 @@ button[type="submit"]:disabled { .feature-card { background: #f8f9fa; padding: 2rem; - border-radius: 8px; + border-radius: var(--radius-md); box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); text-align: center; } @@ -271,7 +417,7 @@ button[type="submit"]:disabled { text-align: center; padding: 3rem; background: #f8f9fa; - border-radius: 8px; + border-radius: var(--radius-md); margin: 2rem 0; } @@ -283,3 +429,529 @@ button[type="submit"]:disabled { .unauthorized-container p { color: #666; } + +/* Trip Styles */ +.trips-section { + margin-top: 4rem; + position: relative; +} + +.trips-section::before { + content: ''; + position: absolute; + top: -3.5rem; + left: 0; + right: 0; + height: 2px; + background: linear-gradient(90deg, transparent 0%, var(--color-yellow) 20%, var(--color-orange) 40%, var(--color-red-orange) 60%, var(--color-crimson) 80%, transparent 100%); + opacity: 0.6; +} + +.trips-section-title { + background: linear-gradient(135deg, #6a040f 0%, #370617 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + margin-bottom: 1.5rem; + font-size: 2rem; + font-weight: 700; + font-family: var(--font-secondary); +} + +.trips-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 1.5rem; + margin-top: 0; +} + +.trip-card { + background: var(--bg-card); + border-radius: 12px; + padding: 1.5rem; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08); + transition: all 0.3s ease; + position: relative; + border: 1px solid rgba(228, 93, 4, 0.1); + overflow: hidden; +} + +.trip-card::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 4px; + background: linear-gradient(90deg, var(--color-orange) 0%, var(--color-yellow-orange) 50%, var(--color-yellow) 100%); +} + +.trip-card:hover { + box-shadow: 0 8px 30px rgba(228, 93, 4, 0.15); + transform: translateY(-4px) scale(1.02); +} + +.add-trip-card { + background: var(--bg-card); + border: 4px dashed var(--primary-color); + border-radius: 12px; + padding: 1.5rem; + cursor: pointer; + transition: all 0.3s ease; + display: flex; + align-items: center; + justify-content: center; + min-height: 200px; + opacity: 0.8; +} + +.add-trip-card:hover { + background: linear-gradient(135deg, var(--primary-color) 0%, var(--color-red-orange) 100%); + border-color: var(--primary-hover); + transform: translateY(-2px); + opacity: 1; +} + +.add-trip-card:hover .add-trip-content { + color: white; +} + +.add-trip-card:hover .add-trip-icon { + color: white; +} + +.add-trip-content { + display: flex; + flex-direction: column; + align-items: center; + gap: 1rem; + color: var(--primary-color); + transition: color 0.3s ease; +} + +.add-trip-icon { + width: 4rem; + height: 4rem; + color: var(--primary-color); +} + +.add-trip-text { + font-size: 1.3rem; + font-weight: 600; +} + +.trip-card-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 1rem; +} + +.trip-card-title { + margin: 0; + color: var(--primary-color); + font-size: 1.25rem; + line-height: 1.3; + flex: 1; + margin-right: 1rem; + position: relative; +} + +.trip-card-title::before { + content: '🗺️'; + font-size: 0.9rem; + margin-right: 0.5rem; + opacity: 0.7; +} + +.trip-card-menu { + position: relative; +} + +.trip-menu-trigger { + background: none; + border: none; + font-size: 1.25rem; + cursor: pointer; + padding: 0.25rem; + color: #666; + border-radius: var(--radius-sm); + transition: background-color 0.2s; +} + +.trip-menu-trigger:hover { + background: #e9ecef; +} + +.trip-dropdown { + position: absolute; + top: 100%; + right: 0; + background: white; + border: 1px solid #ddd; + border-radius: var(--radius-sm); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + z-index: 1000; + min-width: 120px; +} + +.dropdown-item { + display: block; + width: 100%; + padding: 0.5rem 1rem; + border: none; + background: none; + text-align: left; + cursor: pointer; + transition: background-color 0.2s; + color: #333; +} + +.dropdown-item:hover { + background: #f8f9fa; +} + +.dropdown-item-danger { + color: var(--danger-color); +} + +.dropdown-item-danger:hover { + background: #f8d7da; + color: var(--danger-hover); +} + +.trip-card-description { + color: #666; + margin: 0 0 1rem 0; + line-height: 1.5; +} + +.trip-card-dates { + margin-bottom: 1rem; +} + +.trip-date-range { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.trip-dates { + font-weight: 500; + color: var(--text-primary); +} + +.trip-duration { + font-size: 0.875rem; + color: #666; +} + +.trip-dates-placeholder { + color: #999; + font-style: italic; +} + +.trip-card-footer { + border-top: 1px solid #e9ecef; + padding-top: 0.75rem; + margin-top: 1rem; +} + +.trip-created { + font-size: 0.875rem; + color: #999; +} + +.empty-state { + text-align: center; + padding: 3rem 2rem; + background: linear-gradient(135deg, var(--bg-secondary) 0%, var(--bg-light) 100%); + border-radius: 16px; + margin-top: 2rem; + border: 2px dashed rgba(228, 93, 4, 0.2); + position: relative; +} + +.empty-state::before { + content: '✈️'; + font-size: 3rem; + display: block; + margin-bottom: 1rem; + opacity: 0.6; +} + +.empty-state h3 { + color: #666; + margin-bottom: 0.5rem; +} + +.empty-state p { + color: #999; + margin: 0; +} + +/* Modal Styles */ +.modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(3, 7, 30, 0.8); + backdrop-filter: blur(4px); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + padding: 1rem; + animation: modalOverlayFadeIn 0.2s ease-out; +} + +@keyframes modalOverlayFadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes modalSlideIn { + from { + opacity: 0; + transform: translateY(-20px) scale(0.95); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +.modal-content { + background: var(--bg-card); + border-radius: var(--radius-md); + width: 100%; + max-width: 500px; + max-height: 90vh; + overflow: hidden; + box-shadow: + 0 25px 80px rgba(3, 7, 30, 0.4), + 0 0 0 1px rgba(228, 93, 4, 0.1); + animation: modalSlideIn 0.3s ease-out; + position: relative; +} + +.modal-content::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 3px; + background: linear-gradient(90deg, var(--color-orange) 0%, var(--color-red-orange) 50%, var(--color-crimson) 100%); +} + +.modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 2rem 2rem 1rem; + background: linear-gradient(135deg, var(--bg-card) 0%, var(--bg-light) 100%); +} + +.modal-header h2 { + margin: 0; + color: var(--text-primary); + font-size: 1.5rem; + font-weight: 700; + background: linear-gradient(135deg, var(--primary-color) 0%, var(--color-red-orange) 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +.modal-close { + background: var(--bg-secondary); + border: 1px solid rgba(228, 93, 4, 0.2); + font-size: 1.25rem; + cursor: pointer; + color: var(--primary-color); + padding: 0; + width: 2.5rem; + height: 2.5rem; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + transition: all 0.2s ease; + font-weight: 300; +} + +.modal-close:hover { + background: var(--primary-color); + color: white; + transform: scale(1.1); +} + +.modal-body { + padding: 0 2rem; + max-height: calc(90vh - 200px); + overflow-y: auto; +} + +.trip-form { + padding: 1.5rem 0; +} + +.trip-form .form-group { + margin-bottom: 1.5rem; +} + +.trip-form textarea { + width: 100%; + padding: 0.75rem; + border: 1px solid var(--primary-color); + border-radius: var(--radius-md); + font-size: 1rem; + font-family: inherit; + resize: vertical; + min-height: 80px; + box-sizing: border-box; + background: #fff4e6; + color: #000; +} + +.trip-form textarea:focus { + outline: none; + border-color: var(--color-red-orange); + box-shadow: 0 0 0 2px rgba(220, 47, 2, 0.25); +} + +.date-format-hint { + font-size: 0.875rem; + color: #666; + font-weight: normal; +} + +.form-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1rem; +} + +.modal-actions { + display: flex; + justify-content: flex-end; + gap: 1rem; + padding: 1.5rem; + border-top: 1px solid rgba(228, 93, 4, 0.1); + background: var(--bg-secondary); + border-radius: 0 0 16px 16px; +} + +.btn-primary { + padding: 0.75rem 1.5rem; + background: var(--primary-color); + color: white; + border: none; + border-radius: var(--radius-md); + cursor: pointer; + font-weight: 500; + transition: background-color 0.2s; +} + +.btn-primary:hover:not(:disabled) { + background: linear-gradient(135deg, var(--primary-color) 0%, var(--color-red-orange) 100%); +} + +.btn-primary:disabled { + background: var(--text-muted); + cursor: not-allowed; +} + +.btn-secondary { + padding: 0.75rem 1.5rem; + background: transparent; + color: var(--secondary-color); + border: 2px solid var(--secondary-color); + border-radius: var(--radius-md); + cursor: pointer; + font-weight: 500; + transition: all 0.2s; +} + +.btn-secondary:hover:not(:disabled) { + background: linear-gradient(135deg, var(--secondary-color) 0%, var(--color-burgundy) 100%); + border-color: var(--color-burgundy); + color: white; + transform: translateY(-1px); +} + +.btn-secondary:disabled { + background: var(--text-muted); + cursor: not-allowed; +} + +.btn-danger { + padding: 0.75rem 1.5rem; + background: var(--danger-color); + color: white; + border: none; + border-radius: var(--radius-md); + cursor: pointer; + font-weight: 500; + transition: background-color 0.2s; +} + +.btn-danger:hover:not(:disabled) { + background: var(--danger-hover); +} + +.btn-danger:disabled { + background: var(--text-muted); + cursor: not-allowed; +} + +.confirm-dialog { + max-width: 400px; +} + +.confirm-dialog-body { + padding: 1.5rem; +} + +.confirm-dialog-body p { + margin: 0; + color: var(--text-primary); + line-height: 1.5; +} + +/* Responsive Design */ +@media (max-width: 768px) { + .dashboard { + padding: 1rem; + } + + .dashboard-header { + flex-direction: column; + gap: 1rem; + align-items: stretch; + } + + .dashboard-header-actions { + justify-content: space-between; + } + + .trips-grid { + grid-template-columns: 1fr; + } + + .form-row { + grid-template-columns: 1fr; + } + + .modal-content { + margin: 1rem; + max-width: none; + } + + .modal-actions { + flex-direction: column; + } +} diff --git a/frontend/src/components/BaseModal.jsx b/frontend/src/components/BaseModal.jsx new file mode 100644 index 0000000..589efe4 --- /dev/null +++ b/frontend/src/components/BaseModal.jsx @@ -0,0 +1,48 @@ +const BaseModal = ({ + isOpen, + onClose, + title, + children, + actions, + maxWidth = "500px", + className = "" +}) => { + if (!isOpen) return null; + + const handleOverlayClick = (e) => { + if (e.target === e.currentTarget) { + onClose(); + } + }; + + return ( +
+
e.stopPropagation()} + > + {title && ( +
+

{title}

+ +
+ )} + +
+ {children} +
+ + {actions && ( +
+ {actions} +
+ )} +
+
+ ); +}; + +export default BaseModal; \ No newline at end of file diff --git a/frontend/src/components/ConfirmDialog.jsx b/frontend/src/components/ConfirmDialog.jsx new file mode 100644 index 0000000..7488504 --- /dev/null +++ b/frontend/src/components/ConfirmDialog.jsx @@ -0,0 +1,60 @@ +import BaseModal from './BaseModal'; + +const ConfirmDialog = ({ + isOpen, + onClose, + onConfirm, + title = "Confirm Action", + message = "Are you sure you want to proceed?", + confirmText = "Confirm", + cancelText = "Cancel", + isLoading = false, + variant = "danger" // "danger" or "primary" +}) => { + const handleConfirm = async () => { + try { + await onConfirm(); + onClose(); + } catch (error) { + console.error('Confirm action failed:', error); + } + }; + + const actions = ( + <> + + + + ); + + return ( + +
+

{message}

+
+
+ ); +}; + +export default ConfirmDialog; \ No newline at end of file diff --git a/frontend/src/components/Dashboard.jsx b/frontend/src/components/Dashboard.jsx index 415914a..c551cca 100644 --- a/frontend/src/components/Dashboard.jsx +++ b/frontend/src/components/Dashboard.jsx @@ -1,47 +1,164 @@ +import { useState, useEffect, useRef } from 'react'; import { useAuth } from '../contexts/AuthContext'; +import api from '../utils/api'; +import TripList from './TripList'; +import TripModal from './TripModal'; +import ConfirmDialog from './ConfirmDialog'; const Dashboard = () => { const { user, logout } = useAuth(); + const [trips, setTrips] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [showTripModal, setShowTripModal] = useState(false); + const [selectedTrip, setSelectedTrip] = useState(null); + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); + const [tripToDelete, setTripToDelete] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + const [showUserDropdown, setShowUserDropdown] = useState(false); + const userDropdownRef = useRef(null); + + useEffect(() => { + fetchTrips(); + }, []); + + useEffect(() => { + const handleClickOutside = (event) => { + if (userDropdownRef.current && !userDropdownRef.current.contains(event.target)) { + setShowUserDropdown(false); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, []); + + const fetchTrips = async () => { + try { + setIsLoading(true); + const response = await api.get('/trips'); + setTrips(response.data); + } catch (error) { + console.error('Error fetching trips:', error); + } finally { + setIsLoading(false); + } + }; const handleLogout = async () => { await logout(); }; + const handleCreateTrip = () => { + setSelectedTrip(null); + setShowTripModal(true); + }; + + const handleEditTrip = (trip) => { + setSelectedTrip(trip); + setShowTripModal(true); + }; + + const handleDeleteTrip = (trip) => { + setTripToDelete(trip); + setShowDeleteConfirm(true); + }; + + const handleTripSubmit = async (tripData) => { + setIsSubmitting(true); + try { + if (selectedTrip) { + const response = await api.put(`/trips/${selectedTrip.id}`, tripData); + setTrips(trips.map(trip => + trip.id === selectedTrip.id ? response.data : trip + )); + } else { + const response = await api.post('/trips', tripData); + setTrips([response.data, ...trips]); + } + setShowTripModal(false); + setSelectedTrip(null); + } catch (error) { + console.error('Error saving trip:', error); + throw error; + } finally { + setIsSubmitting(false); + } + }; + + const confirmDeleteTrip = async () => { + try { + await api.delete(`/trips/${tripToDelete.id}`); + setTrips(trips.filter(trip => trip.id !== tripToDelete.id)); + setShowDeleteConfirm(false); + setTripToDelete(null); + } catch (error) { + console.error('Error deleting trip:', error); + throw error; + } + }; + return (
-

Trip Planner Dashboard

+
+

TripPlanner

+
- Welcome, {user?.name}! - + Welcome back! +
+ + {showUserDropdown && ( +
+ +
+ )} +
-
-

Welcome to Your Trip Planner

-

Start planning your next adventure!

-
- -
-
-

Plan Trips

-

Create and organize your travel itineraries

-
- -
-

Save Destinations

-

Keep track of places you want to visit

-
- -
-

Share Plans

-

Collaborate with friends and family

-
-
+
+ + { + setShowTripModal(false); + setSelectedTrip(null); + }} + onSubmit={handleTripSubmit} + trip={selectedTrip} + isLoading={isSubmitting} + /> + + { + setShowDeleteConfirm(false); + setTripToDelete(null); + }} + onConfirm={confirmDeleteTrip} + title="Delete Trip" + message={`Are you sure you want to delete "${tripToDelete?.name}"? This action cannot be undone.`} + confirmText="Delete" + variant="danger" + />
); }; diff --git a/frontend/src/components/TripCard.jsx b/frontend/src/components/TripCard.jsx new file mode 100644 index 0000000..d77e8fd --- /dev/null +++ b/frontend/src/components/TripCard.jsx @@ -0,0 +1,118 @@ +import { useState, useRef, useEffect } from 'react'; + +const TripCard = ({ trip, onEdit, onDelete }) => { + const [showDropdown, setShowDropdown] = useState(false); + const dropdownRef = useRef(null); + + useEffect(() => { + const handleClickOutside = (event) => { + if (dropdownRef.current && !dropdownRef.current.contains(event.target)) { + setShowDropdown(false); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, []); + + const formatDate = (dateString) => { + if (!dateString) return null; + const date = new Date(dateString); + return date.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric' + }); + }; + + const handleEdit = () => { + setShowDropdown(false); + onEdit(trip); + }; + + const handleDelete = () => { + setShowDropdown(false); + onDelete(trip); + }; + + const getDuration = () => { + if (!trip.start_date || !trip.end_date) return null; + + const start = new Date(trip.start_date); + const end = new Date(trip.end_date); + const diffTime = Math.abs(end - start); + const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)) + 1; + + return diffDays === 1 ? '1 day' : `${diffDays} days`; + }; + + return ( +
+
+

{trip.name}

+
+ + {showDropdown && ( +
+ + +
+ )} +
+
+ + {trip.description && ( +

{trip.description}

+ )} + +
+ {trip.start_date && trip.end_date ? ( +
+ + {formatDate(trip.start_date)} - {formatDate(trip.end_date)} + + + {getDuration()} + +
+ ) : trip.start_date ? ( + + Starts: {formatDate(trip.start_date)} + + ) : trip.end_date ? ( + + Ends: {formatDate(trip.end_date)} + + ) : ( + + Dates not set + + )} +
+ +
+ + Created {new Date(trip.created_at).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric' + })} + +
+
+ ); +}; + +export default TripCard; \ No newline at end of file diff --git a/frontend/src/components/TripList.jsx b/frontend/src/components/TripList.jsx new file mode 100644 index 0000000..b75df17 --- /dev/null +++ b/frontend/src/components/TripList.jsx @@ -0,0 +1,54 @@ +import TripCard from './TripCard'; +import { GlobeEuropeAfricaIcon } from '@heroicons/react/24/outline'; + +const TripList = ({ trips, isLoading, onEdit, onDelete, onCreateTrip }) => { + if (isLoading) { + return ( +
+
+ Loading trips... +
+
+ ); + } + + if (!trips || trips.length === 0) { + return ( +
+

Your Trips

+
+
+
+ + Create New Trip +
+
+
+
+ ); + } + + return ( +
+

Your Trips

+
+ {trips.map((trip) => ( + + ))} +
+
+ + Create New Trip +
+
+
+
+ ); +}; + +export default TripList; \ No newline at end of file diff --git a/frontend/src/components/TripModal.jsx b/frontend/src/components/TripModal.jsx new file mode 100644 index 0000000..541bf36 --- /dev/null +++ b/frontend/src/components/TripModal.jsx @@ -0,0 +1,175 @@ +import { useState, useEffect } from 'react'; +import BaseModal from './BaseModal'; + +const TripModal = ({ isOpen, onClose, onSubmit, trip = null, isLoading = false }) => { + const [formData, setFormData] = useState({ + name: '', + description: '', + start_date: '', + end_date: '' + }); + const [errors, setErrors] = useState({}); + + useEffect(() => { + if (trip) { + setFormData({ + name: trip.name || '', + description: trip.description || '', + start_date: trip.start_date ? trip.start_date.split('T')[0] : '', + end_date: trip.end_date ? trip.end_date.split('T')[0] : '' + }); + } else { + setFormData({ + name: '', + description: '', + start_date: '', + end_date: '' + }); + } + setErrors({}); + }, [trip, isOpen]); + + const handleChange = (e) => { + const { name, value } = e.target; + setFormData(prev => ({ + ...prev, + [name]: value + })); + + if (errors[name]) { + setErrors(prev => ({ + ...prev, + [name]: '' + })); + } + }; + + const validateForm = () => { + const newErrors = {}; + + if (!formData.name.trim()) { + newErrors.name = 'Trip name is required'; + } + + if (formData.start_date && formData.end_date && formData.start_date > formData.end_date) { + newErrors.end_date = 'End date must be after start date'; + } + + setErrors(newErrors); + return Object.keys(newErrors).length === 0; + }; + + const handleSubmit = async (e) => { + e.preventDefault(); + + if (!validateForm()) { + return; + } + + try { + await onSubmit(formData); + onClose(); + } catch (error) { + if (error.response?.data?.errors) { + setErrors(error.response.data.errors); + } + } + }; + + const handleClose = () => { + setFormData({ + name: '', + description: '', + start_date: '', + end_date: '' + }); + setErrors({}); + onClose(); + }; + + const actions = ( + <> + + + + ); + + return ( + +
+
+ + + {errors.name && {errors.name}} +
+ +
+ +