diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index 9e537a72..88bfb896 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -7,6 +7,12 @@ "commands": [ "sm" ] + }, + "csharpier": { + "version": "1.2.6", + "commands": [ + "dotnet-csharpier" + ] } } } diff --git a/.csharpierrc.yaml b/.csharpierrc.yaml new file mode 100644 index 00000000..7aaa1416 --- /dev/null +++ b/.csharpierrc.yaml @@ -0,0 +1 @@ +printWidth: 100 diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100755 index 00000000..f841cf21 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,2 @@ +#!/usr/bin/env sh +npx lint-staged diff --git a/Makefile b/Makefile index 4358a3d8..2d069ce7 100644 --- a/Makefile +++ b/Makefile @@ -21,7 +21,11 @@ restore: ## Restore .NET packages dotnet restore .PHONY: setup -setup: restore install ## Full project setup (dotnet restore + npm install) +setup: restore tool-restore install ## Full project setup (dotnet restore + tool restore + npm install) + +.PHONY: tool-restore +tool-restore: ## Restore dotnet local tools (CSharpier, sm CLI) + dotnet tool restore # ─── Build ─────────────────────────────────────── @@ -135,7 +139,7 @@ lint-js: ## Run Biome linter on JS/TS .PHONY: lint-cs lint-cs: ## Run CSharpier format check on C# - csharpier check . + dotnet csharpier check . .PHONY: format format: ## Format JS (Biome) and C# (CSharpier) @@ -148,17 +152,17 @@ format-js: ## Run Biome formatter (writes changes) .PHONY: format-cs format-cs: ## Run CSharpier formatter (writes changes) - csharpier format . + dotnet csharpier format . .PHONY: check check: ## Run Biome check + CSharpier check + page validation npm run check - csharpier check . + dotnet csharpier check . .PHONY: check-fix check-fix: ## Auto-fix Biome + CSharpier formatting issues npm run check:fix - csharpier format . + dotnet csharpier format . .PHONY: validate-pages validate-pages: ## Validate C# endpoints have matching TS page entries diff --git a/docs/superpowers/plans/2026-04-01-git-hooks-precommit-formatting.md b/docs/superpowers/plans/2026-04-01-git-hooks-precommit-formatting.md new file mode 100644 index 00000000..b0a9e523 --- /dev/null +++ b/docs/superpowers/plans/2026-04-01-git-hooks-precommit-formatting.md @@ -0,0 +1,296 @@ +# Pre-commit Formatting via Git Hooks — Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Auto-format staged JS/TS/CSS/JSON (Biome) and C# (CSharpier) files on every git commit via a pre-commit hook. + +**Architecture:** Husky manages the git hook lifecycle, lint-staged runs formatters only on staged files. CSharpier is installed as a dotnet local tool. Existing Makefile targets updated to use the local tool. + +**Tech Stack:** Husky 9.x, lint-staged 16.x, CSharpier 1.2.x (dotnet local tool), Biome (already installed) + +**Spec:** `docs/superpowers/specs/2026-04-01-git-hooks-precommit-formatting-design.md` + +--- + +## Chunk 1: CSharpier Setup + +### Task 1: Install CSharpier as a dotnet local tool + +**Files:** +- Modify: `.config/dotnet-tools.json` + +- [ ] **Step 1: Add CSharpier to dotnet local tools manifest** + +Edit `.config/dotnet-tools.json` to add CSharpier: + +```json +{ + "version": 1, + "isRoot": true, + "tools": { + "simplemodule.cli": { + "version": "1.0.0", + "commands": [ + "sm" + ] + }, + "csharpier": { + "version": "1.2.6", + "commands": [ + "dotnet-csharpier" + ] + } + } +} +``` + +- [ ] **Step 2: Restore tools and verify CSharpier works** + +Run: `dotnet tool restore` +Expected: Successfully restored `csharpier`. + +Run: `dotnet csharpier --version` +Expected: `1.2.6` + +- [ ] **Step 3: Commit** + +```bash +git add .config/dotnet-tools.json +git commit -m "chore: add CSharpier as dotnet local tool" +``` + +### Task 2: Add CSharpier configuration + +**Files:** +- Create: `.csharpierrc.yaml` + +- [ ] **Step 1: Create `.csharpierrc.yaml` at repo root** + +```yaml +printWidth: 100 +``` + +This aligns with the Biome `lineWidth: 100` in `biome.json`. + +- [ ] **Step 2: Verify CSharpier respects config** + +Run: `dotnet csharpier --check framework/SimpleModule.Core/IModule.cs` +Expected: exits 0 (already formatted) or exits 1 (needs formatting) — either is fine, just confirming the tool runs. + +- [ ] **Step 3: Commit** + +```bash +git add .csharpierrc.yaml +git commit -m "chore: add CSharpier configuration (printWidth: 100)" +``` + +--- + +## Chunk 2: Husky + lint-staged Setup + +### Task 3: Install Husky and lint-staged + +**Files:** +- Modify: `package.json` (devDependencies, scripts, lint-staged config) +- Create: `.husky/pre-commit` + +- [ ] **Step 1: Install npm dependencies** + +Run: `npm install --save-dev husky@^9 lint-staged@^16` +Expected: Both packages added to `devDependencies` in `package.json`. + +- [ ] **Step 2: Add `prepare` script to `package.json`** + +Add to `"scripts"` in `package.json`: + +```json +"prepare": "husky" +``` + +- [ ] **Step 3: Add lint-staged configuration to `package.json`** + +Add top-level `"lint-staged"` key to `package.json`: + +```json +"lint-staged": { + "*.{js,jsx,ts,tsx,css,json}": "biome check --write --no-errors-on-unmatched", + "*.cs": "dotnet csharpier" +} +``` + +- [ ] **Step 4: Initialize Husky** + +Run: `npx husky` +Expected: Creates `.husky/` directory. + +- [ ] **Step 5: Create the pre-commit hook** + +Create `.husky/pre-commit` with contents: + +```bash +npx lint-staged +``` + +- [ ] **Step 6: Make the hook executable** + +Run: `chmod +x .husky/pre-commit` + +- [ ] **Step 7: Verify Husky hook is installed** + +Run: `cat .husky/pre-commit` +Expected: Shows `npx lint-staged`. + +Run: `ls -la .husky/pre-commit` +Expected: Executable permissions. + +- [ ] **Step 8: Commit** + +```bash +git add package.json package-lock.json .husky/pre-commit +git commit -m "chore: add Husky + lint-staged for pre-commit formatting" +``` + +--- + +## Chunk 3: Makefile Updates + +### Task 4: Update Makefile to use dotnet local tool + +**Files:** +- Modify: `Makefile` + +- [ ] **Step 1: Update `lint-cs` target** + +Change from: +```makefile +lint-cs: ## Run CSharpier format check on C# + csharpier check . +``` + +To: +```makefile +lint-cs: ## Run CSharpier format check on C# + dotnet csharpier check . +``` + +- [ ] **Step 2: Update `format-cs` target** + +Change from: +```makefile +format-cs: ## Run CSharpier formatter (writes changes) + csharpier format . +``` + +To: +```makefile +format-cs: ## Run CSharpier formatter (writes changes) + dotnet csharpier format . +``` + +- [ ] **Step 3: Update `check` target** + +Change from: +```makefile +check: ## Run Biome check + CSharpier check + page validation + npm run check + csharpier check . +``` + +To: +```makefile +check: ## Run Biome check + CSharpier check + page validation + npm run check + dotnet csharpier check . +``` + +- [ ] **Step 4: Update `check-fix` target** + +Change from: +```makefile +check-fix: ## Auto-fix Biome + CSharpier formatting issues + npm run check:fix + csharpier format . +``` + +To: +```makefile +check-fix: ## Auto-fix Biome + CSharpier formatting issues + npm run check:fix + dotnet csharpier format . +``` + +- [ ] **Step 5: Add `dotnet tool restore` to setup target** + +Change from: +```makefile +setup: restore install ## Full project setup (dotnet restore + npm install) +``` + +To: +```makefile +setup: restore tool-restore install ## Full project setup (dotnet restore + tool restore + npm install) + +.PHONY: tool-restore +tool-restore: ## Restore dotnet local tools (CSharpier, sm CLI) + dotnet tool restore +``` + +- [ ] **Step 6: Verify Makefile targets work** + +Run: `make format-cs` (on a single file or small directory to test) +Expected: CSharpier runs via dotnet local tool. + +Run: `make lint-cs` +Expected: CSharpier check runs via dotnet local tool. + +- [ ] **Step 7: Commit** + +```bash +git add Makefile +git commit -m "chore: update Makefile to use dotnet local tool for CSharpier" +``` + +--- + +## Chunk 4: End-to-End Verification + +### Task 5: Verify pre-commit hook works end-to-end + +- [ ] **Step 1: Test JS formatting hook** + +Create a deliberately unformatted test: +```bash +echo 'const x = 1;' > /tmp/test-hook.ts +cp /tmp/test-hook.ts modules/Products/src/SimpleModule.Products/test-hook.ts +git add modules/Products/src/SimpleModule.Products/test-hook.ts +git commit -m "test: verify pre-commit hook" +``` + +Expected: lint-staged runs Biome, formats the file, commit succeeds with formatted code. + +- [ ] **Step 2: Verify the file was formatted** + +Run: `cat modules/Products/src/SimpleModule.Products/test-hook.ts` +Expected: `const x = 1;` (properly formatted). + +- [ ] **Step 3: Clean up test file** + +```bash +git rm modules/Products/src/SimpleModule.Products/test-hook.ts +git commit -m "chore: remove pre-commit hook test file" +``` + +- [ ] **Step 4: Test C# formatting hook** + +Stage a .cs file that's already in the repo: +```bash +git add framework/SimpleModule.Core/IModule.cs +git commit --allow-empty -m "test: verify CSharpier hook" +``` + +Expected: lint-staged runs CSharpier on the staged .cs file, commit succeeds. + +- [ ] **Step 5: Final verification — `make format` still works** + +Run: `make format` +Expected: Both `format-js` and `format-cs` run successfully using the local tools. diff --git a/docs/superpowers/specs/2026-04-01-git-hooks-precommit-formatting-design.md b/docs/superpowers/specs/2026-04-01-git-hooks-precommit-formatting-design.md new file mode 100644 index 00000000..8a6862c5 --- /dev/null +++ b/docs/superpowers/specs/2026-04-01-git-hooks-precommit-formatting-design.md @@ -0,0 +1,61 @@ +# Pre-commit Formatting via Git Hooks + +**Date:** 2026-04-01 +**Status:** Approved + +## Problem + +Code formatting is not enforced at commit time. Developers can commit unformatted JS/TS or C# code, leading to noisy diffs and inconsistent style. + +## Solution + +Add a pre-commit git hook using **Husky + lint-staged** that automatically formats staged files before each commit: + +- **JS/TS/CSS/JSON** files: formatted and lint-fixed via Biome (`biome check --write`) +- **C# (.cs)** files: formatted via CSharpier (`dotnet csharpier `) + +## Components + +### 1. CSharpier (dotnet local tool) + +- Register in `.config/dotnet-tools.json` so `dotnet csharpier` works without global install +- Add `.csharpierrc.yaml` at repo root with default settings + +### 2. Husky + +- npm devDependency +- `"prepare": "husky"` script in root `package.json` auto-installs hooks on `npm install` +- `.husky/pre-commit` script runs `npx lint-staged` + +### 3. lint-staged + +- npm devDependency +- Configuration in root `package.json` under `"lint-staged"` key: + - `"*.{js,jsx,ts,tsx,css,json}"` → `"biome check --write --no-errors-on-unmatched"` + - `"*.cs"` → `"dotnet csharpier"` + +### 4. Makefile updates + +- Update `format-cs` and `lint-cs` targets to use `dotnet csharpier` (local tool) instead of bare `csharpier` +- Add `dotnet tool restore` to the `setup` target so local tools are restored alongside npm/dotnet packages + +## Developer Experience + +- **On `npm install`**: Husky's `prepare` script installs the pre-commit hook automatically +- **On `git commit`**: lint-staged runs formatters on staged files only, re-stages them, then commits +- **On format failure**: commit is blocked with clear error output +- **Manual formatting**: `make format` (already exists) runs both Biome and CSharpier on the whole repo +- **New clones**: `dotnet tool restore && npm install` sets up everything + +## Notes + +- **Bypass**: `git commit --no-verify` skips the pre-commit hook when needed (e.g., WIP commits) +- **Partial staging**: lint-staged v15+ handles `git add -p` safely via stashing +- **Source-generated files**: Not an issue — lint-staged only passes staged files, and generated files in `obj/` are gitignored + +## What Does NOT Change + +- Existing `npm run check` / `npm run check:fix` commands +- CI pipeline (local-only enforcement) +- biome.json configuration +- .editorconfig conventions diff --git a/framework/SimpleModule.DevTools/ViteDevWatchService.cs b/framework/SimpleModule.DevTools/ViteDevWatchService.cs index e0c39a71..ce3c5d40 100644 --- a/framework/SimpleModule.DevTools/ViteDevWatchService.cs +++ b/framework/SimpleModule.DevTools/ViteDevWatchService.cs @@ -1,4 +1,4 @@ -using System.Collections.Concurrent; +using System.Collections.Concurrent; using System.Diagnostics; using System.Runtime.InteropServices; using Microsoft.Extensions.Hosting; @@ -194,7 +194,9 @@ private async Task RunTailwindBuild() var inputPath = Path.Combine(hostDir, "Styles", "app.css"); var outputPath = Path.Combine(hostDir, "wwwroot", "css", "app.css"); - var tailwindBin = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "tailwindcss.cmd" : "tailwindcss"; + var tailwindBin = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + ? "tailwindcss.cmd" + : "tailwindcss"; var tailwindCli = Path.Combine(_npmBinPath, tailwindBin); var command = $"\"{tailwindCli}\" -i \"{inputPath}\" -o \"{outputPath}\""; @@ -328,7 +330,9 @@ CancellationToken stoppingToken var current = startPath; while (current is not null) { - if (Directory.Exists(Path.Combine(current, ".git"))) + var gitPath = Path.Combine(current, ".git"); + // .git is a directory in normal repos, but a file in worktrees + if (Directory.Exists(gitPath) || File.Exists(gitPath)) { return current; } diff --git a/framework/SimpleModule.Hosting/build/SimpleModule.Hosting.targets b/framework/SimpleModule.Hosting/build/SimpleModule.Hosting.targets index c4888149..0c37190e 100644 --- a/framework/SimpleModule.Hosting/build/SimpleModule.Hosting.targets +++ b/framework/SimpleModule.Hosting/build/SimpleModule.Hosting.targets @@ -73,7 +73,7 @@ Inputs="@(ViteSourceFiles)" Outputs="$(ViteBuildStamp)" > - + diff --git a/modules/Directory.Build.targets b/modules/Directory.Build.targets index dae060d5..e29eedd6 100644 --- a/modules/Directory.Build.targets +++ b/modules/Directory.Build.targets @@ -5,7 +5,7 @@ Modules can override JsBuildCommand for custom build steps (e.g. Users double-build). --> - npx vite build + npx vite build --configLoader runner $(IntermediateOutputPath)jsbuild.stamp diff --git a/modules/FeatureFlags/src/SimpleModule.FeatureFlags/FeatureFlagSyncService.cs b/modules/FeatureFlags/src/SimpleModule.FeatureFlags/FeatureFlagSyncService.cs index f5ff22d7..613c71fd 100644 --- a/modules/FeatureFlags/src/SimpleModule.FeatureFlags/FeatureFlagSyncService.cs +++ b/modules/FeatureFlags/src/SimpleModule.FeatureFlags/FeatureFlagSyncService.cs @@ -1,7 +1,4 @@ -using System.Data.Common; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Storage; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; @@ -21,19 +18,6 @@ public async Task StartAsync(CancellationToken cancellationToken) using var scope = serviceProvider.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); - // Ensure module tables exist. EnsureCreated is a no-op when the database - // file already exists (created by the Host DbContext), so we call - // CreateTables directly and ignore the error if tables already exist. - try - { - var creator = db.Database.GetService(); - await creator.CreateTablesAsync(cancellationToken); - } - catch (DbException) - { - // Tables already exist — expected in normal operation - } - var definitions = registry.GetAllDefinitions(); var existingFlags = await db.FeatureFlags.ToListAsync(cancellationToken); var existingNames = existingFlags.ToDictionary(f => f.Name); @@ -47,11 +31,7 @@ public async Task StartAsync(CancellationToken cancellationToken) if (!existingNames.ContainsKey(def.Name)) { db.FeatureFlags.Add( - new FeatureFlagEntity - { - Name = def.Name, - IsEnabled = def.DefaultEnabled, - } + new FeatureFlagEntity { Name = def.Name, IsEnabled = def.DefaultEnabled } ); newCount++; } @@ -81,9 +61,5 @@ public async Task StartAsync(CancellationToken cancellationToken) Level = LogLevel.Information, Message = "Feature flag sync: {NewCount} new, {DeprecatedCount} deprecated" )] - private static partial void LogSync( - ILogger logger, - int newCount, - int deprecatedCount - ); + private static partial void LogSync(ILogger logger, int newCount, int deprecatedCount); } diff --git a/modules/Marketplace/src/SimpleModule.Marketplace/types.ts b/modules/Marketplace/src/SimpleModule.Marketplace/types.ts index 6729262b..77f16f67 100644 --- a/modules/Marketplace/src/SimpleModule.Marketplace/types.ts +++ b/modules/Marketplace/src/SimpleModule.Marketplace/types.ts @@ -13,7 +13,7 @@ export interface MarketplacePackage { isInstalled: boolean; } -export interface MarketplacePackageDetail extends MarketplacePackage { +export interface MarketplacePackageDetail { licenseLink: string; versions: MarketplacePackageVersion[]; dependencies: string[]; diff --git a/modules/Users/src/SimpleModule.Users/SimpleModule.Users.csproj b/modules/Users/src/SimpleModule.Users/SimpleModule.Users.csproj index 70e19d8d..5c99fdb9 100644 --- a/modules/Users/src/SimpleModule.Users/SimpleModule.Users.csproj +++ b/modules/Users/src/SimpleModule.Users/SimpleModule.Users.csproj @@ -11,6 +11,6 @@ - npx vite build && npx cross-env VITE_BUILD_TARGET=pages npx vite build + npx vite build --configLoader runner && npx cross-env VITE_BUILD_TARGET=pages npx vite build --configLoader runner diff --git a/package-lock.json b/package-lock.json index c3d10944..75645b24 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,6 +28,8 @@ "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.1", "cross-env": "^10.1.0", + "husky": "^9.1.7", + "lint-staged": "^16.4.0", "typescript": "^6.0.2", "vite": "^8.0.3" } @@ -4496,6 +4498,22 @@ "node": ">= 14.0.0" } }, + "node_modules/ansi-escapes": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", + "integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "environment": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/ansi-regex": { "version": "5.0.1", "license": "MIT", @@ -4582,6 +4600,85 @@ "url": "https://polar.sh/cva" } }, + "node_modules/cli-cursor": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", + "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "restore-cursor": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-5.2.0.tgz", + "integrity": "sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "slice-ansi": "^8.0.0", + "string-width": "^8.2.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/cli-truncate/node_modules/string-width": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.0.tgz", + "integrity": "sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, "node_modules/cliui": { "version": "6.0.0", "license": "ISC", @@ -4626,6 +4723,13 @@ "version": "1.1.4", "license": "MIT" }, + "node_modules/colorette": { + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", + "dev": true, + "license": "MIT" + }, "node_modules/comma-separated-tokens": { "version": "2.0.3", "dev": true, @@ -4635,6 +4739,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/commander": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, "node_modules/copy-anything": { "version": "4.0.5", "dev": true, @@ -4882,6 +4996,19 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/environment": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", + "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/es-toolkit": { "version": "1.45.1", "license": "MIT", @@ -5018,6 +5145,19 @@ "node": "6.* || 8.* || >= 10.*" } }, + "node_modules/get-east-asian-width": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.5.0.tgz", + "integrity": "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/get-nonce": { "version": "1.0.1", "license": "MIT", @@ -5093,6 +5233,22 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/husky": { + "version": "9.1.7", + "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", + "integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==", + "dev": true, + "license": "MIT", + "bin": { + "husky": "bin.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/typicode" + } + }, "node_modules/immer": { "version": "10.2.0", "license": "MIT", @@ -5433,6 +5589,133 @@ "version": "4.3.2", "license": "MIT" }, + "node_modules/lint-staged": { + "version": "16.4.0", + "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-16.4.0.tgz", + "integrity": "sha512-lBWt8hujh/Cjysw5GYVmZpFHXDCgZzhrOm8vbcUdobADZNOK/bRshr2kM3DfgrrtR1DQhfupW9gnIXOfiFi+bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "commander": "^14.0.3", + "listr2": "^9.0.5", + "picomatch": "^4.0.3", + "string-argv": "^0.3.2", + "tinyexec": "^1.0.4", + "yaml": "^2.8.2" + }, + "bin": { + "lint-staged": "bin/lint-staged.js" + }, + "engines": { + "node": ">=20.17" + }, + "funding": { + "url": "https://opencollective.com/lint-staged" + } + }, + "node_modules/listr2": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/listr2/-/listr2-9.0.5.tgz", + "integrity": "sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "cli-truncate": "^5.0.0", + "colorette": "^2.0.20", + "eventemitter3": "^5.0.1", + "log-update": "^6.1.0", + "rfdc": "^1.4.1", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/listr2/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/listr2/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/listr2/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/listr2/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/listr2/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/listr2/node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/locate-path": { "version": "5.0.0", "license": "MIT", @@ -5443,6 +5726,144 @@ "node": ">=8" } }, + "node_modules/log-update": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz", + "integrity": "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-escapes": "^7.0.0", + "cli-cursor": "^5.0.0", + "slice-ansi": "^7.1.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/log-update/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/log-update/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/log-update/node_modules/is-fullwidth-code-point": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", + "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.3.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/slice-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz", + "integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "is-fullwidth-code-point": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/log-update/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/log-update/node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/magic-string": { "version": "0.30.21", "dev": true, @@ -5589,6 +6010,19 @@ ], "license": "MIT" }, + "node_modules/mimic-function": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", + "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/minisearch": { "version": "7.2.0", "dev": true, @@ -5639,6 +6073,22 @@ "node": ">= 6" } }, + "node_modules/onetime": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", + "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-function": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/oniguruma-to-es": { "version": "3.1.1", "dev": true, @@ -6201,6 +6651,23 @@ "version": "5.1.1", "license": "MIT" }, + "node_modules/restore-cursor": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", + "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", + "dev": true, + "license": "MIT", + "dependencies": { + "onetime": "^7.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/rfdc": { "version": "1.4.1", "dev": true, @@ -6336,6 +6803,65 @@ "@types/hast": "^3.0.4" } }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/slice-ansi": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-8.0.0.tgz", + "integrity": "sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.3", + "is-fullwidth-code-point": "^5.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/slice-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/slice-ansi/node_modules/is-fullwidth-code-point": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", + "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.3.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "license": "BSD-3-Clause", @@ -6360,6 +6886,16 @@ "node": ">=0.10.0" } }, + "node_modules/string-argv": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz", + "integrity": "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6.19" + } + }, "node_modules/string-width": { "version": "4.2.3", "license": "MIT", @@ -6439,6 +6975,16 @@ "version": "1.3.3", "license": "MIT" }, + "node_modules/tinyexec": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.4.tgz", + "integrity": "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.15", "license": "MIT", @@ -7361,6 +7907,22 @@ "version": "4.0.3", "license": "ISC" }, + "node_modules/yaml": { + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", + "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", + "devOptional": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, "node_modules/yargs": { "version": "15.4.1", "license": "MIT", diff --git a/package.json b/package.json index ffcef824..01c5457b 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,8 @@ "test:e2e:ui": "npm run test:ui -w tests/e2e", "website:dev": "npm run dev -w website", "website:build": "npm run build -w website", - "website:preview": "npm run preview -w website" + "website:preview": "npm run preview -w website", + "prepare": "husky" }, "devDependencies": { "@biomejs/biome": "^2.4.10", @@ -37,10 +38,16 @@ "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.1", "cross-env": "^10.1.0", + "husky": "^9.1.7", + "lint-staged": "^16.4.0", "typescript": "^6.0.2", "vite": "^8.0.3" }, "dependencies": { "tailwindcss": "^4.2.2" + }, + "lint-staged": { + "*.{js,jsx,ts,tsx,css,json}": "biome check --write --no-errors-on-unmatched", + "*.cs": "dotnet csharpier" } } diff --git a/tests/SimpleModule.DevTools.Tests/ViteDevWatchServiceTests.cs b/tests/SimpleModule.DevTools.Tests/ViteDevWatchServiceTests.cs index 84655422..8e92196e 100644 --- a/tests/SimpleModule.DevTools.Tests/ViteDevWatchServiceTests.cs +++ b/tests/SimpleModule.DevTools.Tests/ViteDevWatchServiceTests.cs @@ -1,4 +1,4 @@ -using FluentAssertions; +using FluentAssertions; using Microsoft.Extensions.FileProviders; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; @@ -38,6 +38,22 @@ public void FindRepoRoot_Returns_Directory_With_Git_Folder() result.Should().Be(_tempDir); } + [Fact] + public void FindRepoRoot_Returns_Directory_With_Git_File_Worktree() + { + // In git worktrees, .git is a file (not a directory) containing "gitdir: ..." + File.WriteAllText( + Path.Combine(_tempDir, ".git"), + "gitdir: /some/other/repo/.git/worktrees/branch" + ); + var nested = Path.Combine(_tempDir, "a", "b"); + Directory.CreateDirectory(nested); + + var result = ViteDevWatchService.FindRepoRoot(nested); + + result.Should().Be(_tempDir); + } + [Fact] public void FindRepoRoot_Returns_Null_When_No_Git_Folder() { diff --git a/tests/SimpleModule.LoadTests/LoadTestRunner.cs b/tests/SimpleModule.LoadTests/LoadTestRunner.cs index 320737a4..9a579b5c 100644 --- a/tests/SimpleModule.LoadTests/LoadTestRunner.cs +++ b/tests/SimpleModule.LoadTests/LoadTestRunner.cs @@ -1,4 +1,4 @@ -using System.Net.Http.Json; +using System.Net.Http.Json; using NBomber.Contracts; using NBomber.Contracts.Stats; using NBomber.CSharp; @@ -9,7 +9,10 @@ namespace SimpleModule.LoadTests; -public sealed class LoadTestRunner : IClassFixture, IAsyncLifetime, IDisposable +public sealed class LoadTestRunner + : IClassFixture, + IAsyncLifetime, + IDisposable { private readonly LoadTestWebApplicationFactory _factory; private HttpClient _client = null!; @@ -59,14 +62,55 @@ private static void RunScenario(ScenarioProps scenario) var stats = result.ScenarioStats[0]; var total = stats.Ok.Request.Count + stats.Fail.Request.Count; var successRate = total > 0 ? (double)stats.Ok.Request.Count / total * 100 : 0; - Assert.True(successRate > 50, $"Success rate too low: {successRate:F1}% ({stats.Ok.Request.Count}/{total})"); + Assert.True( + successRate > 50, + $"Success rate too low: {successRate:F1}% ({stats.Ok.Request.Count}/{total})" + ); } + private static void RunAllScenarios(params ScenarioProps[] scenarios) + { + var result = NBomberRunner + .RegisterScenarios(scenarios) + .WithReportFolder("reports") + .WithReportFormats(ReportFormat.Html, ReportFormat.Csv) + .Run(); + + Assert.True(result.AllOkCount > 0, "No successful requests across all scenarios"); + + foreach (var stats in result.ScenarioStats) + { + var total = stats.Ok.Request.Count + stats.Fail.Request.Count; + var successRate = total > 0 ? (double)stats.Ok.Request.Count / total * 100 : 0; + Assert.True( + successRate > 50, + $"Scenario '{stats.ScenarioName}' success rate too low: {successRate:F1}% ({stats.Ok.Request.Count}/{total})" + ); + } + } + + [Fact] + public void All_Scenarios() => + RunAllScenarios( + ProductsScenario.Create(_client, LoadProfile.Combined), + OrdersScenario.Create(_client, _seededUserId, _seededProductIds, LoadProfile.Combined), + UsersScenario.Create(_client, LoadProfile.Combined), + SettingsScenario.Create(_client, LoadProfile.Combined), + AuditLogsScenario.Create(_client, LoadProfile.Combined), + FileStorageScenario.Create(_client, LoadProfile.Combined), + PageBuilderScenario.Create(_client, LoadProfile.Combined), + AdminScenario.Create(_client, LoadProfile.Combined), + MixedWorkloadScenario.Create(_client, LoadProfile.Combined), + FeatureFlagsScenario.Create(_client, LoadProfile.Combined), + MarketplaceScenario.Create(_client, LoadProfile.Combined) + ); + [Fact] public void Products_Crud() => RunScenario(ProductsScenario.Create(_client)); [Fact] - public void Orders_Crud() => RunScenario(OrdersScenario.Create(_client, _seededUserId, _seededProductIds)); + public void Orders_Crud() => + RunScenario(OrdersScenario.Create(_client, _seededUserId, _seededProductIds)); [Fact] public void Users_Crud() => RunScenario(UsersScenario.Create(_client)); diff --git a/tests/SimpleModule.LoadTests/Scenarios/AdminScenario.cs b/tests/SimpleModule.LoadTests/Scenarios/AdminScenario.cs index d6f13a07..b848d7cf 100644 --- a/tests/SimpleModule.LoadTests/Scenarios/AdminScenario.cs +++ b/tests/SimpleModule.LoadTests/Scenarios/AdminScenario.cs @@ -1,48 +1,60 @@ +using NBomber.Contracts; using NBomber.CSharp; -using NBomber.Contracts; namespace SimpleModule.LoadTests.Scenarios; public static class AdminScenario { - public static ScenarioProps Create(HttpClient client) + public static ScenarioProps Create(HttpClient client, LoadProfile? profile = null) { - return Scenario.Create("admin_ops", async context => - { - // Create a role - var roleName = $"LT-{Guid.NewGuid():N}"[..20]; - using var roleForm = new FormUrlEncodedContent([ - new KeyValuePair("name", roleName), - new KeyValuePair("description", "Load test role"), - ]); - var roleResponse = await client.PostAsync("/admin/roles/", roleForm); + return Scenario + .Create( + "admin_ops", + async context => + { + // Create a role + var roleName = $"LT-{Guid.NewGuid():N}"[..20]; + using var roleForm = new FormUrlEncodedContent( + [ + new KeyValuePair("name", roleName), + new KeyValuePair("description", "Load test role"), + ] + ); + var roleResponse = await client.PostAsync("/admin/roles/", roleForm); - // Admin endpoints return 302 redirect on success (Blazor SSR pattern). - // With AllowAutoRedirect=false, we get the raw 302. - if (!IsSuccess(roleResponse)) - return Response.Fail(statusCode: ((int)roleResponse.StatusCode).ToString(System.Globalization.CultureInfo.InvariantCulture)); + // Admin endpoints return 302 redirect on success (Blazor SSR pattern). + // With AllowAutoRedirect=false, we get the raw 302. + if (!IsSuccess(roleResponse)) + return Response.Fail( + statusCode: ((int)roleResponse.StatusCode).ToString( + System.Globalization.CultureInfo.InvariantCulture + ) + ); - // Extract role ID from Location header (/admin/roles/{id}/edit) - var locationPath = roleResponse.Headers.Location?.OriginalString ?? string.Empty; - var segments = locationPath.Split('/', StringSplitOptions.RemoveEmptyEntries); - // segments: ["admin", "roles", "{id}", "edit"] - if (segments.Length >= 3) - { - var roleId = segments[2]; + // Extract role ID from Location header (/admin/roles/{id}/edit) + var locationPath = + roleResponse.Headers.Location?.OriginalString ?? string.Empty; + var segments = locationPath.Split('/', StringSplitOptions.RemoveEmptyEntries); + // segments: ["admin", "roles", "{id}", "edit"] + if (segments.Length >= 3) + { + var roleId = segments[2]; - // Delete the role (also returns 302 on success) - var deleteResponse = await client.DeleteAsync($"/admin/roles/{roleId}"); - if (!IsSuccess(deleteResponse)) - return Response.Fail(statusCode: ((int)deleteResponse.StatusCode).ToString(System.Globalization.CultureInfo.InvariantCulture)); - } + // Delete the role (also returns 302 on success) + var deleteResponse = await client.DeleteAsync($"/admin/roles/{roleId}"); + if (!IsSuccess(deleteResponse)) + return Response.Fail( + statusCode: ((int)deleteResponse.StatusCode).ToString( + System.Globalization.CultureInfo.InvariantCulture + ) + ); + } - return Response.Ok(statusCode: "200"); - }) - .WithoutWarmUp() - .WithLoadSimulations( - Simulation.RampingConstant(copies: 50, during: TimeSpan.FromSeconds(5)), - Simulation.KeepConstant(copies: 50, during: TimeSpan.FromSeconds(20)) - ); + return Response.Ok(statusCode: "200"); + } + ) + .WithoutWarmUp() + .WithLoadSimulations((profile ?? LoadProfile.Individual).ToSimulations()); } /// diff --git a/tests/SimpleModule.LoadTests/Scenarios/AuditLogsScenario.cs b/tests/SimpleModule.LoadTests/Scenarios/AuditLogsScenario.cs index 3c01ab83..70b2cbd4 100644 --- a/tests/SimpleModule.LoadTests/Scenarios/AuditLogsScenario.cs +++ b/tests/SimpleModule.LoadTests/Scenarios/AuditLogsScenario.cs @@ -1,6 +1,6 @@ -using System.Net.Http.Json; -using NBomber.CSharp; +using System.Net.Http.Json; using NBomber.Contracts; +using NBomber.CSharp; using SimpleModule.AuditLogs.Contracts; using SimpleModule.Core; @@ -8,31 +8,42 @@ namespace SimpleModule.LoadTests.Scenarios; public static class AuditLogsScenario { - public static ScenarioProps Create(HttpClient client) + public static ScenarioProps Create(HttpClient client, LoadProfile? profile = null) { - return Scenario.Create("auditlogs_read", async context => - { - // Get all audit logs - var getAllResponse = await client.GetAsync("/api/audit-logs"); - if (!getAllResponse.IsSuccessStatusCode) - return Response.Fail(statusCode: ((int)getAllResponse.StatusCode).ToString(System.Globalization.CultureInfo.InvariantCulture)); + return Scenario + .Create( + "auditlogs_read", + async context => + { + // Get all audit logs + var getAllResponse = await client.GetAsync("/api/audit-logs"); + if (!getAllResponse.IsSuccessStatusCode) + return Response.Fail( + statusCode: ((int)getAllResponse.StatusCode).ToString( + System.Globalization.CultureInfo.InvariantCulture + ) + ); - // Get by ID if any entries exist - var pagedResult = await getAllResponse.Content.ReadFromJsonAsync>(); - if (pagedResult?.Items.Count > 0) - { - var firstId = pagedResult.Items[0].Id; - var getByIdResponse = await client.GetAsync($"/api/audit-logs/{firstId}"); - if (!getByIdResponse.IsSuccessStatusCode) - return Response.Fail(statusCode: ((int)getByIdResponse.StatusCode).ToString(System.Globalization.CultureInfo.InvariantCulture)); - } + // Get by ID if any entries exist + var pagedResult = await getAllResponse.Content.ReadFromJsonAsync< + PagedResult + >(); + if (pagedResult?.Items.Count > 0) + { + var firstId = pagedResult.Items[0].Id; + var getByIdResponse = await client.GetAsync($"/api/audit-logs/{firstId}"); + if (!getByIdResponse.IsSuccessStatusCode) + return Response.Fail( + statusCode: ((int)getByIdResponse.StatusCode).ToString( + System.Globalization.CultureInfo.InvariantCulture + ) + ); + } - return Response.Ok(statusCode: "200"); - }) - .WithoutWarmUp() - .WithLoadSimulations( - Simulation.RampingConstant(copies: 50, during: TimeSpan.FromSeconds(5)), - Simulation.KeepConstant(copies: 50, during: TimeSpan.FromSeconds(20)) - ); + return Response.Ok(statusCode: "200"); + } + ) + .WithoutWarmUp() + .WithLoadSimulations((profile ?? LoadProfile.Individual).ToSimulations()); } } diff --git a/tests/SimpleModule.LoadTests/Scenarios/FeatureFlagsScenario.cs b/tests/SimpleModule.LoadTests/Scenarios/FeatureFlagsScenario.cs index a049fcd7..9b50dbed 100644 --- a/tests/SimpleModule.LoadTests/Scenarios/FeatureFlagsScenario.cs +++ b/tests/SimpleModule.LoadTests/Scenarios/FeatureFlagsScenario.cs @@ -1,32 +1,43 @@ -using System.Net.Http.Json; -using NBomber.CSharp; +using System.Net.Http.Json; using NBomber.Contracts; +using NBomber.CSharp; using SimpleModule.FeatureFlags.Contracts; namespace SimpleModule.LoadTests.Scenarios; public static class FeatureFlagsScenario { - public static ScenarioProps Create(HttpClient client) + public static ScenarioProps Create(HttpClient client, LoadProfile? profile = null) { - return Scenario.Create("featureflags_ops", async context => - { - // Get all feature flags - var getAllResponse = await client.GetAsync("/api/feature-flags"); - if (!getAllResponse.IsSuccessStatusCode) - return Response.Fail(statusCode: ((int)getAllResponse.StatusCode).ToString(System.Globalization.CultureInfo.InvariantCulture)); + return Scenario + .Create( + "featureflags_ops", + async context => + { + // Get all feature flags + var getAllResponse = await client.GetAsync("/api/feature-flags"); + if (!getAllResponse.IsSuccessStatusCode) + return Response.Fail( + statusCode: ((int)getAllResponse.StatusCode).ToString( + System.Globalization.CultureInfo.InvariantCulture + ) + ); - // Check a flag (may not exist, but the endpoint should return 200) - var checkResponse = await client.GetAsync("/api/feature-flags/check/load-test-flag"); - if (!checkResponse.IsSuccessStatusCode) - return Response.Fail(statusCode: ((int)checkResponse.StatusCode).ToString(System.Globalization.CultureInfo.InvariantCulture)); + // Check a flag (may not exist, but the endpoint should return 200) + var checkResponse = await client.GetAsync( + "/api/feature-flags/check/load-test-flag" + ); + if (!checkResponse.IsSuccessStatusCode) + return Response.Fail( + statusCode: ((int)checkResponse.StatusCode).ToString( + System.Globalization.CultureInfo.InvariantCulture + ) + ); - return Response.Ok(statusCode: "200"); - }) - .WithoutWarmUp() - .WithLoadSimulations( - Simulation.RampingConstant(copies: 50, during: TimeSpan.FromSeconds(5)), - Simulation.KeepConstant(copies: 50, during: TimeSpan.FromSeconds(20)) - ); + return Response.Ok(statusCode: "200"); + } + ) + .WithoutWarmUp() + .WithLoadSimulations((profile ?? LoadProfile.Individual).ToSimulations()); } } diff --git a/tests/SimpleModule.LoadTests/Scenarios/FileStorageScenario.cs b/tests/SimpleModule.LoadTests/Scenarios/FileStorageScenario.cs index f01fb95c..70106821 100644 --- a/tests/SimpleModule.LoadTests/Scenarios/FileStorageScenario.cs +++ b/tests/SimpleModule.LoadTests/Scenarios/FileStorageScenario.cs @@ -1,30 +1,39 @@ +using NBomber.Contracts; using NBomber.CSharp; -using NBomber.Contracts; namespace SimpleModule.LoadTests.Scenarios; public static class FileStorageScenario { - public static ScenarioProps Create(HttpClient client) + public static ScenarioProps Create(HttpClient client, LoadProfile? profile = null) { - return Scenario.Create("files_ops", async context => - { - // Get all files - var getAllResponse = await client.GetAsync("/api/files"); - if (!getAllResponse.IsSuccessStatusCode) - return Response.Fail(statusCode: ((int)getAllResponse.StatusCode).ToString(System.Globalization.CultureInfo.InvariantCulture)); + return Scenario + .Create( + "files_ops", + async context => + { + // Get all files + var getAllResponse = await client.GetAsync("/api/files"); + if (!getAllResponse.IsSuccessStatusCode) + return Response.Fail( + statusCode: ((int)getAllResponse.StatusCode).ToString( + System.Globalization.CultureInfo.InvariantCulture + ) + ); - // List folders - var foldersResponse = await client.GetAsync("/api/files/folders"); - if (!foldersResponse.IsSuccessStatusCode) - return Response.Fail(statusCode: ((int)foldersResponse.StatusCode).ToString(System.Globalization.CultureInfo.InvariantCulture)); + // List folders + var foldersResponse = await client.GetAsync("/api/files/folders"); + if (!foldersResponse.IsSuccessStatusCode) + return Response.Fail( + statusCode: ((int)foldersResponse.StatusCode).ToString( + System.Globalization.CultureInfo.InvariantCulture + ) + ); - return Response.Ok(statusCode: "200"); - }) - .WithoutWarmUp() - .WithLoadSimulations( - Simulation.RampingConstant(copies: 50, during: TimeSpan.FromSeconds(5)), - Simulation.KeepConstant(copies: 50, during: TimeSpan.FromSeconds(20)) - ); + return Response.Ok(statusCode: "200"); + } + ) + .WithoutWarmUp() + .WithLoadSimulations((profile ?? LoadProfile.Individual).ToSimulations()); } } diff --git a/tests/SimpleModule.LoadTests/Scenarios/LoadProfile.cs b/tests/SimpleModule.LoadTests/Scenarios/LoadProfile.cs new file mode 100644 index 00000000..c223cac2 --- /dev/null +++ b/tests/SimpleModule.LoadTests/Scenarios/LoadProfile.cs @@ -0,0 +1,25 @@ +using NBomber.Contracts; +using NBomber.CSharp; + +namespace SimpleModule.LoadTests.Scenarios; + +public sealed record LoadProfile(int Copies, TimeSpan RampDuration, TimeSpan SustainDuration) +{ + public static readonly LoadProfile Individual = new( + Copies: 50, + RampDuration: TimeSpan.FromSeconds(3), + SustainDuration: TimeSpan.FromSeconds(10) + ); + + public static readonly LoadProfile Combined = new( + Copies: 10, + RampDuration: TimeSpan.FromSeconds(3), + SustainDuration: TimeSpan.FromSeconds(12) + ); + + public LoadSimulation[] ToSimulations() => + [ + Simulation.RampingConstant(copies: Copies, during: RampDuration), + Simulation.KeepConstant(copies: Copies, during: SustainDuration), + ]; +} diff --git a/tests/SimpleModule.LoadTests/Scenarios/MarketplaceScenario.cs b/tests/SimpleModule.LoadTests/Scenarios/MarketplaceScenario.cs index 1a2ca443..0122c6b3 100644 --- a/tests/SimpleModule.LoadTests/Scenarios/MarketplaceScenario.cs +++ b/tests/SimpleModule.LoadTests/Scenarios/MarketplaceScenario.cs @@ -1,30 +1,39 @@ +using NBomber.Contracts; using NBomber.CSharp; -using NBomber.Contracts; namespace SimpleModule.LoadTests.Scenarios; public static class MarketplaceScenario { - public static ScenarioProps Create(HttpClient client) + public static ScenarioProps Create(HttpClient client, LoadProfile? profile = null) { - return Scenario.Create("marketplace_read", async context => - { - // Search packages (anonymous, no auth required) - var searchResponse = await client.GetAsync("/api/marketplace?q=auth&take=10"); - if (!searchResponse.IsSuccessStatusCode) - return Response.Fail(statusCode: ((int)searchResponse.StatusCode).ToString(System.Globalization.CultureInfo.InvariantCulture)); + return Scenario + .Create( + "marketplace_read", + async context => + { + // Search packages (anonymous, no auth required) + var searchResponse = await client.GetAsync("/api/marketplace?q=auth&take=10"); + if (!searchResponse.IsSuccessStatusCode) + return Response.Fail( + statusCode: ((int)searchResponse.StatusCode).ToString( + System.Globalization.CultureInfo.InvariantCulture + ) + ); - // Browse all packages - var browseResponse = await client.GetAsync("/api/marketplace"); - if (!browseResponse.IsSuccessStatusCode) - return Response.Fail(statusCode: ((int)browseResponse.StatusCode).ToString(System.Globalization.CultureInfo.InvariantCulture)); + // Browse all packages + var browseResponse = await client.GetAsync("/api/marketplace"); + if (!browseResponse.IsSuccessStatusCode) + return Response.Fail( + statusCode: ((int)browseResponse.StatusCode).ToString( + System.Globalization.CultureInfo.InvariantCulture + ) + ); - return Response.Ok(statusCode: "200"); - }) - .WithoutWarmUp() - .WithLoadSimulations( - Simulation.RampingConstant(copies: 50, during: TimeSpan.FromSeconds(5)), - Simulation.KeepConstant(copies: 50, during: TimeSpan.FromSeconds(20)) - ); + return Response.Ok(statusCode: "200"); + } + ) + .WithoutWarmUp() + .WithLoadSimulations((profile ?? LoadProfile.Individual).ToSimulations()); } } diff --git a/tests/SimpleModule.LoadTests/Scenarios/MixedWorkloadScenario.cs b/tests/SimpleModule.LoadTests/Scenarios/MixedWorkloadScenario.cs index a5252dd3..affd89b9 100644 --- a/tests/SimpleModule.LoadTests/Scenarios/MixedWorkloadScenario.cs +++ b/tests/SimpleModule.LoadTests/Scenarios/MixedWorkloadScenario.cs @@ -1,7 +1,7 @@ -using System.Net.Http.Json; +using System.Net.Http.Json; using System.Security.Cryptography; -using NBomber.CSharp; using NBomber.Contracts; +using NBomber.CSharp; using SimpleModule.Products.Contracts; using SimpleModule.Tests.Shared.Fakes; @@ -9,73 +9,113 @@ namespace SimpleModule.LoadTests.Scenarios; public static class MixedWorkloadScenario { - public static ScenarioProps Create(HttpClient client) + public static ScenarioProps Create(HttpClient client, LoadProfile? profile = null) { - return Scenario.Create("mixed_realistic", async context => - { - // Weighted random: 70% reads, 20% creates, 10% updates - var roll = RandomNumberGenerator.GetInt32(100); - - if (roll < 70) - { - // Read operations across modules - var endpoint = RandomNumberGenerator.GetInt32(5) switch + return Scenario + .Create( + "mixed_realistic", + async context => { - 0 => "/api/products", - 1 => "/api/orders", - 2 => "/api/users", - 3 => "/api/settings", - _ => "/api/audit-logs", - }; + // Weighted random: 70% reads, 20% creates, 10% updates + var roll = RandomNumberGenerator.GetInt32(100); - var response = await client.GetAsync(endpoint); - return response.IsSuccessStatusCode - ? Response.Ok(statusCode: ((int)response.StatusCode).ToString(System.Globalization.CultureInfo.InvariantCulture)) - : Response.Fail(statusCode: ((int)response.StatusCode).ToString(System.Globalization.CultureInfo.InvariantCulture)); - } + if (roll < 70) + { + // Read operations across modules + var endpoint = RandomNumberGenerator.GetInt32(5) switch + { + 0 => "/api/products", + 1 => "/api/orders", + 2 => "/api/users", + 3 => "/api/settings", + _ => "/api/audit-logs", + }; - if (roll < 90) - { - // Create operations - var createRoll = RandomNumberGenerator.GetInt32(2); - if (createRoll == 0) - { - var request = FakeDataGenerators.CreateProductRequestFaker.Generate(); - var response = await client.PostAsJsonAsync("/api/products", request); - return response.IsSuccessStatusCode - ? Response.Ok(statusCode: ((int)response.StatusCode).ToString(System.Globalization.CultureInfo.InvariantCulture)) - : Response.Fail(statusCode: ((int)response.StatusCode).ToString(System.Globalization.CultureInfo.InvariantCulture)); - } - else - { - var request = FakeDataGenerators.CreateOrderRequestFaker.Generate(); - var response = await client.PostAsJsonAsync("/api/orders", request); - return response.IsSuccessStatusCode - ? Response.Ok(statusCode: ((int)response.StatusCode).ToString(System.Globalization.CultureInfo.InvariantCulture)) - : Response.Fail(statusCode: ((int)response.StatusCode).ToString(System.Globalization.CultureInfo.InvariantCulture)); - } - } + var response = await client.GetAsync(endpoint); + return response.IsSuccessStatusCode + ? Response.Ok( + statusCode: ((int)response.StatusCode).ToString( + System.Globalization.CultureInfo.InvariantCulture + ) + ) + : Response.Fail( + statusCode: ((int)response.StatusCode).ToString( + System.Globalization.CultureInfo.InvariantCulture + ) + ); + } - // Update operations (10%) - { - // Create then update a product - var createReq = FakeDataGenerators.CreateProductRequestFaker.Generate(); - var createResp = await client.PostAsJsonAsync("/api/products", createReq); - if (!createResp.IsSuccessStatusCode) - return Response.Fail(statusCode: ((int)createResp.StatusCode).ToString(System.Globalization.CultureInfo.InvariantCulture)); + if (roll < 90) + { + // Create operations + var createRoll = RandomNumberGenerator.GetInt32(2); + if (createRoll == 0) + { + var request = FakeDataGenerators.CreateProductRequestFaker.Generate(); + var response = await client.PostAsJsonAsync("/api/products", request); + return response.IsSuccessStatusCode + ? Response.Ok( + statusCode: ((int)response.StatusCode).ToString( + System.Globalization.CultureInfo.InvariantCulture + ) + ) + : Response.Fail( + statusCode: ((int)response.StatusCode).ToString( + System.Globalization.CultureInfo.InvariantCulture + ) + ); + } + else + { + var request = FakeDataGenerators.CreateOrderRequestFaker.Generate(); + var response = await client.PostAsJsonAsync("/api/orders", request); + return response.IsSuccessStatusCode + ? Response.Ok( + statusCode: ((int)response.StatusCode).ToString( + System.Globalization.CultureInfo.InvariantCulture + ) + ) + : Response.Fail( + statusCode: ((int)response.StatusCode).ToString( + System.Globalization.CultureInfo.InvariantCulture + ) + ); + } + } - var product = await createResp.Content.ReadFromJsonAsync(); - var updateReq = FakeDataGenerators.UpdateProductRequestFaker.Generate(); - var updateResp = await client.PutAsJsonAsync($"/api/products/{product!.Id}", updateReq); - return updateResp.IsSuccessStatusCode - ? Response.Ok(statusCode: ((int)updateResp.StatusCode).ToString(System.Globalization.CultureInfo.InvariantCulture)) - : Response.Fail(statusCode: ((int)updateResp.StatusCode).ToString(System.Globalization.CultureInfo.InvariantCulture)); - } - }) - .WithoutWarmUp() - .WithLoadSimulations( - Simulation.RampingConstant(copies: 50, during: TimeSpan.FromSeconds(5)), - Simulation.KeepConstant(copies: 50, during: TimeSpan.FromSeconds(20)) - ); + // Update operations (10%) + { + // Create then update a product + var createReq = FakeDataGenerators.CreateProductRequestFaker.Generate(); + var createResp = await client.PostAsJsonAsync("/api/products", createReq); + if (!createResp.IsSuccessStatusCode) + return Response.Fail( + statusCode: ((int)createResp.StatusCode).ToString( + System.Globalization.CultureInfo.InvariantCulture + ) + ); + + var product = await createResp.Content.ReadFromJsonAsync(); + var updateReq = FakeDataGenerators.UpdateProductRequestFaker.Generate(); + var updateResp = await client.PutAsJsonAsync( + $"/api/products/{product!.Id}", + updateReq + ); + return updateResp.IsSuccessStatusCode + ? Response.Ok( + statusCode: ((int)updateResp.StatusCode).ToString( + System.Globalization.CultureInfo.InvariantCulture + ) + ) + : Response.Fail( + statusCode: ((int)updateResp.StatusCode).ToString( + System.Globalization.CultureInfo.InvariantCulture + ) + ); + } + } + ) + .WithoutWarmUp() + .WithLoadSimulations((profile ?? LoadProfile.Individual).ToSimulations()); } } diff --git a/tests/SimpleModule.LoadTests/Scenarios/OrdersScenario.cs b/tests/SimpleModule.LoadTests/Scenarios/OrdersScenario.cs index 9e337571..c2129f0c 100644 --- a/tests/SimpleModule.LoadTests/Scenarios/OrdersScenario.cs +++ b/tests/SimpleModule.LoadTests/Scenarios/OrdersScenario.cs @@ -1,65 +1,88 @@ -using System.Net.Http.Json; -using NBomber.CSharp; +using System.Net.Http.Json; using NBomber.Contracts; +using NBomber.CSharp; using SimpleModule.Orders.Contracts; namespace SimpleModule.LoadTests.Scenarios; public static class OrdersScenario { - public static ScenarioProps Create(HttpClient client, string userId, int[] productIds) + public static ScenarioProps Create( + HttpClient client, + string userId, + int[] productIds, + LoadProfile? profile = null + ) { - return Scenario.Create("orders_crud", async context => - { - // Build order items using real product IDs - var items = productIds.Select(pid => new OrderItem - { - ProductId = pid, - Quantity = context.ScenarioInfo.InstanceNumber % 4 + 1, - }).ToList(); + return Scenario + .Create( + "orders_crud", + async context => + { + // Build order items using real product IDs + var items = productIds + .Select(pid => new OrderItem + { + ProductId = pid, + Quantity = context.ScenarioInfo.InstanceNumber % 4 + 1, + }) + .ToList(); - var createRequest = new CreateOrderRequest - { - UserId = userId, - Items = items, - }; - var createResponse = await client.PostAsJsonAsync("/api/orders", createRequest); - if (!createResponse.IsSuccessStatusCode) - return Response.Fail(statusCode: ((int)createResponse.StatusCode).ToString(System.Globalization.CultureInfo.InvariantCulture)); + var createRequest = new CreateOrderRequest { UserId = userId, Items = items }; + var createResponse = await client.PostAsJsonAsync("/api/orders", createRequest); + if (!createResponse.IsSuccessStatusCode) + return Response.Fail( + statusCode: ((int)createResponse.StatusCode).ToString( + System.Globalization.CultureInfo.InvariantCulture + ) + ); - var order = await createResponse.Content.ReadFromJsonAsync(); + var order = await createResponse.Content.ReadFromJsonAsync(); - // Get all - var getAllResponse = await client.GetAsync("/api/orders"); - if (!getAllResponse.IsSuccessStatusCode) - return Response.Fail(statusCode: ((int)getAllResponse.StatusCode).ToString(System.Globalization.CultureInfo.InvariantCulture)); + // Get all + var getAllResponse = await client.GetAsync("/api/orders"); + if (!getAllResponse.IsSuccessStatusCode) + return Response.Fail( + statusCode: ((int)getAllResponse.StatusCode).ToString( + System.Globalization.CultureInfo.InvariantCulture + ) + ); - // Get by ID - var getResponse = await client.GetAsync($"/api/orders/{order!.Id}"); - if (!getResponse.IsSuccessStatusCode) - return Response.Fail(statusCode: ((int)getResponse.StatusCode).ToString(System.Globalization.CultureInfo.InvariantCulture)); + // Get by ID + var getResponse = await client.GetAsync($"/api/orders/{order!.Id}"); + if (!getResponse.IsSuccessStatusCode) + return Response.Fail( + statusCode: ((int)getResponse.StatusCode).ToString( + System.Globalization.CultureInfo.InvariantCulture + ) + ); - // Update order - var updateRequest = new UpdateOrderRequest - { - UserId = userId, - Items = items, - }; - var updateResponse = await client.PutAsJsonAsync($"/api/orders/{order.Id}", updateRequest); - if (!updateResponse.IsSuccessStatusCode) - return Response.Fail(statusCode: ((int)updateResponse.StatusCode).ToString(System.Globalization.CultureInfo.InvariantCulture)); + // Update order + var updateRequest = new UpdateOrderRequest { UserId = userId, Items = items }; + var updateResponse = await client.PutAsJsonAsync( + $"/api/orders/{order.Id}", + updateRequest + ); + if (!updateResponse.IsSuccessStatusCode) + return Response.Fail( + statusCode: ((int)updateResponse.StatusCode).ToString( + System.Globalization.CultureInfo.InvariantCulture + ) + ); - // Delete - var deleteResponse = await client.DeleteAsync($"/api/orders/{order.Id}"); - if (!deleteResponse.IsSuccessStatusCode) - return Response.Fail(statusCode: ((int)deleteResponse.StatusCode).ToString(System.Globalization.CultureInfo.InvariantCulture)); + // Delete + var deleteResponse = await client.DeleteAsync($"/api/orders/{order.Id}"); + if (!deleteResponse.IsSuccessStatusCode) + return Response.Fail( + statusCode: ((int)deleteResponse.StatusCode).ToString( + System.Globalization.CultureInfo.InvariantCulture + ) + ); - return Response.Ok(statusCode: "200"); - }) - .WithoutWarmUp() - .WithLoadSimulations( - Simulation.RampingConstant(copies: 50, during: TimeSpan.FromSeconds(5)), - Simulation.KeepConstant(copies: 50, during: TimeSpan.FromSeconds(20)) - ); + return Response.Ok(statusCode: "200"); + } + ) + .WithoutWarmUp() + .WithLoadSimulations((profile ?? LoadProfile.Individual).ToSimulations()); } } diff --git a/tests/SimpleModule.LoadTests/Scenarios/PageBuilderScenario.cs b/tests/SimpleModule.LoadTests/Scenarios/PageBuilderScenario.cs index c3c64483..987f7519 100644 --- a/tests/SimpleModule.LoadTests/Scenarios/PageBuilderScenario.cs +++ b/tests/SimpleModule.LoadTests/Scenarios/PageBuilderScenario.cs @@ -1,88 +1,139 @@ -using System.Net.Http.Json; -using NBomber.CSharp; +using System.Net.Http.Json; using NBomber.Contracts; +using NBomber.CSharp; using SimpleModule.PageBuilder.Contracts; namespace SimpleModule.LoadTests.Scenarios; public static class PageBuilderScenario { - public static ScenarioProps Create(HttpClient client) + public static ScenarioProps Create(HttpClient client, LoadProfile? profile = null) { - return Scenario.Create("pagebuilder_crud", async context => - { - try - { - // Create a page - var createRequest = new CreatePageRequest + return Scenario + .Create( + "pagebuilder_crud", + async context => { - Title = $"Load Test Page {context.ScenarioInfo.InstanceNumber}", - Slug = $"lt-{Guid.NewGuid():N}", - }; - var createResponse = await client.PostAsJsonAsync("/api/pagebuilder", createRequest); - if (!createResponse.IsSuccessStatusCode) - return Response.Fail(statusCode: ((int)createResponse.StatusCode).ToString(System.Globalization.CultureInfo.InvariantCulture)); + try + { + // Create a page + var createRequest = new CreatePageRequest + { + Title = $"Load Test Page {context.ScenarioInfo.InstanceNumber}", + Slug = $"lt-{Guid.NewGuid():N}", + }; + var createResponse = await client.PostAsJsonAsync( + "/api/pagebuilder", + createRequest + ); + if (!createResponse.IsSuccessStatusCode) + return Response.Fail( + statusCode: ((int)createResponse.StatusCode).ToString( + System.Globalization.CultureInfo.InvariantCulture + ) + ); - var page = await createResponse.Content.ReadFromJsonAsync(); + var page = await createResponse.Content.ReadFromJsonAsync(); - // Get by ID - var getByIdResponse = await client.GetAsync($"/api/pagebuilder/{page!.Id}"); - if (!getByIdResponse.IsSuccessStatusCode) - return Response.Fail(statusCode: ((int)getByIdResponse.StatusCode).ToString(System.Globalization.CultureInfo.InvariantCulture)); + // Get by ID + var getByIdResponse = await client.GetAsync($"/api/pagebuilder/{page!.Id}"); + if (!getByIdResponse.IsSuccessStatusCode) + return Response.Fail( + statusCode: ((int)getByIdResponse.StatusCode).ToString( + System.Globalization.CultureInfo.InvariantCulture + ) + ); - // Update title - var updateRequest = new UpdatePageRequest - { - Title = $"Updated {createRequest.Title}", - Slug = page.Slug, - }; - var updateResponse = await client.PutAsJsonAsync($"/api/pagebuilder/{page.Id}", updateRequest); - if (!updateResponse.IsSuccessStatusCode) - return Response.Fail(statusCode: ((int)updateResponse.StatusCode).ToString(System.Globalization.CultureInfo.InvariantCulture)); + // Update title + var updateRequest = new UpdatePageRequest + { + Title = $"Updated {createRequest.Title}", + Slug = page.Slug, + }; + var updateResponse = await client.PutAsJsonAsync( + $"/api/pagebuilder/{page.Id}", + updateRequest + ); + if (!updateResponse.IsSuccessStatusCode) + return Response.Fail( + statusCode: ((int)updateResponse.StatusCode).ToString( + System.Globalization.CultureInfo.InvariantCulture + ) + ); - // Publish - var publishResponse = await client.PostAsync($"/api/pagebuilder/{page.Id}/publish", null); - if (!publishResponse.IsSuccessStatusCode) - return Response.Fail(statusCode: ((int)publishResponse.StatusCode).ToString(System.Globalization.CultureInfo.InvariantCulture)); + // Publish + var publishResponse = await client.PostAsync( + $"/api/pagebuilder/{page.Id}/publish", + null + ); + if (!publishResponse.IsSuccessStatusCode) + return Response.Fail( + statusCode: ((int)publishResponse.StatusCode).ToString( + System.Globalization.CultureInfo.InvariantCulture + ) + ); - // Unpublish - var unpublishResponse = await client.PostAsync($"/api/pagebuilder/{page.Id}/unpublish", null); - if (!unpublishResponse.IsSuccessStatusCode) - return Response.Fail(statusCode: ((int)unpublishResponse.StatusCode).ToString(System.Globalization.CultureInfo.InvariantCulture)); + // Unpublish + var unpublishResponse = await client.PostAsync( + $"/api/pagebuilder/{page.Id}/unpublish", + null + ); + if (!unpublishResponse.IsSuccessStatusCode) + return Response.Fail( + statusCode: ((int)unpublishResponse.StatusCode).ToString( + System.Globalization.CultureInfo.InvariantCulture + ) + ); - // Delete - var deleteResponse = await client.DeleteAsync($"/api/pagebuilder/{page.Id}"); - if (!deleteResponse.IsSuccessStatusCode) - return Response.Fail(statusCode: ((int)deleteResponse.StatusCode).ToString(System.Globalization.CultureInfo.InvariantCulture)); + // Delete + var deleteResponse = await client.DeleteAsync( + $"/api/pagebuilder/{page.Id}" + ); + if (!deleteResponse.IsSuccessStatusCode) + return Response.Fail( + statusCode: ((int)deleteResponse.StatusCode).ToString( + System.Globalization.CultureInfo.InvariantCulture + ) + ); - // Get all pages - var getAllResponse = await client.GetAsync("/api/pagebuilder"); - if (!getAllResponse.IsSuccessStatusCode) - return Response.Fail(statusCode: ((int)getAllResponse.StatusCode).ToString(System.Globalization.CultureInfo.InvariantCulture)); + // Get all pages + var getAllResponse = await client.GetAsync("/api/pagebuilder"); + if (!getAllResponse.IsSuccessStatusCode) + return Response.Fail( + statusCode: ((int)getAllResponse.StatusCode).ToString( + System.Globalization.CultureInfo.InvariantCulture + ) + ); - // Get tags - var tagsResponse = await client.GetAsync("/api/pagebuilder/tags"); - if (!tagsResponse.IsSuccessStatusCode) - return Response.Fail(statusCode: ((int)tagsResponse.StatusCode).ToString(System.Globalization.CultureInfo.InvariantCulture)); + // Get tags + var tagsResponse = await client.GetAsync("/api/pagebuilder/tags"); + if (!tagsResponse.IsSuccessStatusCode) + return Response.Fail( + statusCode: ((int)tagsResponse.StatusCode).ToString( + System.Globalization.CultureInfo.InvariantCulture + ) + ); - // Get templates - var templatesResponse = await client.GetAsync("/api/pagebuilder/templates"); - if (!templatesResponse.IsSuccessStatusCode) - return Response.Fail(statusCode: ((int)templatesResponse.StatusCode).ToString(System.Globalization.CultureInfo.InvariantCulture)); + // Get templates + var templatesResponse = await client.GetAsync("/api/pagebuilder/templates"); + if (!templatesResponse.IsSuccessStatusCode) + return Response.Fail( + statusCode: ((int)templatesResponse.StatusCode).ToString( + System.Globalization.CultureInfo.InvariantCulture + ) + ); - return Response.Ok(statusCode: "200"); - } + return Response.Ok(statusCode: "200"); + } #pragma warning disable CA1031 // Catch scenario exceptions for NBomber reporting - catch (Exception ex) + catch (Exception ex) #pragma warning restore CA1031 - { - return Response.Fail(message: ex.Message); - } - }) - .WithoutWarmUp() - .WithLoadSimulations( - Simulation.RampingConstant(copies: 50, during: TimeSpan.FromSeconds(5)), - Simulation.KeepConstant(copies: 50, during: TimeSpan.FromSeconds(20)) - ); + { + return Response.Fail(message: ex.Message); + } + } + ) + .WithoutWarmUp() + .WithLoadSimulations((profile ?? LoadProfile.Individual).ToSimulations()); } } diff --git a/tests/SimpleModule.LoadTests/Scenarios/ProductsScenario.cs b/tests/SimpleModule.LoadTests/Scenarios/ProductsScenario.cs index 9e6891f7..e40d664a 100644 --- a/tests/SimpleModule.LoadTests/Scenarios/ProductsScenario.cs +++ b/tests/SimpleModule.LoadTests/Scenarios/ProductsScenario.cs @@ -1,6 +1,6 @@ -using System.Net.Http.Json; -using NBomber.CSharp; +using System.Net.Http.Json; using NBomber.Contracts; +using NBomber.CSharp; using SimpleModule.Products.Contracts; using SimpleModule.Tests.Shared.Fakes; @@ -8,45 +8,72 @@ namespace SimpleModule.LoadTests.Scenarios; public static class ProductsScenario { - public static ScenarioProps Create(HttpClient client) + public static ScenarioProps Create(HttpClient client, LoadProfile? profile = null) { - return Scenario.Create("products_crud", async context => - { - // Create - var createRequest = FakeDataGenerators.CreateProductRequestFaker.Generate(); - var createResponse = await client.PostAsJsonAsync("/api/products", createRequest); - if (!createResponse.IsSuccessStatusCode) - return Response.Fail(statusCode: ((int)createResponse.StatusCode).ToString(System.Globalization.CultureInfo.InvariantCulture)); - - var product = await createResponse.Content.ReadFromJsonAsync(); - - // Get all - var getAllResponse = await client.GetAsync("/api/products"); - if (!getAllResponse.IsSuccessStatusCode) - return Response.Fail(statusCode: ((int)getAllResponse.StatusCode).ToString(System.Globalization.CultureInfo.InvariantCulture)); - - // Get by ID - var getResponse = await client.GetAsync($"/api/products/{product!.Id}"); - if (!getResponse.IsSuccessStatusCode) - return Response.Fail(statusCode: ((int)getResponse.StatusCode).ToString(System.Globalization.CultureInfo.InvariantCulture)); - - // Update - var updateRequest = FakeDataGenerators.UpdateProductRequestFaker.Generate(); - var updateResponse = await client.PutAsJsonAsync($"/api/products/{product.Id}", updateRequest); - if (!updateResponse.IsSuccessStatusCode) - return Response.Fail(statusCode: ((int)updateResponse.StatusCode).ToString(System.Globalization.CultureInfo.InvariantCulture)); - - // Delete - var deleteResponse = await client.DeleteAsync($"/api/products/{product.Id}"); - if (!deleteResponse.IsSuccessStatusCode) - return Response.Fail(statusCode: ((int)deleteResponse.StatusCode).ToString(System.Globalization.CultureInfo.InvariantCulture)); - - return Response.Ok(statusCode: "200"); - }) - .WithoutWarmUp() - .WithLoadSimulations( - Simulation.RampingConstant(copies: 50, during: TimeSpan.FromSeconds(5)), - Simulation.KeepConstant(copies: 50, during: TimeSpan.FromSeconds(20)) - ); + return Scenario + .Create( + "products_crud", + async context => + { + // Create + var createRequest = FakeDataGenerators.CreateProductRequestFaker.Generate(); + var createResponse = await client.PostAsJsonAsync( + "/api/products", + createRequest + ); + if (!createResponse.IsSuccessStatusCode) + return Response.Fail( + statusCode: ((int)createResponse.StatusCode).ToString( + System.Globalization.CultureInfo.InvariantCulture + ) + ); + + var product = await createResponse.Content.ReadFromJsonAsync(); + + // Get all + var getAllResponse = await client.GetAsync("/api/products"); + if (!getAllResponse.IsSuccessStatusCode) + return Response.Fail( + statusCode: ((int)getAllResponse.StatusCode).ToString( + System.Globalization.CultureInfo.InvariantCulture + ) + ); + + // Get by ID + var getResponse = await client.GetAsync($"/api/products/{product!.Id}"); + if (!getResponse.IsSuccessStatusCode) + return Response.Fail( + statusCode: ((int)getResponse.StatusCode).ToString( + System.Globalization.CultureInfo.InvariantCulture + ) + ); + + // Update + var updateRequest = FakeDataGenerators.UpdateProductRequestFaker.Generate(); + var updateResponse = await client.PutAsJsonAsync( + $"/api/products/{product.Id}", + updateRequest + ); + if (!updateResponse.IsSuccessStatusCode) + return Response.Fail( + statusCode: ((int)updateResponse.StatusCode).ToString( + System.Globalization.CultureInfo.InvariantCulture + ) + ); + + // Delete + var deleteResponse = await client.DeleteAsync($"/api/products/{product.Id}"); + if (!deleteResponse.IsSuccessStatusCode) + return Response.Fail( + statusCode: ((int)deleteResponse.StatusCode).ToString( + System.Globalization.CultureInfo.InvariantCulture + ) + ); + + return Response.Ok(statusCode: "200"); + } + ) + .WithoutWarmUp() + .WithLoadSimulations((profile ?? LoadProfile.Individual).ToSimulations()); } } diff --git a/tests/SimpleModule.LoadTests/Scenarios/SettingsScenario.cs b/tests/SimpleModule.LoadTests/Scenarios/SettingsScenario.cs index 39fb1e1e..af279f29 100644 --- a/tests/SimpleModule.LoadTests/Scenarios/SettingsScenario.cs +++ b/tests/SimpleModule.LoadTests/Scenarios/SettingsScenario.cs @@ -1,43 +1,62 @@ +using NBomber.Contracts; using NBomber.CSharp; -using NBomber.Contracts; namespace SimpleModule.LoadTests.Scenarios; public static class SettingsScenario { - public static ScenarioProps Create(HttpClient client) + public static ScenarioProps Create(HttpClient client, LoadProfile? profile = null) { - return Scenario.Create("settings_ops", async context => - { - // Get settings - var settingsResponse = await client.GetAsync("/api/settings"); - if (!settingsResponse.IsSuccessStatusCode) - return Response.Fail(statusCode: ((int)settingsResponse.StatusCode).ToString(System.Globalization.CultureInfo.InvariantCulture)); + return Scenario + .Create( + "settings_ops", + async context => + { + // Get settings + var settingsResponse = await client.GetAsync("/api/settings"); + if (!settingsResponse.IsSuccessStatusCode) + return Response.Fail( + statusCode: ((int)settingsResponse.StatusCode).ToString( + System.Globalization.CultureInfo.InvariantCulture + ) + ); - // Get definitions - var definitionsResponse = await client.GetAsync("/api/settings/definitions"); - if (!definitionsResponse.IsSuccessStatusCode) - return Response.Fail(statusCode: ((int)definitionsResponse.StatusCode).ToString(System.Globalization.CultureInfo.InvariantCulture)); + // Get definitions + var definitionsResponse = await client.GetAsync("/api/settings/definitions"); + if (!definitionsResponse.IsSuccessStatusCode) + return Response.Fail( + statusCode: ((int)definitionsResponse.StatusCode).ToString( + System.Globalization.CultureInfo.InvariantCulture + ) + ); - // Get menu tree - var menuResponse = await client.GetAsync("/api/settings/menus"); - if (!menuResponse.IsSuccessStatusCode) - return Response.Fail(statusCode: ((int)menuResponse.StatusCode).ToString(System.Globalization.CultureInfo.InvariantCulture)); + // Get menu tree + var menuResponse = await client.GetAsync("/api/settings/menus"); + if (!menuResponse.IsSuccessStatusCode) + return Response.Fail( + statusCode: ((int)menuResponse.StatusCode).ToString( + System.Globalization.CultureInfo.InvariantCulture + ) + ); - // Get available pages - var pagesResponse = await client.GetAsync("/api/settings/menus/available-pages"); - if (!pagesResponse.IsSuccessStatusCode) - return Response.Fail(statusCode: ((int)pagesResponse.StatusCode).ToString(System.Globalization.CultureInfo.InvariantCulture)); + // Get available pages + var pagesResponse = await client.GetAsync( + "/api/settings/menus/available-pages" + ); + if (!pagesResponse.IsSuccessStatusCode) + return Response.Fail( + statusCode: ((int)pagesResponse.StatusCode).ToString( + System.Globalization.CultureInfo.InvariantCulture + ) + ); - // Note: GET /api/settings/me is excluded — it uses ClaimTypes.NameIdentifier - // which doesn't match OpenIddict's "sub" claim in Bearer tokens (product bug). + // Note: GET /api/settings/me is excluded — it uses ClaimTypes.NameIdentifier + // which doesn't match OpenIddict's "sub" claim in Bearer tokens (product bug). - return Response.Ok(statusCode: "200"); - }) - .WithoutWarmUp() - .WithLoadSimulations( - Simulation.RampingConstant(copies: 50, during: TimeSpan.FromSeconds(5)), - Simulation.KeepConstant(copies: 50, during: TimeSpan.FromSeconds(20)) - ); + return Response.Ok(statusCode: "200"); + } + ) + .WithoutWarmUp() + .WithLoadSimulations((profile ?? LoadProfile.Individual).ToSimulations()); } } diff --git a/tests/SimpleModule.LoadTests/Scenarios/UsersScenario.cs b/tests/SimpleModule.LoadTests/Scenarios/UsersScenario.cs index bfd82d59..ad77c5c4 100644 --- a/tests/SimpleModule.LoadTests/Scenarios/UsersScenario.cs +++ b/tests/SimpleModule.LoadTests/Scenarios/UsersScenario.cs @@ -1,38 +1,51 @@ -using System.Net.Http.Json; -using NBomber.CSharp; +using System.Net.Http.Json; using NBomber.Contracts; +using NBomber.CSharp; using SimpleModule.Users.Contracts; namespace SimpleModule.LoadTests.Scenarios; public static class UsersScenario { - public static ScenarioProps Create(HttpClient client) + public static ScenarioProps Create(HttpClient client, LoadProfile? profile = null) { - return Scenario.Create("users_crud", async context => - { - // Get current user (requires NameIdentifier to match a real Identity user) - var meResponse = await client.GetAsync("/api/users/me"); - if (!meResponse.IsSuccessStatusCode) - return Response.Fail(statusCode: ((int)meResponse.StatusCode).ToString(System.Globalization.CultureInfo.InvariantCulture)); + return Scenario + .Create( + "users_crud", + async context => + { + // Get current user (requires NameIdentifier to match a real Identity user) + var meResponse = await client.GetAsync("/api/users/me"); + if (!meResponse.IsSuccessStatusCode) + return Response.Fail( + statusCode: ((int)meResponse.StatusCode).ToString( + System.Globalization.CultureInfo.InvariantCulture + ) + ); - // Get user by ID using the ID from /me - var currentUser = await meResponse.Content.ReadFromJsonAsync(); - var getByIdResponse = await client.GetAsync($"/api/users/{currentUser!.Id}"); - if (!getByIdResponse.IsSuccessStatusCode) - return Response.Fail(statusCode: ((int)getByIdResponse.StatusCode).ToString(System.Globalization.CultureInfo.InvariantCulture)); + // Get user by ID using the ID from /me + var currentUser = await meResponse.Content.ReadFromJsonAsync(); + var getByIdResponse = await client.GetAsync($"/api/users/{currentUser!.Id}"); + if (!getByIdResponse.IsSuccessStatusCode) + return Response.Fail( + statusCode: ((int)getByIdResponse.StatusCode).ToString( + System.Globalization.CultureInfo.InvariantCulture + ) + ); - // Get all users - var getAllResponse = await client.GetAsync("/api/users"); - if (!getAllResponse.IsSuccessStatusCode) - return Response.Fail(statusCode: ((int)getAllResponse.StatusCode).ToString(System.Globalization.CultureInfo.InvariantCulture)); + // Get all users + var getAllResponse = await client.GetAsync("/api/users"); + if (!getAllResponse.IsSuccessStatusCode) + return Response.Fail( + statusCode: ((int)getAllResponse.StatusCode).ToString( + System.Globalization.CultureInfo.InvariantCulture + ) + ); - return Response.Ok(statusCode: "200"); - }) - .WithoutWarmUp() - .WithLoadSimulations( - Simulation.RampingConstant(copies: 50, during: TimeSpan.FromSeconds(5)), - Simulation.KeepConstant(copies: 50, during: TimeSpan.FromSeconds(20)) - ); + return Response.Ok(statusCode: "200"); + } + ) + .WithoutWarmUp() + .WithLoadSimulations((profile ?? LoadProfile.Individual).ToSimulations()); } }