(feature): packages updates and tests

This commit is contained in:
Maksym Sadovnychyy 2026-05-24 19:07:13 +02:00
parent 2c18605699
commit 977201ecae
78 changed files with 6483 additions and 539 deletions

4
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,4 @@
{
"typescript.tsdk": "src/node_modules/typescript/lib",
"typescript.enablePromptUseWorkspaceTsdk": true
}

View File

@ -4,8 +4,27 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## [v0.2.0] - 2026-05-24
### Added
- Initial `@maksit/webui-contracts`, `@maksit/webui-core`, and `@maksit/webui-components` packages extracted from Certs UI and Vault WebUI.
- Jest test suite (50 tests) covering `@maks-it.com/webui-core` utilities and `@maks-it.com/webui-contracts` schemas.
- Root `npm test` script and per-package build tsconfigs (`tsconfig.build.json`) for TypeScript 6 declaration emit.
### Changed
- Updated dependencies to current majors: TypeScript 6, Jest 30, Zod 4.4, lucide-react 1.x, axios 1.16, and React 19.2.
- Migrated Zod schemas to v4 APIs: `intersection()` replaces `.and()`, custom refinements use `'custom'` issue codes.
- `@maks-it.com/webui-components` Toast IDs now use `crypto.randomUUID()`; lodash imports use `lodash/debounce` subpaths.
- Peer dependency ranges: `zod` ^4.4, `axios` ^1.16, `lucide-react` ^1.0.
### Removed
- `uuid` runtime dependency from `@maks-it.com/webui-components`.
## [v0.1.0] - 2026-05-24
### Added
- Initial `@maks-it.com/webui-contracts`, `@maks-it.com/webui-core`, and `@maks-it.com/webui-components` packages extracted from Certs UI and Vault WebUI.
- npm publish under the `@maks-it.com` org scope on registry.npmjs.org.

View File

@ -1,4 +1,6 @@
# maksit-webui
# MaksIT.WebUI
![Line Coverage](/assets/badges/coverage-lines.svg) ![Branch Coverage](/assets/badges/coverage-branches.svg) ![Method Coverage](/assets/badges/coverage-methods.svg)
Shared React UI library for **maksit-certs-ui** and **maksit-vault** WebUI apps.
@ -6,9 +8,9 @@ Shared React UI library for **maksit-certs-ui** and **maksit-vault** WebUI apps.
| npm package | Description |
|-------------|-------------|
| `@maksit/webui-contracts` | Shared TypeScript contracts (paging, gallery types, patch ops, scopes) |
| `@maksit/webui-core` | Utilities (`deepDelta`, enum helpers, ACL parsers) and `useFormState` |
| `@maksit/webui-components` | React components, layout, editors, DataTable, auth shell |
| `@maks-it.com/webui-contracts` | Shared TypeScript contracts (paging, gallery types, patch ops, scopes) |
| `@maks-it.com/webui-core` | Utilities (`deepDelta`, enum helpers, ACL parsers) and `useFormState` |
| `@maks-it.com/webui-components` | React components, layout, editors, DataTable, auth shell |
Source lives under `src/` (npm workspaces). Release automation lives under `utils/` (from [maksit-repoutils](https://github.com/MAKS-IT-COM/maksit-repoutils)).
@ -18,8 +20,11 @@ Source lives under `src/` (npm workspaces). Release automation lives under `util
cd src
npm install
npm run build
npm test
```
Tests and coverage badges: **`utils/Run-Tests/Run-Tests.bat`** (plugin config in `utils/Run-Tests/scriptsettings.json`; uses `NpmJestTest`).
## Release to npmjs
1. Set **`NPMJS_MAKS_IT`** to your npm automation token (same pattern as `NUGET_MAKS_IT` for NuGet).
@ -41,7 +46,7 @@ Refresh shared utils from repoutils: **`utils/Update-RepoUtils/Update-RepoUtils.
## Consume in product repos
```bash
npm install @maksit/webui-contracts @maksit/webui-core @maksit/webui-components
npm install @maks-it.com/webui-contracts @maks-it.com/webui-core @maks-it.com/webui-components
```
Wrap the app with `WebUiProvider` and pass axios/redux adapters — see [assets/docs/NPM_CONSUMPTION.md](assets/docs/NPM_CONSUMPTION.md).

View File

@ -0,0 +1,21 @@
<svg xmlns="http://www.w3.org/2000/svg" width="147.5" height="20" role="img" aria-label="Branch Coverage: 48%">
<title>Branch Coverage: 48%</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
</linearGradient>
<clipPath id="r">
<rect width="147.5" height="20" rx="3" fill="#fff"/>
</clipPath>
<g clip-path="url(#r)">
<rect width="107.5" height="20" fill="#555"/>
<rect x="107.5" width="40" height="20" fill="#a4a61d"/>
<rect width="147.5" height="20" fill="url(#s)"/>
</g>
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
<text aria-hidden="true" x="53.75" y="15" fill="#010101" fill-opacity=".3">Branch Coverage</text>
<text x="53.75" y="14" fill="#fff">Branch Coverage</text>
<text aria-hidden="true" x="127.5" y="15" fill="#010101" fill-opacity=".3">48%</text>
<text x="127.5" y="14" fill="#fff">48%</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@ -0,0 +1,21 @@
<svg xmlns="http://www.w3.org/2000/svg" width="137" height="20" role="img" aria-label="Line Coverage: 42.7%">
<title>Line Coverage: 42.7%</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
</linearGradient>
<clipPath id="r">
<rect width="137" height="20" rx="3" fill="#fff"/>
</clipPath>
<g clip-path="url(#r)">
<rect width="94.5" height="20" fill="#555"/>
<rect x="94.5" width="42.5" height="20" fill="#a4a61d"/>
<rect width="137" height="20" fill="url(#s)"/>
</g>
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
<text aria-hidden="true" x="47.25" y="15" fill="#010101" fill-opacity=".3">Line Coverage</text>
<text x="47.25" y="14" fill="#fff">Line Coverage</text>
<text aria-hidden="true" x="115.75" y="15" fill="#010101" fill-opacity=".3">42.7%</text>
<text x="115.75" y="14" fill="#fff">42.7%</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@ -0,0 +1,21 @@
<svg xmlns="http://www.w3.org/2000/svg" width="150" height="20" role="img" aria-label="Method Coverage: 21.5%">
<title>Method Coverage: 21.5%</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
</linearGradient>
<clipPath id="r">
<rect width="150" height="20" rx="3" fill="#fff"/>
</clipPath>
<g clip-path="url(#r)">
<rect width="107.5" height="20" fill="#555"/>
<rect x="107.5" width="42.5" height="20" fill="#dfb317"/>
<rect width="150" height="20" fill="url(#s)"/>
</g>
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
<text aria-hidden="true" x="53.75" y="15" fill="#010101" fill-opacity=".3">Method Coverage</text>
<text x="53.75" y="14" fill="#fff">Method Coverage</text>
<text aria-hidden="true" x="128.75" y="15" fill="#010101" fill-opacity=".3">21.5%</text>
<text x="128.75" y="14" fill="#fff">21.5%</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@ -1,15 +1,15 @@
# Consuming @maksit/webui-* in Certs UI / Vault
# Consuming @maks-it.com/webui-* in Certs UI / Vault
Install:
```bash
npm install @maksit/webui-contracts @maksit/webui-core @maksit/webui-components
npm install @maks-it.com/webui-contracts @maks-it.com/webui-core @maks-it.com/webui-components
```
Wrap the app:
```tsx
import { WebUiProvider, Loader, Authorization } from '@maksit/webui-components'
import { WebUiProvider, Loader, Authorization } from '@maks-it.com/webui-components'
<WebUiProvider
api={{
@ -43,5 +43,5 @@ import { WebUiProvider, Loader, Authorization } from '@maksit/webui-components'
- `RemoteSelectBoxComponent`: use `searchRoute` (absolute API path string) instead of `apiRoute: ApiRoutes`.
- `SecretComponent`: pass `generateSecretRoute` when `enableGenerate` is true.
- ACL: generic `parseAclEntry` / `parseAclEntries` from `@maksit/webui-core`; per-app entity maps and `parse*AclEntries` live in each WebUI project (`models/acl.ts`).
- Identity request types and Zod schemas (`LoginRequest` + `LoginRequestSchema`, `LogoutRequest` + `LogoutRequestSchema`, `RefreshTokenRequest` + `RefreshTokenRequestSchema`) live in `@maksit/webui-contracts`.
- ACL: generic `parseAclEntry` / `parseAclEntries` from `@maks-it.com/webui-core`; per-app entity maps and `parse*AclEntries` live in each WebUI project (`models/acl.ts`).
- Identity request types and Zod schemas (`LoginRequest` + `LoginRequestSchema`, `LogoutRequest` + `LogoutRequestSchema`, `RefreshTokenRequest` + `RefreshTokenRequestSchema`) live in `@maks-it.com/webui-contracts`.

View File

@ -1,20 +1,20 @@
# Publishing `@maksit/webui-*` to npm
# Publishing `@maks-it.com/webui-*` to npm
Packages are published under the **`@maksit` scope** to [registry.npmjs.org](https://registry.npmjs.org), managed from the [maks-it.com npm org](https://www.npmjs.com/settings/maks-it.com/packages).
Packages are published under the **`@maks-it.com` scope** to [registry.npmjs.org](https://registry.npmjs.org), managed from the [maks-it.com npm org](https://www.npmjs.com/settings/maks-it.com/packages).
Published packages:
| Package | npm |
|---------|-----|
| `@maksit/webui-contracts` | https://www.npmjs.com/package/@maksit/webui-contracts |
| `@maksit/webui-core` | https://www.npmjs.com/package/@maksit/webui-core |
| `@maksit/webui-components` | https://www.npmjs.com/package/@maksit/webui-components |
| `@maks-it.com/webui-contracts` | https://www.npmjs.com/package/@maks-it.com/webui-contracts |
| `@maks-it.com/webui-core` | https://www.npmjs.com/package/@maks-it.com/webui-core |
| `@maks-it.com/webui-components` | https://www.npmjs.com/package/@maks-it.com/webui-components |
## One-time npm setup
1. Sign in at https://www.npmjs.com/ with the **maks-it.com** org account.
2. Confirm the **`@maksit` scope** exists under [Packages](https://www.npmjs.com/settings/maks-it.com/packages). Create the org/scope on npm if this is the first `@maksit/*` publish.
3. Create an **Automation** token (recommended) or Granular Access token with **Publish** on `@maksit/*`:
2. Confirm the **`@maks-it.com` scope** exists under [Packages](https://www.npmjs.com/settings/maks-it.com/packages). The org name `maks-it.com` is the npm scope for scoped packages.
3. Create an **Automation** token (recommended) or Granular Access token with **Publish** on `@maks-it.com/*`:
- https://www.npmjs.com/settings/maks-it.com/tokens
4. Store the token for release tooling:
- **CI / Release-Package:** set env var `NPMJS_MAKS_IT` to the token value (same pattern as `NUGET_MAKS_IT`).
@ -33,45 +33,39 @@ From the repo root:
cd src
npm ci
npm run build
npm publish -w @maksit/webui-contracts --access public
npm publish -w @maksit/webui-core --access public
npm publish -w @maksit/webui-components --access public
npm publish -w @maks-it.com/webui-contracts --access public
npm publish -w @maks-it.com/webui-core --access public
npm publish -w @maks-it.com/webui-components --access public
```
Order matters: **contracts → core → components**.
Or use the helper script:
```powershell
.\scripts\publish-npm.ps1
```
Verify:
```powershell
npm view @maksit/webui-contracts version
npm view @maksit/webui-core version
npm view @maksit/webui-components version
npm view @maks-it.com/webui-contracts version
npm view @maks-it.com/webui-core version
npm view @maks-it.com/webui-components version
```
## Release pipeline (recommended)
From `utils/Release-Package/`:
Use **`utils/Release-Package/Release-Package.bat`** (or `pwsh utils/Release-Package/Release-Package.ps1`):
1. Bump version in `src/package.json` (or tag drives `NpmReleaseVersion`).
2. Tag `HEAD` with exact semver, e.g. `git tag v0.1.0 && git push origin v0.1.0`.
3. Set `NPMJS_MAKS_IT` and run `Release-Package.ps1`.
2. Tag `HEAD` with exact semver, e.g. `git tag v0.2.0 && git push origin v0.2.0`.
3. Set `NPMJS_MAKS_IT` and run the release engine.
`scriptsettings.json` runs `NpmBuild` then `NpmPublish` in dependency order.
`utils/Release-Package/scriptsettings.json` runs `NpmReleaseVersion`, `NpmBuild`, `ReleasePublishGuard`, optional `GitHub`, then `NpmPublish` in dependency order.
## After publish — Certs UI / Vault
In each WebUI app (`MaksIT.WebUI/package.json`):
```json
"@maksit/webui-contracts": "^0.1.0",
"@maksit/webui-core": "^0.1.0",
"@maksit/webui-components": "^0.1.0"
"@maks-it.com/webui-contracts": "^0.1.0",
"@maks-it.com/webui-core": "^0.1.0",
"@maks-it.com/webui-components": "^0.1.0"
```
Then refresh the lockfile:

View File

@ -1,52 +0,0 @@
#requires -Version 7.0
# Publish @maksit/webui-* to registry.npmjs.org (maks-it.com org / @maksit scope).
# Requires: npm login or //registry.npmjs.org/:_authToken in ~/.npmrc, or NPMJS_MAKS_IT env var.
$ErrorActionPreference = 'Stop'
$workspaceRoot = Join-Path $PSScriptRoot '..' 'src' | Resolve-Path
$publishOrder = @(
'@maksit/webui-contracts',
'@maksit/webui-core',
'@maksit/webui-components'
)
Push-Location $workspaceRoot
try {
if (-not [string]::IsNullOrWhiteSpace($env:NPMJS_MAKS_IT)) {
$tempNpmRc = Join-Path $workspaceRoot '.npmrc.publish-temp'
@"
registry=https://registry.npmjs.org
//registry.npmjs.org/:_authToken=$($env:NPMJS_MAKS_IT)
"@ | Set-Content -Path $tempNpmRc -Encoding utf8 -NoNewline
$npmUserConfig = @('--userconfig', $tempNpmRc)
}
else {
$npmUserConfig = @()
Write-Host 'NPMJS_MAKS_IT not set; using default npm auth (~/.npmrc or npm login).' -ForegroundColor Yellow
}
Write-Host 'Installing workspace dependencies...' -ForegroundColor Cyan
npm ci @npmUserConfig
if ($LASTEXITCODE -ne 0) { throw 'npm ci failed.' }
Write-Host 'Building packages...' -ForegroundColor Cyan
npm run build @npmUserConfig
if ($LASTEXITCODE -ne 0) { throw 'npm run build failed.' }
foreach ($packageName in $publishOrder) {
Write-Host "Publishing $packageName..." -ForegroundColor Cyan
npm publish -w $packageName --access public @npmUserConfig
if ($LASTEXITCODE -ne 0) { throw "npm publish failed for $packageName." }
Write-Host "Published $packageName." -ForegroundColor Green
}
Write-Host 'All @maksit/webui-* packages published.' -ForegroundColor Green
}
finally {
Pop-Location
$tempNpmRc = Join-Path $workspaceRoot '.npmrc.publish-temp'
if (Test-Path $tempNpmRc) {
Remove-Item -Path $tempNpmRc -Force -ErrorAction SilentlyContinue
}
}

View File

@ -0,0 +1,61 @@
{"total": {"lines":{"total":824,"covered":352,"skipped":0,"pct":42.71},"statements":{"total":889,"covered":377,"skipped":0,"pct":42.4},"functions":{"total":200,"covered":43,"skipped":0,"pct":21.5},"branches":{"total":404,"covered":194,"skipped":0,"pct":48.01},"branchesTrue":{"total":0,"covered":0,"skipped":0,"pct":100}}
,"E:\\Users\\maksym\\source\\repos\\MaksIT\\maksit-webui\\src\\packages\\contracts\\src\\PagedRequest.ts": {"lines":{"total":3,"covered":3,"skipped":0,"pct":100},"functions":{"total":0,"covered":0,"skipped":0,"pct":100},"statements":{"total":3,"covered":3,"skipped":0,"pct":100},"branches":{"total":0,"covered":0,"skipped":0,"pct":100}}
,"E:\\Users\\maksym\\source\\repos\\MaksIT\\maksit-webui\\src\\packages\\contracts\\src\\PatchOperation.ts": {"lines":{"total":6,"covered":6,"skipped":0,"pct":100},"functions":{"total":1,"covered":1,"skipped":0,"pct":100},"statements":{"total":6,"covered":6,"skipped":0,"pct":100},"branches":{"total":2,"covered":2,"skipped":0,"pct":100}}
,"E:\\Users\\maksym\\source\\repos\\MaksIT\\maksit-webui\\src\\packages\\contracts\\src\\PatchRequestModelBase.ts": {"lines":{"total":4,"covered":4,"skipped":0,"pct":100},"functions":{"total":0,"covered":0,"skipped":0,"pct":100},"statements":{"total":4,"covered":4,"skipped":0,"pct":100},"branches":{"total":0,"covered":0,"skipped":0,"pct":100}}
,"E:\\Users\\maksym\\source\\repos\\MaksIT\\maksit-webui\\src\\packages\\contracts\\src\\RequestModelBase.ts": {"lines":{"total":2,"covered":2,"skipped":0,"pct":100},"functions":{"total":0,"covered":0,"skipped":0,"pct":100},"statements":{"total":2,"covered":2,"skipped":0,"pct":100},"branches":{"total":0,"covered":0,"skipped":0,"pct":100}}
,"E:\\Users\\maksym\\source\\repos\\MaksIT\\maksit-webui\\src\\packages\\contracts\\src\\index.ts": {"lines":{"total":10,"covered":10,"skipped":0,"pct":100},"functions":{"total":9,"covered":2,"skipped":0,"pct":22.22},"statements":{"total":17,"covered":17,"skipped":0,"pct":100},"branches":{"total":0,"covered":0,"skipped":0,"pct":100}}
,"E:\\Users\\maksym\\source\\repos\\MaksIT\\maksit-webui\\src\\packages\\contracts\\src\\identity\\Claims.ts": {"lines":{"total":56,"covered":56,"skipped":0,"pct":100},"functions":{"total":1,"covered":1,"skipped":0,"pct":100},"statements":{"total":56,"covered":56,"skipped":0,"pct":100},"branches":{"total":2,"covered":2,"skipped":0,"pct":100}}
,"E:\\Users\\maksym\\source\\repos\\MaksIT\\maksit-webui\\src\\packages\\contracts\\src\\identity\\login\\LoginRequest.ts": {"lines":{"total":9,"covered":9,"skipped":0,"pct":100},"functions":{"total":1,"covered":1,"skipped":0,"pct":100},"statements":{"total":9,"covered":9,"skipped":0,"pct":100},"branches":{"total":8,"covered":8,"skipped":0,"pct":100}}
,"E:\\Users\\maksym\\source\\repos\\MaksIT\\maksit-webui\\src\\packages\\contracts\\src\\identity\\login\\RefreshTokenRequest.ts": {"lines":{"total":2,"covered":2,"skipped":0,"pct":100},"functions":{"total":0,"covered":0,"skipped":0,"pct":100},"statements":{"total":2,"covered":2,"skipped":0,"pct":100},"branches":{"total":0,"covered":0,"skipped":0,"pct":100}}
,"E:\\Users\\maksym\\source\\repos\\MaksIT\\maksit-webui\\src\\packages\\contracts\\src\\identity\\logout\\LogoutRequest.ts": {"lines":{"total":2,"covered":2,"skipped":0,"pct":100},"functions":{"total":0,"covered":0,"skipped":0,"pct":100},"statements":{"total":2,"covered":2,"skipped":0,"pct":100},"branches":{"total":0,"covered":0,"skipped":0,"pct":100}}
,"E:\\Users\\maksym\\source\\repos\\MaksIT\\maksit-webui\\src\\packages\\core\\src\\index.ts": {"lines":{"total":5,"covered":0,"skipped":0,"pct":0},"functions":{"total":4,"covered":0,"skipped":0,"pct":0},"statements":{"total":9,"covered":0,"skipped":0,"pct":0},"branches":{"total":0,"covered":0,"skipped":0,"pct":100}}
,"E:\\Users\\maksym\\source\\repos\\MaksIT\\maksit-webui\\src\\packages\\core\\src\\functions\\index.ts": {"lines":{"total":41,"covered":0,"skipped":0,"pct":0},"functions":{"total":32,"covered":0,"skipped":0,"pct":0},"statements":{"total":41,"covered":0,"skipped":0,"pct":0},"branches":{"total":0,"covered":0,"skipped":0,"pct":100}}
,"E:\\Users\\maksym\\source\\repos\\MaksIT\\maksit-webui\\src\\packages\\core\\src\\functions\\acl\\index.ts": {"lines":{"total":1,"covered":0,"skipped":0,"pct":0},"functions":{"total":2,"covered":0,"skipped":0,"pct":0},"statements":{"total":3,"covered":0,"skipped":0,"pct":0},"branches":{"total":0,"covered":0,"skipped":0,"pct":100}}
,"E:\\Users\\maksym\\source\\repos\\MaksIT\\maksit-webui\\src\\packages\\core\\src\\functions\\acl\\parseAclEntry.ts": {"lines":{"total":17,"covered":17,"skipped":0,"pct":100},"functions":{"total":4,"covered":4,"skipped":0,"pct":100},"statements":{"total":18,"covered":18,"skipped":0,"pct":100},"branches":{"total":6,"covered":6,"skipped":0,"pct":100}}
,"E:\\Users\\maksym\\source\\repos\\MaksIT\\maksit-webui\\src\\packages\\core\\src\\functions\\dataTable\\dataTableFilters.ts": {"lines":{"total":5,"covered":5,"skipped":0,"pct":100},"functions":{"total":1,"covered":1,"skipped":0,"pct":100},"statements":{"total":6,"covered":6,"skipped":0,"pct":100},"branches":{"total":2,"covered":2,"skipped":0,"pct":100}}
,"E:\\Users\\maksym\\source\\repos\\MaksIT\\maksit-webui\\src\\packages\\core\\src\\functions\\dataTable\\dataTablePaged.ts": {"lines":{"total":4,"covered":4,"skipped":0,"pct":100},"functions":{"total":1,"covered":1,"skipped":0,"pct":100},"statements":{"total":4,"covered":4,"skipped":0,"pct":100},"branches":{"total":16,"covered":16,"skipped":0,"pct":100}}
,"E:\\Users\\maksym\\source\\repos\\MaksIT\\maksit-webui\\src\\packages\\core\\src\\functions\\dataTable\\index.ts": {"lines":{"total":4,"covered":0,"skipped":0,"pct":0},"functions":{"total":2,"covered":0,"skipped":0,"pct":0},"statements":{"total":4,"covered":0,"skipped":0,"pct":0},"branches":{"total":0,"covered":0,"skipped":0,"pct":100}}
,"E:\\Users\\maksym\\source\\repos\\MaksIT\\maksit-webui\\src\\packages\\core\\src\\functions\\date\\dateTimeToUtcIsoSchema.ts": {"lines":{"total":8,"covered":0,"skipped":0,"pct":0},"functions":{"total":2,"covered":0,"skipped":0,"pct":0},"statements":{"total":8,"covered":0,"skipped":0,"pct":0},"branches":{"total":0,"covered":0,"skipped":0,"pct":100}}
,"E:\\Users\\maksym\\source\\repos\\MaksIT\\maksit-webui\\src\\packages\\core\\src\\functions\\date\\formatISODateString.ts": {"lines":{"total":10,"covered":0,"skipped":0,"pct":0},"functions":{"total":1,"covered":0,"skipped":0,"pct":0},"statements":{"total":10,"covered":0,"skipped":0,"pct":0},"branches":{"total":4,"covered":0,"skipped":0,"pct":0}}
,"E:\\Users\\maksym\\source\\repos\\MaksIT\\maksit-webui\\src\\packages\\core\\src\\functions\\date\\index.ts": {"lines":{"total":5,"covered":0,"skipped":0,"pct":0},"functions":{"total":3,"covered":0,"skipped":0,"pct":0},"statements":{"total":6,"covered":0,"skipped":0,"pct":0},"branches":{"total":0,"covered":0,"skipped":0,"pct":100}}
,"E:\\Users\\maksym\\source\\repos\\MaksIT\\maksit-webui\\src\\packages\\core\\src\\functions\\date\\isValidDateString.ts": {"lines":{"total":6,"covered":6,"skipped":0,"pct":100},"functions":{"total":1,"covered":1,"skipped":0,"pct":100},"statements":{"total":7,"covered":7,"skipped":0,"pct":100},"branches":{"total":2,"covered":2,"skipped":0,"pct":100}}
,"E:\\Users\\maksym\\source\\repos\\MaksIT\\maksit-webui\\src\\packages\\core\\src\\functions\\deep\\deepCopy.ts": {"lines":{"total":17,"covered":16,"skipped":0,"pct":94.11},"functions":{"total":1,"covered":1,"skipped":0,"pct":100},"statements":{"total":17,"covered":16,"skipped":0,"pct":94.11},"branches":{"total":12,"covered":11,"skipped":0,"pct":91.66}}
,"E:\\Users\\maksym\\source\\repos\\MaksIT\\maksit-webui\\src\\packages\\core\\src\\functions\\deep\\deepDelta.ts": {"lines":{"total":183,"covered":119,"skipped":0,"pct":65.02},"functions":{"total":18,"covered":14,"skipped":0,"pct":77.77},"statements":{"total":199,"covered":130,"skipped":0,"pct":65.32},"branches":{"total":137,"covered":92,"skipped":0,"pct":67.15}}
,"E:\\Users\\maksym\\source\\repos\\MaksIT\\maksit-webui\\src\\packages\\core\\src\\functions\\deep\\deepEqual.ts": {"lines":{"total":36,"covered":35,"skipped":0,"pct":97.22},"functions":{"total":3,"covered":3,"skipped":0,"pct":100},"statements":{"total":38,"covered":37,"skipped":0,"pct":97.36},"branches":{"total":26,"covered":25,"skipped":0,"pct":96.15}}
,"E:\\Users\\maksym\\source\\repos\\MaksIT\\maksit-webui\\src\\packages\\core\\src\\functions\\deep\\deepMerge.ts": {"lines":{"total":21,"covered":0,"skipped":0,"pct":0},"functions":{"total":1,"covered":0,"skipped":0,"pct":0},"statements":{"total":24,"covered":0,"skipped":0,"pct":0},"branches":{"total":17,"covered":0,"skipped":0,"pct":0}}
,"E:\\Users\\maksym\\source\\repos\\MaksIT\\maksit-webui\\src\\packages\\core\\src\\functions\\deep\\deepPatternMatch.ts": {"lines":{"total":10,"covered":0,"skipped":0,"pct":0},"functions":{"total":1,"covered":0,"skipped":0,"pct":0},"statements":{"total":14,"covered":0,"skipped":0,"pct":0},"branches":{"total":10,"covered":0,"skipped":0,"pct":0}}
,"E:\\Users\\maksym\\source\\repos\\MaksIT\\maksit-webui\\src\\packages\\core\\src\\functions\\deep\\index.ts": {"lines":{"total":15,"covered":0,"skipped":0,"pct":0},"functions":{"total":9,"covered":0,"skipped":0,"pct":0},"statements":{"total":15,"covered":0,"skipped":0,"pct":0},"branches":{"total":0,"covered":0,"skipped":0,"pct":100}}
,"E:\\Users\\maksym\\source\\repos\\MaksIT\\maksit-webui\\src\\packages\\core\\src\\functions\\deep\\patchCollectionPolicies.ts": {"lines":{"total":16,"covered":0,"skipped":0,"pct":0},"functions":{"total":5,"covered":0,"skipped":0,"pct":0},"statements":{"total":16,"covered":0,"skipped":0,"pct":0},"branches":{"total":16,"covered":0,"skipped":0,"pct":0}}
,"E:\\Users\\maksym\\source\\repos\\MaksIT\\maksit-webui\\src\\packages\\core\\src\\functions\\enum\\enumToArr.ts": {"lines":{"total":16,"covered":0,"skipped":0,"pct":0},"functions":{"total":3,"covered":0,"skipped":0,"pct":0},"statements":{"total":19,"covered":0,"skipped":0,"pct":0},"branches":{"total":10,"covered":0,"skipped":0,"pct":0}}
,"E:\\Users\\maksym\\source\\repos\\MaksIT\\maksit-webui\\src\\packages\\core\\src\\functions\\enum\\enumToObj.ts": {"lines":{"total":9,"covered":0,"skipped":0,"pct":0},"functions":{"total":2,"covered":0,"skipped":0,"pct":0},"statements":{"total":11,"covered":0,"skipped":0,"pct":0},"branches":{"total":4,"covered":0,"skipped":0,"pct":0}}
,"E:\\Users\\maksym\\source\\repos\\MaksIT\\maksit-webui\\src\\packages\\core\\src\\functions\\enum\\enumToString.ts": {"lines":{"total":11,"covered":0,"skipped":0,"pct":0},"functions":{"total":3,"covered":0,"skipped":0,"pct":0},"statements":{"total":12,"covered":0,"skipped":0,"pct":0},"branches":{"total":6,"covered":0,"skipped":0,"pct":0}}
,"E:\\Users\\maksym\\source\\repos\\MaksIT\\maksit-webui\\src\\packages\\core\\src\\functions\\enum\\flagsToString.ts": {"lines":{"total":6,"covered":0,"skipped":0,"pct":0},"functions":{"total":3,"covered":0,"skipped":0,"pct":0},"statements":{"total":6,"covered":0,"skipped":0,"pct":0},"branches":{"total":4,"covered":0,"skipped":0,"pct":0}}
,"E:\\Users\\maksym\\source\\repos\\MaksIT\\maksit-webui\\src\\packages\\core\\src\\functions\\enum\\hasAnyFlag.ts": {"lines":{"total":3,"covered":3,"skipped":0,"pct":100},"functions":{"total":1,"covered":1,"skipped":0,"pct":100},"statements":{"total":3,"covered":3,"skipped":0,"pct":100},"branches":{"total":1,"covered":0,"skipped":0,"pct":0}}
,"E:\\Users\\maksym\\source\\repos\\MaksIT\\maksit-webui\\src\\packages\\core\\src\\functions\\enum\\hasFlag.ts": {"lines":{"total":3,"covered":3,"skipped":0,"pct":100},"functions":{"total":1,"covered":1,"skipped":0,"pct":100},"statements":{"total":3,"covered":3,"skipped":0,"pct":100},"branches":{"total":1,"covered":0,"skipped":0,"pct":0}}
,"E:\\Users\\maksym\\source\\repos\\MaksIT\\maksit-webui\\src\\packages\\core\\src\\functions\\enum\\index.ts": {"lines":{"total":14,"covered":0,"skipped":0,"pct":0},"functions":{"total":7,"covered":0,"skipped":0,"pct":0},"statements":{"total":14,"covered":0,"skipped":0,"pct":0},"branches":{"total":0,"covered":0,"skipped":0,"pct":100}}
,"E:\\Users\\maksym\\source\\repos\\MaksIT\\maksit-webui\\src\\packages\\core\\src\\functions\\enum\\toggleFlag.ts": {"lines":{"total":3,"covered":3,"skipped":0,"pct":100},"functions":{"total":1,"covered":1,"skipped":0,"pct":100},"statements":{"total":3,"covered":3,"skipped":0,"pct":100},"branches":{"total":3,"covered":2,"skipped":0,"pct":66.66}}
,"E:\\Users\\maksym\\source\\repos\\MaksIT\\maksit-webui\\src\\packages\\core\\src\\functions\\file\\index.ts": {"lines":{"total":2,"covered":0,"skipped":0,"pct":0},"functions":{"total":1,"covered":0,"skipped":0,"pct":0},"statements":{"total":2,"covered":0,"skipped":0,"pct":0},"branches":{"total":0,"covered":0,"skipped":0,"pct":100}}
,"E:\\Users\\maksym\\source\\repos\\MaksIT\\maksit-webui\\src\\packages\\core\\src\\functions\\file\\saveBinaryToDisk.ts": {"lines":{"total":11,"covered":0,"skipped":0,"pct":0},"functions":{"total":2,"covered":0,"skipped":0,"pct":0},"statements":{"total":12,"covered":0,"skipped":0,"pct":0},"branches":{"total":2,"covered":0,"skipped":0,"pct":0}}
,"E:\\Users\\maksym\\source\\repos\\MaksIT\\maksit-webui\\src\\packages\\core\\src\\functions\\guid\\index.ts": {"lines":{"total":2,"covered":0,"skipped":0,"pct":0},"functions":{"total":1,"covered":0,"skipped":0,"pct":0},"statements":{"total":2,"covered":0,"skipped":0,"pct":0},"branches":{"total":0,"covered":0,"skipped":0,"pct":100}}
,"E:\\Users\\maksym\\source\\repos\\MaksIT\\maksit-webui\\src\\packages\\core\\src\\functions\\guid\\isGuid.ts": {"lines":{"total":4,"covered":4,"skipped":0,"pct":100},"functions":{"total":1,"covered":1,"skipped":0,"pct":100},"statements":{"total":4,"covered":4,"skipped":0,"pct":100},"branches":{"total":0,"covered":0,"skipped":0,"pct":100}}
,"E:\\Users\\maksym\\source\\repos\\MaksIT\\maksit-webui\\src\\packages\\core\\src\\functions\\headers\\extractFilenameFromHeaders.ts": {"lines":{"total":17,"covered":15,"skipped":0,"pct":88.23},"functions":{"total":1,"covered":1,"skipped":0,"pct":100},"statements":{"total":17,"covered":15,"skipped":0,"pct":88.23},"branches":{"total":15,"covered":14,"skipped":0,"pct":93.33}}
,"E:\\Users\\maksym\\source\\repos\\MaksIT\\maksit-webui\\src\\packages\\core\\src\\functions\\headers\\index.ts": {"lines":{"total":2,"covered":0,"skipped":0,"pct":0},"functions":{"total":1,"covered":0,"skipped":0,"pct":0},"statements":{"total":2,"covered":0,"skipped":0,"pct":0},"branches":{"total":0,"covered":0,"skipped":0,"pct":100}}
,"E:\\Users\\maksym\\source\\repos\\MaksIT\\maksit-webui\\src\\packages\\core\\src\\functions\\zod\\applyFormBulkChange.ts": {"lines":{"total":3,"covered":0,"skipped":0,"pct":0},"functions":{"total":1,"covered":0,"skipped":0,"pct":0},"statements":{"total":3,"covered":0,"skipped":0,"pct":0},"branches":{"total":0,"covered":0,"skipped":0,"pct":100}}
,"E:\\Users\\maksym\\source\\repos\\MaksIT\\maksit-webui\\src\\packages\\core\\src\\functions\\zod\\applyFormFieldChange.ts": {"lines":{"total":9,"covered":0,"skipped":0,"pct":0},"functions":{"total":2,"covered":0,"skipped":0,"pct":0},"statements":{"total":10,"covered":0,"skipped":0,"pct":0},"branches":{"total":0,"covered":0,"skipped":0,"pct":100}}
,"E:\\Users\\maksym\\source\\repos\\MaksIT\\maksit-webui\\src\\packages\\core\\src\\functions\\zod\\createFormBulkUpdater.ts": {"lines":{"total":4,"covered":0,"skipped":0,"pct":0},"functions":{"total":2,"covered":0,"skipped":0,"pct":0},"statements":{"total":5,"covered":0,"skipped":0,"pct":0},"branches":{"total":0,"covered":0,"skipped":0,"pct":100}}
,"E:\\Users\\maksym\\source\\repos\\MaksIT\\maksit-webui\\src\\packages\\core\\src\\functions\\zod\\createFormFieldUpdater.ts": {"lines":{"total":4,"covered":0,"skipped":0,"pct":0},"functions":{"total":2,"covered":0,"skipped":0,"pct":0},"statements":{"total":5,"covered":0,"skipped":0,"pct":0},"branches":{"total":0,"covered":0,"skipped":0,"pct":100}}
,"E:\\Users\\maksym\\source\\repos\\MaksIT\\maksit-webui\\src\\packages\\core\\src\\functions\\zod\\emptyFormErrors.ts": {"lines":{"total":3,"covered":3,"skipped":0,"pct":100},"functions":{"total":2,"covered":2,"skipped":0,"pct":100},"statements":{"total":4,"covered":4,"skipped":0,"pct":100},"branches":{"total":0,"covered":0,"skipped":0,"pct":100}}
,"E:\\Users\\maksym\\source\\repos\\MaksIT\\maksit-webui\\src\\packages\\core\\src\\functions\\zod\\flattenFormValidationIssues.ts": {"lines":{"total":7,"covered":7,"skipped":0,"pct":100},"functions":{"total":1,"covered":1,"skipped":0,"pct":100},"statements":{"total":7,"covered":7,"skipped":0,"pct":100},"branches":{"total":0,"covered":0,"skipped":0,"pct":100}}
,"E:\\Users\\maksym\\source\\repos\\MaksIT\\maksit-webui\\src\\packages\\core\\src\\functions\\zod\\index.ts": {"lines":{"total":12,"covered":0,"skipped":0,"pct":0},"functions":{"total":6,"covered":0,"skipped":0,"pct":0},"statements":{"total":12,"covered":0,"skipped":0,"pct":0},"branches":{"total":0,"covered":0,"skipped":0,"pct":100}}
,"E:\\Users\\maksym\\source\\repos\\MaksIT\\maksit-webui\\src\\packages\\core\\src\\functions\\zod\\validateFormState.ts": {"lines":{"total":7,"covered":7,"skipped":0,"pct":100},"functions":{"total":1,"covered":1,"skipped":0,"pct":100},"statements":{"total":7,"covered":7,"skipped":0,"pct":100},"branches":{"total":2,"covered":2,"skipped":0,"pct":100}}
,"E:\\Users\\maksym\\source\\repos\\MaksIT\\maksit-webui\\src\\packages\\core\\src\\http\\authInterceptors.ts": {"lines":{"total":60,"covered":0,"skipped":0,"pct":0},"functions":{"total":9,"covered":0,"skipped":0,"pct":0},"statements":{"total":60,"covered":0,"skipped":0,"pct":0},"branches":{"total":38,"covered":0,"skipped":0,"pct":0}}
,"E:\\Users\\maksym\\source\\repos\\MaksIT\\maksit-webui\\src\\packages\\core\\src\\http\\config.ts": {"lines":{"total":18,"covered":0,"skipped":0,"pct":0},"functions":{"total":4,"covered":0,"skipped":0,"pct":0},"statements":{"total":18,"covered":0,"skipped":0,"pct":0},"branches":{"total":13,"covered":0,"skipped":0,"pct":0}}
,"E:\\Users\\maksym\\source\\repos\\MaksIT\\maksit-webui\\src\\packages\\core\\src\\http\\createWebUiHttpClient.ts": {"lines":{"total":49,"covered":0,"skipped":0,"pct":0},"functions":{"total":22,"covered":0,"skipped":0,"pct":0},"statements":{"total":50,"covered":0,"skipped":0,"pct":0},"branches":{"total":15,"covered":0,"skipped":0,"pct":0}}
,"E:\\Users\\maksym\\source\\repos\\MaksIT\\maksit-webui\\src\\packages\\core\\src\\http\\errorHandler.ts": {"lines":{"total":12,"covered":0,"skipped":0,"pct":0},"functions":{"total":1,"covered":0,"skipped":0,"pct":0},"statements":{"total":12,"covered":0,"skipped":0,"pct":0},"branches":{"total":13,"covered":0,"skipped":0,"pct":0}}
,"E:\\Users\\maksym\\source\\repos\\MaksIT\\maksit-webui\\src\\packages\\core\\src\\http\\formData.ts": {"lines":{"total":9,"covered":0,"skipped":0,"pct":0},"functions":{"total":3,"covered":0,"skipped":0,"pct":0},"statements":{"total":10,"covered":0,"skipped":0,"pct":0},"branches":{"total":4,"covered":0,"skipped":0,"pct":0}}
,"E:\\Users\\maksym\\source\\repos\\MaksIT\\maksit-webui\\src\\packages\\core\\src\\http\\index.ts": {"lines":{"total":5,"covered":0,"skipped":0,"pct":0},"functions":{"total":5,"covered":0,"skipped":0,"pct":0},"statements":{"total":10,"covered":0,"skipped":0,"pct":0},"branches":{"total":0,"covered":0,"skipped":0,"pct":100}}
,"E:\\Users\\maksym\\source\\repos\\MaksIT\\maksit-webui\\src\\packages\\core\\src\\http\\problemDetails.ts": {"lines":{"total":5,"covered":5,"skipped":0,"pct":100},"functions":{"total":3,"covered":3,"skipped":0,"pct":100},"statements":{"total":6,"covered":6,"skipped":0,"pct":100},"branches":{"total":9,"covered":8,"skipped":0,"pct":88.88}}
,"E:\\Users\\maksym\\source\\repos\\MaksIT\\maksit-webui\\src\\packages\\core\\src\\localStorage\\identity.ts": {"lines":{"total":9,"covered":0,"skipped":0,"pct":0},"functions":{"total":3,"covered":0,"skipped":0,"pct":0},"statements":{"total":12,"covered":0,"skipped":0,"pct":0},"branches":{"total":2,"covered":0,"skipped":0,"pct":0}}
,"E:\\Users\\maksym\\source\\repos\\MaksIT\\maksit-webui\\src\\packages\\core\\src\\types\\ScopePermissions.ts": {"lines":{"total":6,"covered":6,"skipped":0,"pct":100},"functions":{"total":1,"covered":1,"skipped":0,"pct":100},"statements":{"total":6,"covered":6,"skipped":0,"pct":100},"branches":{"total":2,"covered":2,"skipped":0,"pct":100}}
,"E:\\Users\\maksym\\source\\repos\\MaksIT\\maksit-webui\\src\\packages\\core\\src\\types\\index.ts": {"lines":{"total":1,"covered":0,"skipped":0,"pct":0},"functions":{"total":1,"covered":0,"skipped":0,"pct":0},"statements":{"total":2,"covered":0,"skipped":0,"pct":0},"branches":{"total":0,"covered":0,"skipped":0,"pct":100}}
}

26
src/jest.config.cjs Normal file
View File

@ -0,0 +1,26 @@
/** @type {import('jest').Config} */
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
roots: ['<rootDir>/packages/core/src', '<rootDir>/packages/contracts/src'],
testMatch: ['**/*.test.ts'],
collectCoverageFrom: [
'packages/core/src/**/*.ts',
'packages/contracts/src/**/*.ts',
'!**/*.test.ts',
],
coverageDirectory: 'coverage',
coverageReporters: ['json-summary', 'text'],
moduleNameMapper: {
'^(\\.{1,2}/.*)\\.js$': '$1',
'^@maks-it.com/webui-contracts$': '<rootDir>/packages/contracts/src/index.ts',
},
transform: {
'^.+\\.tsx?$': [
'ts-jest',
{
tsconfig: '<rootDir>/tsconfig.jest.json',
},
],
},
}

4136
src/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +1,7 @@
{
"name": "maksit-webui",
"private": true,
"version": "0.1.0",
"version": "0.2.0",
"description": "Shared React UI library for MaksIT Certs UI and Vault WebUI",
"workspaces": [
"packages/*"
@ -10,13 +10,22 @@
"node": ">=20"
},
"overrides": {
"zod": "^4.3.6"
"zod": "^4.4.3",
"glob": "^13.0.6",
"test-exclude": "^8.0.0"
},
"scripts": {
"build": "npm run build -w @maksit/webui-contracts && npm run build -w @maksit/webui-core && npm run build -w @maksit/webui-components",
"build": "npm run build -w @maks-it.com/webui-contracts && npm run build -w @maks-it.com/webui-core && npm run build -w @maks-it.com/webui-components",
"test": "jest --config jest.config.cjs",
"test:coverage": "jest --config jest.config.cjs --coverage",
"typecheck": "npm run typecheck --workspaces --if-present",
"clean": "npm run clean --workspaces --if-present"
},
"devDependencies": {
"@types/jest": "^30.0.0",
"jest": "^30.4.2",
"ts-jest": "^29.4.11"
},
"author": "MaksIT",
"license": "MIT"
}

View File

@ -0,0 +1,66 @@
# @maks-it.com/webui-components
Shared React UI components for MaksIT WebUI apps: forms, DataTable, layout, editors, and list views.
Depends on `@maks-it.com/webui-core` and `@maks-it.com/webui-contracts`. Peer dependencies must be installed in the host app.
## Install
```bash
npm install @maks-it.com/webui-components @maks-it.com/webui-core @maks-it.com/webui-contracts
npm install react react-dom react-router-dom lucide-react @tanstack/react-table react-virtualized zod
```
## Components
| Export | Purpose |
|--------|---------|
| `DataTable`, `DataTableFilter`, `DataTableLabel` | Virtualized server-paged tables |
| `RemoteSelectBoxComponent` | Async search select (pass `searchRoute` API path) |
| `SecretComponent` | Secret field with optional generate action |
| `FormContainer`, `FormHeader`, `FormContent`, `FormFooter` | Form layout shell |
| `Layout` | App chrome / navigation wrapper |
| `Offcanvas` | Slide-over panel |
| `LazyLoadTable` | Incrementally loaded table |
| `VaultStyleDataTable`, `VaultStyleListSection` | Vault-style list layouts |
| `EntityScopesSummary` | Entity scope permissions summary |
| `Toast`, `addToast` | Toast notifications |
| `FieldContainer` | Label + validation wrapper for fields |
## Example — DataTable
```tsx
import { DataTable, createColumns } from '@maks-it.com/webui-components'
const columns = createColumns([
{ key: 'name', header: 'Name' },
{ key: 'createdAt', header: 'Created', label: 'date' },
])
<DataTable
columns={columns}
rawd={pagedResponse}
onPageChange={setPage}
/>
```
## Example — remote select
```tsx
import { RemoteSelectBoxComponent } from '@maks-it.com/webui-components'
<RemoteSelectBoxComponent
searchRoute="/api/users/search"
value={userId}
onChange={setUserId}
labelKey="name"
/>
```
## Repository
[github.com/MAKS-IT-COM/maksit-webui](https://github.com/MAKS-IT-COM/maksit-webui) — `src/packages/components`
## License
MIT

View File

@ -1,6 +1,6 @@
{
"name": "@maksit/webui-components",
"version": "0.1.0",
"name": "@maks-it.com/webui-components",
"version": "0.2.0",
"description": "Shared React components for MaksIT WebUI apps",
"type": "module",
"main": "./dist/index.cjs",
@ -18,7 +18,7 @@
"README.md"
],
"scripts": {
"build": "tsup src/index.ts --format esm,cjs --dts --clean --external react --external react-dom --external react-router-dom --external lucide-react --external @tanstack/react-table --external react-virtualized",
"build": "tsup src/index.ts --format esm,cjs --dts --clean --tsconfig tsconfig.build.json --external react --external react-dom --external react-router-dom --external lucide-react --external @tanstack/react-table --external react-virtualized",
"typecheck": "tsc -p tsconfig.json --noEmit",
"clean": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\"",
"prepublishOnly": "npm run build"
@ -33,34 +33,33 @@
"directory": "src/packages/components"
},
"dependencies": {
"@maksit/webui-contracts": "^0.1.0",
"@maksit/webui-core": "^0.1.0",
"date-fns": "^4.1.0",
"lodash": "^4.17.23",
"uuid": "^13.0.0"
"@maks-it.com/webui-contracts": "^0.2.0",
"@maks-it.com/webui-core": "^0.2.0",
"date-fns": "^4.3.0",
"lodash": "^4.18.1"
},
"peerDependencies": {
"@tanstack/react-table": "^8.0.0",
"lucide-react": "^0.500.0",
"lucide-react": "^1.0.0",
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0",
"react-router-dom": "^7.0.0",
"react-virtualized": "^9.22.0",
"zod": "^4.0.0"
"zod": "^4.4.0"
},
"devDependencies": {
"@tanstack/react-table": "^8.21.3",
"@types/lodash": "^4.17.24",
"@types/react": "^19.2.14",
"@types/react": "^19.2.15",
"@types/react-dom": "^19.2.3",
"@types/react-virtualized": "^9.22.3",
"lucide-react": "^0.576.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-router-dom": "^7.13.1",
"lucide-react": "^1.16.0",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-router-dom": "^7.15.1",
"react-virtualized": "^9.22.6",
"tsup": "^8.5.0",
"typescript": "^5.9.3",
"zod": "^4.3.6"
"tsup": "^8.5.1",
"typescript": "^6.0.3",
"zod": "^4.4.3"
}
}

View File

@ -1,9 +1,9 @@
import React, { useState, useMemo, useRef, useEffect } from 'react'
import { AutoSizer, MultiGrid, GridCellProps } from 'react-virtualized'
import { mapPagedToDataTable, type DataTablePageView, type PagedResponse } from '@maksit/webui-core'
import { mapPagedToDataTable, type DataTablePageView, type PagedResponse } from '@maks-it.com/webui-core'
import { Plus, Trash2, Edit } from 'lucide-react'
import { debounce } from 'lodash'
import debounce from 'lodash/debounce'
interface FilterProps {

View File

@ -1,5 +1,5 @@
import { useMemo, useState } from 'react'
import { debounce } from 'lodash'
import debounce from 'lodash/debounce'
interface FilterPropsBase {
filterId?: string

View File

@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from 'react'
import { formatISODateString } from '@maksit/webui-core'
import { formatISODateString } from '@maks-it.com/webui-core'
interface NormalLabelProps {
type: 'normal'

View File

@ -1,4 +1,4 @@
import type { SearchEntityScopeEntry } from '@maksit/webui-contracts'
import type { SearchEntityScopeEntry } from '@maks-it.com/webui-contracts'
export interface EntityScopesSummaryProps<TScopeEntityType extends number = number> {
entries: SearchEntityScopeEntry<TScopeEntityType>[]

View File

@ -1,5 +1,4 @@
import { useState, useEffect, FC } from 'react'
import { v4 as uuidv4 } from 'uuid'
// Define types for a toast
interface Toast {
@ -9,6 +8,8 @@ interface Toast {
duration?: number;
}
const createToastId = (): string => crypto.randomUUID()
const Toast: FC = () => {
const [toasts, setToasts] = useState<Toast[]>([])
@ -17,7 +18,7 @@ const Toast: FC = () => {
const { message, type, duration } = event.detail
// Add the new toast, avoiding duplicates with same message & type
const id = uuidv4()
const id = createToastId()
setToasts(prev => {
const hasDuplicate = prev.some(t => t.message === message && t.type === type)
if (hasDuplicate) return prev

View File

@ -1,6 +1,6 @@
import React, { useRef, useState } from 'react'
import { ButtonComponent } from './ButtonComponent'
import { TrashIcon } from 'lucide-react'
import { Trash2 } from 'lucide-react'
interface FileUploadComponentProps {
label?: string
@ -163,7 +163,7 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
disabled={disabled || displayFiles.length === 0}
colspan={1}
>
<TrashIcon />
<Trash2 />
</ButtonComponent>
{/* Select files button */}

View File

@ -1,7 +1,7 @@
import { useState, useCallback, ChangeEvent, useEffect, useRef } from 'react'
import type { PagedRequest } from '@maksit/webui-contracts'
import type { SearchResponseBase } from '@maksit/webui-contracts'
import { deepEqual } from '@maksit/webui-core'
import type { PagedRequest } from '@maks-it.com/webui-contracts'
import type { SearchResponseBase } from '@maks-it.com/webui-contracts'
import { deepEqual } from '@maks-it.com/webui-core'
import { SelectBoxComponent } from './SelectBoxComponent'
export type RemoteSelectSearchDataSource<TRequest extends PagedRequest> = (

View File

@ -1,4 +1,4 @@
import { debounce } from 'lodash'
import debounce from 'lodash/debounce'
import { CircleX } from 'lucide-react'
import { ChangeEvent, FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { FieldContainer } from './FieldContainer'

View File

@ -0,0 +1,6 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"ignoreDeprecations": "6.0"
}
}

View File

@ -0,0 +1,48 @@
# @maks-it.com/webui-contracts
Shared TypeScript contracts and Zod schemas for MaksIT WebUI apps (Certs UI, Vault WebUI, and related products).
## Install
```bash
npm install @maks-it.com/webui-contracts zod
```
`zod` is a peer dependency (schemas use Zod v4).
## Contents
| Area | Exports |
|------|---------|
| Paging | `PagedRequest`, `PagedRequestSchema`, `PagedResponse` |
| PATCH | `PatchOperation`, `PatchRequestModelBase`, `PatchRequestModelBaseSchema` |
| API errors | `ProblemDetails` |
| Search | `SearchResponseBase`, `SearchEntityScopeEntry` |
| Identity | `LoginRequest` / `LoginRequestSchema`, `LoginResponse`, `RefreshTokenRequest`, `LogoutRequest`, `Claims` |
| Misc | `TrngResponse`, `RequestModelBase`, `ResponseModelBase` |
## Example
```ts
import {
LoginRequestSchema,
type PagedRequest,
PatchOperation,
} from '@maks-it.com/webui-contracts'
const login = LoginRequestSchema.parse({ username: 'admin', password: '***' })
const page: PagedRequest = {
pageNumber: 1,
pageSize: 25,
filters: 'Name.Contains("cert")',
}
```
## Repository
[github.com/MAKS-IT-COM/maksit-webui](https://github.com/MAKS-IT-COM/maksit-webui) — `src/packages/contracts`
## License
MIT

View File

@ -1,6 +1,6 @@
{
"name": "@maksit/webui-contracts",
"version": "0.1.0",
"name": "@maks-it.com/webui-contracts",
"version": "0.2.0",
"description": "Shared TypeScript contracts for MaksIT WebUI apps",
"type": "module",
"main": "./dist/index.cjs",
@ -18,7 +18,8 @@
"README.md"
],
"scripts": {
"build": "tsup src/index.ts --format esm,cjs --dts --clean",
"build": "tsup src/index.ts --format esm,cjs --dts --clean --tsconfig tsconfig.build.json",
"test": "jest --config ../../jest.config.cjs --testPathPatterns packages/contracts",
"typecheck": "tsc -p tsconfig.json --noEmit",
"clean": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\"",
"prepublishOnly": "npm run build"
@ -33,11 +34,11 @@
"directory": "src/packages/contracts"
},
"peerDependencies": {
"zod": "^4.0.0"
"zod": "^4.4.0"
},
"devDependencies": {
"tsup": "^8.5.0",
"typescript": "^5.9.3",
"zod": "^4.3.6"
"tsup": "^8.5.1",
"typescript": "^6.0.3",
"zod": "^4.4.3"
}
}

View File

@ -1,4 +1,4 @@
import { boolean, number, object, record, string, type ZodType } from 'zod'
import { boolean, intersection, number, object, record, string, type ZodType } from 'zod'
import type { RequestModelBase } from './RequestModelBase'
import { RequestModelBaseSchema } from './RequestModelBase'
@ -11,7 +11,8 @@ export interface PagedRequest extends RequestModelBase {
isAscending?: boolean
}
export const PagedRequestSchema: ZodType<PagedRequest> = RequestModelBaseSchema.and(
export const PagedRequestSchema: ZodType<PagedRequest> = intersection(
RequestModelBaseSchema,
object({
pageSize: number().optional(),
pageNumber: number().optional(),

View File

@ -1,4 +1,4 @@
import z, { object, record, string, type ZodType } from 'zod'
import z, { intersection, object, record, string, type ZodType } from 'zod'
import { PatchOperation } from './PatchOperation'
import { RequestModelBase, RequestModelBaseSchema } from './RequestModelBase'
@ -8,7 +8,8 @@ export interface PatchRequestModelBase extends RequestModelBase {
[key: string]: unknown
}
export const PatchRequestModelBaseSchema: ZodType<PatchRequestModelBase> = RequestModelBaseSchema.and(
export const PatchRequestModelBaseSchema: ZodType<PatchRequestModelBase> = intersection(
RequestModelBaseSchema,
object({
operations: record(string(), z.enum(PatchOperation)).optional(),
})

View File

@ -1,4 +1,4 @@
import { object, RefinementCtx, string, ZodIssueCode, type ZodType } from 'zod'
import { object, RefinementCtx, string, type ZodType } from 'zod'
export interface LoginRequest {
username: string
@ -10,7 +10,7 @@ export interface LoginRequest {
const loginRequestSchemaRefine = (data: LoginRequest, ctx: RefinementCtx) => {
if (data.username === '') {
ctx.addIssue({
code: ZodIssueCode.custom,
code: 'custom',
message: 'Username cannot be empty',
path: ['username'],
})
@ -18,7 +18,7 @@ const loginRequestSchemaRefine = (data: LoginRequest, ctx: RefinementCtx) => {
if (data.password === '') {
ctx.addIssue({
code: ZodIssueCode.custom,
code: 'custom',
message: 'Password cannot be empty',
path: ['password'],
})
@ -26,7 +26,7 @@ const loginRequestSchemaRefine = (data: LoginRequest, ctx: RefinementCtx) => {
if (data.twoFactorCode && data.twoFactorRecoveryCode) {
ctx.addIssue({
code: ZodIssueCode.custom,
code: 'custom',
message: 'Cannot have both twoFactorCode and twoFactorRecoveryCode',
path: ['twoFactorCode', 'twoFactorRecoveryCode'],
})

View File

@ -0,0 +1,73 @@
import { PatchOperation } from './PatchOperation'
import { LoginRequestSchema } from './identity/login/LoginRequest'
import { PagedRequestSchema } from './PagedRequest'
import { PatchRequestModelBaseSchema } from './PatchRequestModelBase'
import { RefreshTokenRequestSchema } from './identity/login/RefreshTokenRequest'
describe('LoginRequestSchema', () => {
it('accepts valid credentials', () => {
const result = LoginRequestSchema.safeParse({
username: 'alice',
password: 'secret',
})
expect(result.success).toBe(true)
})
it('rejects empty username and password', () => {
const result = LoginRequestSchema.safeParse({
username: '',
password: '',
})
expect(result.success).toBe(false)
if (!result.success) {
expect(result.error.issues.map((issue) => issue.message)).toEqual(
expect.arrayContaining(['Username cannot be empty', 'Password cannot be empty'])
)
}
})
it('rejects both two-factor fields together', () => {
const result = LoginRequestSchema.safeParse({
username: 'alice',
password: 'secret',
twoFactorCode: '123456',
twoFactorRecoveryCode: 'recovery',
})
expect(result.success).toBe(false)
})
})
describe('PagedRequestSchema', () => {
it('accepts optional paging fields', () => {
const result = PagedRequestSchema.safeParse({
pageNumber: 2,
pageSize: 25,
sortBy: 'name',
isAscending: true,
})
expect(result.success).toBe(true)
})
})
describe('PatchRequestModelBaseSchema', () => {
it('accepts patch operations keyed by field name', () => {
const result = PatchRequestModelBaseSchema.safeParse({
operations: {
name: PatchOperation.SetField,
},
})
expect(result.success).toBe(true)
})
})
describe('RefreshTokenRequestSchema', () => {
it('requires a non-empty refresh token', () => {
expect(RefreshTokenRequestSchema.safeParse({ refreshToken: 'token' }).success).toBe(true)
expect(RefreshTokenRequestSchema.safeParse({ refreshToken: '' }).success).toBe(false)
})
})

View File

@ -0,0 +1,6 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"ignoreDeprecations": "6.0"
}
}

View File

@ -4,5 +4,6 @@
"rootDir": "src",
"outDir": "dist"
},
"include": ["src"]
"include": ["src"],
"exclude": ["src/**/*.test.ts"]
}

View File

@ -0,0 +1,60 @@
# @maks-it.com/webui-core
Shared utilities, HTTP helpers, and React hooks for MaksIT WebUI apps.
Depends on `@maks-it.com/webui-contracts`. Install peer dependencies in the host app: `react`, `axios`, `zod`.
## Install
```bash
npm install @maks-it.com/webui-core @maks-it.com/webui-contracts axios react zod
```
## Highlights
| Module | Exports |
|--------|---------|
| Deep diff | `deepDelta`, `deltaHasOperations`, collection policies |
| Deep utils | `deepCopy`, `deepEqual`, `deepMerge`, `deepPatternMatch` |
| Forms | `useFormState`, `validateFormState`, `applyFormFieldChange`, `createFormFieldUpdater` |
| DataTable | `mapPagedToDataTable`, `extractPropFilter`, `DataTablePageView` |
| ACL | `parseAclEntry`, `parseAclEntries` |
| HTTP | `createWebUiHttpClient`, auth interceptors, Problem Details helpers |
| Enum / flags | `enumToArr`, `flagsToString`, `hasFlag`, `toggleFlag` |
| Identity storage | `readIdentity`, `writeIdentity`, `removeIdentity` |
## Example — form state
```tsx
import { z } from 'zod'
import { useFormState } from '@maks-it.com/webui-core'
const schema = z.object({ name: z.string().min(1) })
function MyForm() {
const { formState, errors, formIsValid, handleInputChange } = useFormState({
initialState: { name: '' },
validationSchema: schema,
})
// ...
}
```
## Example — PATCH delta
```ts
import { deepDelta, deltaHasOperations } from '@maks-it.com/webui-core'
const delta = deepDelta(formState, backupState, { arrays: { items: { identityKey: 'id' } } })
if (deltaHasOperations(delta)) {
await api.patch('/resource', delta)
}
```
## Repository
[github.com/MAKS-IT-COM/maksit-webui](https://github.com/MAKS-IT-COM/maksit-webui) — `src/packages/core`
## License
MIT

View File

@ -1,6 +1,6 @@
{
"name": "@maksit/webui-core",
"version": "0.1.0",
"name": "@maks-it.com/webui-core",
"version": "0.2.0",
"description": "Shared utilities and hooks for MaksIT WebUI apps",
"type": "module",
"main": "./dist/index.cjs",
@ -18,7 +18,8 @@
"README.md"
],
"scripts": {
"build": "tsup src/index.ts --format esm,cjs --dts --clean",
"build": "tsup src/index.ts --format esm,cjs --dts --clean --tsconfig tsconfig.build.json",
"test": "jest --config ../../jest.config.cjs",
"typecheck": "tsc -p tsconfig.json --noEmit",
"clean": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\"",
"prepublishOnly": "npm run build"
@ -33,20 +34,20 @@
"directory": "src/packages/core"
},
"dependencies": {
"@maksit/webui-contracts": "^0.1.0",
"date-fns": "^4.1.0"
"@maks-it.com/webui-contracts": "^0.2.0",
"date-fns": "^4.3.0"
},
"peerDependencies": {
"axios": "^1.7.0",
"axios": "^1.16.0",
"react": "^18.0.0 || ^19.0.0",
"zod": "^4.0.0"
"zod": "^4.4.0"
},
"devDependencies": {
"@types/react": "^19.2.14",
"axios": "^1.13.2",
"react": "^19.2.4",
"tsup": "^8.5.0",
"typescript": "^5.9.3",
"zod": "^4.3.6"
"@types/react": "^19.2.15",
"axios": "^1.16.1",
"react": "^19.2.6",
"tsup": "^8.5.1",
"typescript": "^6.0.3",
"zod": "^4.4.3"
}
}

View File

@ -0,0 +1,31 @@
import { ScopePermission } from '../../types/ScopePermissions'
import { parseAclEntry, parseAclEntries } from './parseAclEntry'
const entityTypeMap = { O: 1, V: 2 } as const
describe('parseAclEntry', () => {
it('parses a valid ACL entry', () => {
expect(parseAclEntry('O:entity-123:3', entityTypeMap)).toEqual({
entityType: 1,
entityId: 'entity-123',
scope: ScopePermission.Read | ScopePermission.Write,
})
})
it('returns null for malformed entries', () => {
expect(parseAclEntry('invalid', entityTypeMap)).toBeNull()
expect(parseAclEntry('X:entity:1', entityTypeMap)).toBeNull()
expect(parseAclEntry(null as unknown as string, entityTypeMap)).toBeNull()
})
})
describe('parseAclEntries', () => {
it('keeps only valid entries in order', () => {
expect(
parseAclEntries(['O:a:1', 'bad', 'V:b:2'], entityTypeMap)
).toEqual([
{ entityType: 1, entityId: 'a', scope: ScopePermission.Read },
{ entityType: 2, entityId: 'b', scope: ScopePermission.Write },
])
})
})

View File

@ -0,0 +1,22 @@
import { extractPropFilter } from './dataTableFilters'
describe('extractPropFilter', () => {
it('extracts Contains filter values', () => {
expect(extractPropFilter('CommonName.Contains("example")', 'CommonName')).toBe('example')
})
it('extracts StartsWith and EndsWith filter values', () => {
expect(extractPropFilter('Host.StartsWith("api")', 'Host')).toBe('api')
expect(extractPropFilter('Host.EndsWith(".com")', 'Host')).toBe('.com')
})
it('is case-insensitive for property and operator names', () => {
expect(extractPropFilter('commonname.contains("test")', 'CommonName')).toBe('test')
})
it('returns undefined for empty or non-matching filters', () => {
expect(extractPropFilter(undefined, 'CommonName')).toBeUndefined()
expect(extractPropFilter(' ', 'CommonName')).toBeUndefined()
expect(extractPropFilter('OtherField.Contains("x")', 'CommonName')).toBeUndefined()
})
})

View File

@ -0,0 +1,51 @@
import type { PagedResponse } from '@maks-it.com/webui-contracts'
import { mapPagedToDataTable } from './dataTablePaged'
describe('mapPagedToDataTable', () => {
it('returns an empty page for missing responses', () => {
expect(mapPagedToDataTable(undefined)).toEqual({
items: [],
pageNumber: 1,
pageSize: 0,
totalCount: 0,
totalPages: 1,
hasPreviousPage: false,
hasNextPage: false,
})
})
it('maps paged response fields with defaults', () => {
expect(
mapPagedToDataTable({
items: [{ id: '1' }],
pageNumber: 2,
pageSize: 25,
totalCount: 100,
totalPages: 4,
hasPreviousPage: true,
hasNextPage: true,
})
).toEqual({
items: [{ id: '1' }],
pageNumber: 2,
pageSize: 25,
totalCount: 100,
totalPages: 4,
hasPreviousPage: true,
hasNextPage: true,
})
})
it('fills in defaults for partial responses', () => {
const partial = { items: [] } as PagedResponse<never>
expect(mapPagedToDataTable(partial)).toEqual({
items: [],
pageNumber: 1,
pageSize: 0,
totalCount: 0,
totalPages: 1,
hasPreviousPage: false,
hasNextPage: false,
})
})
})

View File

@ -1,4 +1,4 @@
import type { PagedResponse } from '@maksit/webui-contracts'
import type { PagedResponse } from '@maks-it.com/webui-contracts'
/**
* Virtualized DataTable view model used by client paging and search helpers.

View File

@ -0,0 +1,14 @@
import { isValidISODateString } from './isValidDateString'
describe('isValidISODateString', () => {
it('accepts valid ISO date strings', () => {
expect(isValidISODateString('2024-01-15')).toBe(true)
expect(isValidISODateString('2024-01-15T10:30:00Z')).toBe(true)
})
it('rejects empty or invalid strings', () => {
expect(isValidISODateString('')).toBe(false)
expect(isValidISODateString('not-a-date')).toBe(false)
expect(isValidISODateString('2024-13-40')).toBe(false)
})
})

View File

@ -0,0 +1,114 @@
import { COLLECTION_ITEM_OPERATION, PatchOperation } from '@maks-it.com/webui-contracts'
import { deepDelta, deltaHasOperations } from './deepDelta'
describe('deepDelta', () => {
it('detects primitive field changes', () => {
const backup = { name: 'old', count: 1 }
const form = { name: 'new', count: 1 }
const delta = deepDelta(form, backup)
expect(delta.name).toBe('new')
expect(delta.operations?.name).toBe(PatchOperation.SetField)
expect(delta.count).toBeUndefined()
})
it('marks nullish values as RemoveField', () => {
const backup = { name: 'value', optional: 'present' }
const form = { name: 'value', optional: null }
const delta = deepDelta(form, backup)
expect(delta.optional).toBeUndefined()
expect(delta.operations?.optional).toBe(PatchOperation.RemoveField)
})
it('replaces primitive arrays when values differ', () => {
const backup = { tags: ['a', 'b'] }
const form = { tags: ['a', 'b', 'c'] }
const delta = deepDelta(form, backup)
expect(delta.tags).toEqual(['a', 'b', 'c'])
expect(delta.operations?.tags).toBe(PatchOperation.SetField)
})
it('skips unchanged primitive arrays', () => {
const backup = { tags: ['a', 'b'] }
const form = { tags: ['b', 'a'] }
const delta = deepDelta({ tags: ['a', 'b'] }, { tags: ['a', 'b'] })
expect(delta.tags).toBeUndefined()
expect(delta.operations?.tags).toBeUndefined()
})
it('diffs identifiable object arrays by id', () => {
const backup = {
items: [{ id: '1', name: 'first' }, { id: '2', name: 'second' }],
}
const form = {
items: [{ id: '1', name: 'updated' }, { id: '3', name: 'new' }],
}
const delta = deepDelta(form, backup)
expect(delta.items).toEqual(
expect.arrayContaining([
expect.objectContaining({ id: '1', name: 'updated' }),
expect.objectContaining({
id: '3',
name: 'new',
operations: { [COLLECTION_ITEM_OPERATION]: PatchOperation.AddToCollection },
}),
expect.objectContaining({
id: '2',
operations: { [COLLECTION_ITEM_OPERATION]: PatchOperation.RemoveFromCollection },
}),
])
)
})
it('uses identityKey when items have no id', () => {
const backup = { hostnames: [{ hostname: 'a.example.com', enabled: true }] }
const form = { hostnames: [{ hostname: 'a.example.com', enabled: false }] }
const delta = deepDelta(form, backup, {
arrays: { hostnames: { identityKey: 'hostname', idFieldKey: 'hostname' } },
})
expect(delta.hostnames).toEqual([
expect.objectContaining({
hostname: 'a.example.com',
enabled: false,
operations: expect.objectContaining({ enabled: PatchOperation.SetField }),
}),
])
})
})
describe('deltaHasOperations', () => {
it('returns false for empty delta', () => {
expect(deltaHasOperations({})).toBe(false)
})
it('returns true when top-level operations exist', () => {
expect(deltaHasOperations({ operations: { name: PatchOperation.SetField } })).toBe(true)
})
it('returns true for nested object operations', () => {
expect(
deltaHasOperations({
nested: { operations: { field: PatchOperation.SetField } },
})
).toBe(true)
})
it('returns true for array item operations', () => {
expect(
deltaHasOperations({
items: [{ operations: { [COLLECTION_ITEM_OPERATION]: PatchOperation.AddToCollection } }],
})
).toBe(true)
})
})

View File

@ -1,4 +1,4 @@
import { COLLECTION_ITEM_OPERATION, PatchOperation } from '@maksit/webui-contracts'
import { COLLECTION_ITEM_OPERATION, PatchOperation } from '@maks-it.com/webui-contracts'
import { deepCopy } from './deepCopy'
import { deepEqual } from './deepEqual'

View File

@ -0,0 +1,39 @@
import { deepEqual, deepEqualArrays } from './deepEqual'
describe('deepEqual', () => {
it('returns true for identical primitives', () => {
expect(deepEqual(1, 1)).toBe(true)
expect(deepEqual('a', 'a')).toBe(true)
expect(deepEqual(null, null)).toBe(true)
})
it('returns false for different primitives', () => {
expect(deepEqual(1, 2)).toBe(false)
expect(deepEqual('a', 'b')).toBe(false)
expect(deepEqual(null, undefined)).toBe(false)
})
it('compares nested objects by value', () => {
expect(deepEqual({ a: 1, b: { c: 2 } }, { a: 1, b: { c: 2 } })).toBe(true)
expect(deepEqual({ a: 1 }, { a: 2 })).toBe(false)
expect(deepEqual({ a: 1 }, { a: 1, b: 2 })).toBe(false)
})
it('compares arrays regardless of element order', () => {
expect(deepEqual([1, 2, 3], [3, 2, 1])).toBe(true)
expect(deepEqual([{ id: 1 }, { id: 2 }], [{ id: 2 }, { id: 1 }])).toBe(true)
expect(deepEqual([1, 2], [1, 2, 3])).toBe(false)
})
})
describe('deepEqualArrays', () => {
it('returns true for empty arrays', () => {
expect(deepEqualArrays([], [])).toBe(true)
})
it('matches multiset equality', () => {
expect(deepEqualArrays(['a', 'b'], ['b', 'a'])).toBe(true)
expect(deepEqualArrays([1, 1, 2], [2, 1, 1])).toBe(true)
expect(deepEqualArrays([1, 2], [1, 3])).toBe(false)
})
})

View File

@ -0,0 +1,32 @@
import { hasAnyFlag } from './hasAnyFlag'
import { hasFlag } from './hasFlag'
import { toggleFlag } from './toggleFlag'
describe('hasFlag', () => {
it('returns true when all flag bits are set', () => {
expect(hasFlag(0b101, 0b001)).toBe(true)
expect(hasFlag(0b111, 0b101)).toBe(true)
})
it('returns false when any flag bit is missing', () => {
expect(hasFlag(0b100, 0b101)).toBe(false)
expect(hasFlag(0, 0b001)).toBe(false)
})
})
describe('hasAnyFlag', () => {
it('returns true when any overlapping bit is set', () => {
expect(hasAnyFlag(0b100, 0b101)).toBe(true)
expect(hasAnyFlag(0b010, 0b001)).toBe(false)
})
})
describe('toggleFlag', () => {
it('adds the flag when not fully set', () => {
expect(toggleFlag(0b100, 0b001)).toBe(0b101)
})
it('removes the flag when fully set', () => {
expect(toggleFlag(0b101, 0b001)).toBe(0b100)
})
})

View File

@ -0,0 +1,15 @@
import { isGuid } from './isGuid'
describe('isGuid', () => {
it('accepts valid GUIDs', () => {
expect(isGuid('550e8400-e29b-41d4-a716-446655440000')).toBe(true)
expect(isGuid('550E8400-E29B-41D4-A716-446655440000')).toBe(true)
})
it('rejects invalid GUIDs', () => {
expect(isGuid('')).toBe(false)
expect(isGuid('not-a-guid')).toBe(false)
expect(isGuid('550e8400-e29b-41d4-a716')).toBe(false)
expect(isGuid('550e8400e29b41d4a716446655440000')).toBe(false)
})
})

View File

@ -0,0 +1,31 @@
import { extractFilenameFromHeaders } from './extractFilenameFromHeaders'
describe('extractFilenameFromHeaders', () => {
it('returns fallback when header is missing', () => {
expect(extractFilenameFromHeaders({}, 'default.bin')).toBe('default.bin')
})
it('parses RFC 5987 encoded filenames', () => {
expect(
extractFilenameFromHeaders({
'content-disposition': "attachment; filename*=UTF-8''report%20file.pdf",
})
).toBe('report file.pdf')
})
it('parses quoted filenames', () => {
expect(
extractFilenameFromHeaders({
'content-disposition': 'attachment; filename="archive.zip"',
})
).toBe('archive.zip')
})
it('parses plain filenames', () => {
expect(
extractFilenameFromHeaders({
'content-disposition': 'attachment; filename=download.bin',
})
).toBe('download.bin')
})
})

View File

@ -0,0 +1,24 @@
import { z } from 'zod'
import { validateFormState } from './validateFormState'
describe('validateFormState', () => {
const schema = z.object({
name: z.string().min(1, 'Name is required'),
age: z.number().min(0),
})
it('returns valid result for passing form state', () => {
const result = validateFormState({ name: 'Alice', age: 30 }, schema)
expect(result.formIsValid).toBe(true)
expect(result.errors).toEqual({ name: '', age: '' })
})
it('returns field errors for invalid form state', () => {
const result = validateFormState({ name: '', age: -1 }, schema)
expect(result.formIsValid).toBe(false)
expect(result.errors.name).toBe('Name is required')
expect(result.errors.age).toBeTruthy()
})
})

View File

@ -1,5 +1,5 @@
import type { AxiosError } from 'axios'
import type { ProblemDetails } from '@maksit/webui-contracts'
import type { ProblemDetails } from '@maks-it.com/webui-contracts'
import { formatProblemDetailsMessage } from './problemDetails'
/** Shows toast(s) for problem+json and 401 responses. */

View File

@ -0,0 +1,26 @@
import { formatProblemDetailsMessage } from './problemDetails'
describe('formatProblemDetailsMessage', () => {
it('combines detail and field errors', () => {
const message = formatProblemDetailsMessage({
title: 'Validation failed',
detail: 'One or more fields are invalid.',
errors: {
name: ['Name is required'],
email: ['Invalid email', 'Email is too long'],
},
})
expect(message).toBe(
'One or more fields are invalid. name: Name is required; email: Invalid email; email: Email is too long'
)
})
it('falls back to title when detail and errors are empty', () => {
expect(formatProblemDetailsMessage({ title: 'Bad Request' })).toBe('Bad Request')
})
it('uses a generic message when nothing else is available', () => {
expect(formatProblemDetailsMessage({})).toBe('Request failed')
})
})

View File

@ -1,4 +1,4 @@
import type { ProblemDetails } from '@maksit/webui-contracts'
import type { ProblemDetails } from '@maks-it.com/webui-contracts'
/** Builds a user-facing message from RFC 7807 problem details. */
export function formatProblemDetailsMessage(problem: ProblemDetails): string {

View File

@ -1,4 +1,4 @@
import type { LoginResponse } from '@maksit/webui-contracts'
import type { LoginResponse } from '@maks-it.com/webui-contracts'
const readIdentity = () => {
const json = localStorage.getItem('identity')

View File

@ -3,5 +3,5 @@ export type {
FormValidationResult,
FormValidationSchema,
} from './FormValidationSchema'
export type { PagedResponse } from '@maksit/webui-contracts'
export type { PagedResponse } from '@maks-it.com/webui-contracts'
export { ScopePermission } from './ScopePermissions'

View File

@ -0,0 +1,6 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"ignoreDeprecations": "6.0"
}
}

View File

@ -4,5 +4,6 @@
"rootDir": "src",
"outDir": "dist"
},
"include": ["src"]
"include": ["src"],
"exclude": ["src/**/*.test.ts"]
}

11
src/tsconfig.jest.json Normal file
View File

@ -0,0 +1,11 @@
{
"extends": "./tsconfig.base.json",
"compilerOptions": {
"noEmit": true,
"types": ["jest", "node"]
},
"include": [
"packages/core/src/**/*.ts",
"packages/contracts/src/**/*.ts"
]
}

View File

@ -3,263 +3,20 @@
<#
.SYNOPSIS
Generates SVG coverage badges for README.
Legacy entry point forwards to the Run-Tests plugin engine.
.DESCRIPTION
This script runs unit tests via TestRunner.psm1, then generates shields.io-style
SVG badges for line, branch, and method coverage.
Configuration is stored in scriptsettings.json:
- openReport : Generate and open full HTML report (true/false)
- paths.testProjects : Array of relative paths to test projects (preferred)
- paths.testProject : Single test project path (legacy; use testProjects)
- paths.badgesDir : Relative path to badges output directory
- badges : Array of badges to generate (name, label, metric)
- colorThresholds : Coverage percentages for badge colors
Badge colors based on coverage:
- brightgreen (>=80%), green (>=60%), yellowgreen (>=40%)
- yellow (>=20%), orange (>=10%), red (<10%)
If openReport is true, ReportGenerator is required:
dotnet tool install -g dotnet-reportgenerator-globaltool
.EXAMPLE
pwsh -File .\Generate-CoverageBadges.ps1
Runs tests and generates coverage badges (and optionally HTML report if configured).
.OUTPUTS
SVG badge files in the configured badges directory.
.NOTES
Author: MaksIT
Requires: .NET SDK, Coverlet (included in test project)
Generate-CoverageBadges.ps1 is kept for backward compatibility.
Configure plugins in src/Run-Tests/scriptsettings.json.
#>
$ErrorActionPreference = "Stop"
# Get the directory of the current script (for loading settings and relative paths)
$ScriptDir = $PSScriptRoot
$UtilsDir = Split-Path $ScriptDir -Parent
#region Import Modules
# Import TestRunner module (executes tests and collects coverage metrics)
$testRunnerModulePath = Join-Path $UtilsDir "TestRunner.psm1"
if (-not (Test-Path $testRunnerModulePath)) {
Write-Error "TestRunner module not found at: $testRunnerModulePath"
exit 1
}
Import-Module $testRunnerModulePath -Force
# Import shared ScriptConfig module (settings + command validation helpers)
$scriptConfigModulePath = Join-Path $UtilsDir "ScriptConfig.psm1"
if (-not (Test-Path $scriptConfigModulePath)) {
Write-Error "ScriptConfig module not found at: $scriptConfigModulePath"
exit 1
}
Import-Module $scriptConfigModulePath -Force
# Import shared Logging module (timestamped/aligned output)
$loggingModulePath = Join-Path $UtilsDir "Logging.psm1"
if (-not (Test-Path $loggingModulePath)) {
Write-Error "Logging module not found at: $loggingModulePath"
exit 1
}
Import-Module $loggingModulePath -Force
#endregion
#region Load Settings
$Settings = Get-ScriptSettings -ScriptDir $ScriptDir
$thresholds = $Settings.colorThresholds
#endregion
#region Configuration
# Runtime options from settings
$OpenReport = if ($null -ne $Settings.openReport) { [bool]$Settings.openReport } else { $false }
# Resolve configured paths to absolute paths (one or more test projects)
$testProjectPaths = [System.Collections.Generic.List[string]]::new()
$pathsNode = $Settings.paths
if ($pathsNode.PSObject.Properties.Name -contains 'testProjects' -and $pathsNode.testProjects) {
foreach ($rel in @($pathsNode.testProjects)) {
if ([string]::IsNullOrWhiteSpace([string]$rel)) { continue }
$testProjectPaths.Add([System.IO.Path]::GetFullPath((Join-Path $ScriptDir $rel.Trim())))
}
}
if ($testProjectPaths.Count -eq 0 -and $pathsNode.testProject) {
$testProjectPaths.Add([System.IO.Path]::GetFullPath((Join-Path $ScriptDir $pathsNode.testProject)))
}
if ($testProjectPaths.Count -eq 0) {
Write-Error "Configure paths.testProjects (array of relative paths) or paths.testProject (single path) in scriptsettings.json."
$runTestsScript = Join-Path (Split-Path $PSScriptRoot -Parent) "Run-Tests\Run-Tests.ps1"
if (-not (Test-Path $runTestsScript -PathType Leaf)) {
Write-Error "Run-Tests engine not found at: $runTestsScript"
exit 1
}
$BadgesDir = [System.IO.Path]::GetFullPath((Join-Path $ScriptDir $Settings.paths.badgesDir))
# Ensure badges directory exists
if (-not (Test-Path $BadgesDir)) {
New-Item -ItemType Directory -Path $BadgesDir | Out-Null
}
#endregion
#region Helpers
# Maps a coverage percentage to a shields.io color using configured thresholds.
function Get-BadgeColor {
param([double]$percentage)
if ($percentage -ge $thresholds.brightgreen) { return "brightgreen" }
if ($percentage -ge $thresholds.green) { return "green" }
if ($percentage -ge $thresholds.yellowgreen) { return "yellowgreen" }
if ($percentage -ge $thresholds.yellow) { return "yellow" }
if ($percentage -ge $thresholds.orange) { return "orange" }
return "red"
}
# Builds a shields.io-like SVG badge string for one metric.
function New-Badge {
param(
[string]$label,
[string]$value,
[string]$color
)
# Calculate widths (approximate character width of 6.5px for the font)
$labelWidth = [math]::Max(($label.Length * 6.5) + 10, 50)
$valueWidth = [math]::Max(($value.Length * 6.5) + 10, 40)
$totalWidth = $labelWidth + $valueWidth
$labelX = $labelWidth / 2
$valueX = $labelWidth + ($valueWidth / 2)
$colorMap = @{
"brightgreen" = "#4c1"
"green" = "#97ca00"
"yellowgreen" = "#a4a61d"
"yellow" = "#dfb317"
"orange" = "#fe7d37"
"red" = "#e05d44"
}
$hexColor = $colorMap[$color]
if (-not $hexColor) { $hexColor = "#9f9f9f" }
return @"
<svg xmlns="http://www.w3.org/2000/svg" width="$totalWidth" height="20" role="img" aria-label="$label`: $value">
<title>$label`: $value</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
</linearGradient>
<clipPath id="r">
<rect width="$totalWidth" height="20" rx="3" fill="#fff"/>
</clipPath>
<g clip-path="url(#r)">
<rect width="$labelWidth" height="20" fill="#555"/>
<rect x="$labelWidth" width="$valueWidth" height="20" fill="$hexColor"/>
<rect width="$totalWidth" height="20" fill="url(#s)"/>
</g>
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
<text aria-hidden="true" x="$labelX" y="15" fill="#010101" fill-opacity=".3">$label</text>
<text x="$labelX" y="14" fill="#fff">$label</text>
<text aria-hidden="true" x="$valueX" y="15" fill="#010101" fill-opacity=".3">$value</text>
<text x="$valueX" y="14" fill="#fff">$value</text>
</g>
</svg>
"@
}
#endregion
#region Main
#region Test And Coverage
$invokeCoverageParams = @{
TestProjectPath = @($testProjectPaths)
KeepResults = $OpenReport
}
# Keep single-project results next to that project; for several projects use a shared folder under this script.
if ($testProjectPaths.Count -gt 1) {
$invokeCoverageParams.ResultsDirectory = Join-Path $ScriptDir "TestResults"
}
$coverage = Invoke-TestsWithCoverage @invokeCoverageParams
if (-not $coverage.Success) {
Write-Error "Tests failed: $($coverage.Error)"
exit 1
}
Write-Log -Level "OK" -Message "Tests passed!"
$metrics = @{
"line" = $coverage.LineRate
"branch" = $coverage.BranchRate
"method" = $coverage.MethodRate
}
#endregion
#region Generate Badges
Write-LogStep -Message "Generating coverage badges..."
foreach ($badge in $Settings.badges) {
$metricValue = $metrics[$badge.metric]
$color = Get-BadgeColor $metricValue
$svg = New-Badge -label $badge.label -value "$metricValue%" -color $color
$path = Join-Path $BadgesDir $badge.name
$svg | Out-File -FilePath $path -Encoding utf8NoBOM
Write-Log -Level "OK" -Message "$($badge.name): $($badge.label) = $metricValue%"
}
#endregion
#region Summary
Write-Log -Level "INFO" -Message "Coverage Summary:"
Write-Log -Level "INFO" -Message "Line Coverage: $($coverage.LineRate)%"
Write-Log -Level "INFO" -Message "Branch Coverage: $($coverage.BranchRate)%"
Write-Log -Level "INFO" -Message "Method Coverage: $($coverage.MethodRate)% ($($coverage.CoveredMethods) of $($coverage.TotalMethods) methods)"
Write-Log -Level "OK" -Message "Badges generated in: $BadgesDir"
Write-Log -Level "STEP" -Message "Commit the badges/ folder to update README."
#endregion
#region Optional Html Report
if ($OpenReport -and $coverage.CoverageFile) {
Write-LogStep -Message "Generating HTML report..."
Assert-Command reportgenerator
# Cobertura file(s): single path, or semicolon-separated list from merged runs.
$firstCobertura = if ($coverage.CoverageFiles -and $coverage.CoverageFiles.Count -gt 0) {
$coverage.CoverageFiles[0]
}
else {
($coverage.CoverageFile -split ';')[0].Trim()
}
$ResultsDir = Split-Path (Split-Path $firstCobertura -Parent) -Parent
$ReportDir = Join-Path $ResultsDir "report"
$reportGenArgs = @(
"-reports:$($coverage.CoverageFile)"
"-targetdir:$ReportDir"
"-reporttypes:Html"
)
& reportgenerator @reportGenArgs
$IndexFile = Join-Path $ReportDir "index.html"
if (Test-Path $IndexFile) {
Start-Process $IndexFile
}
Write-Log -Level "INFO" -Message "TestResults kept for HTML report viewing."
}
#endregion
#endregion
& pwsh -NoProfile -ExecutionPolicy Bypass -File $runTestsScript
exit $LASTEXITCODE

View File

@ -1,47 +1,6 @@
{
"$schema": "https://json-schema.org/draft-07/schema",
"title": "Generate Coverage Badges Script Settings",
"description": "Configuration for Generate-CoverageBadges.ps1 script",
"openReport": false,
"paths": {
"testProjects": [
"..\\..\\src\\MaksIT.Core.Tests"
],
"badgesDir": "..\\..\\assets\\badges"
},
"badges": [
{
"name": "coverage-lines.svg",
"label": "Line Coverage",
"metric": "line"
},
{
"name": "coverage-branches.svg",
"label": "Branch Coverage",
"metric": "branch"
},
{
"name": "coverage-methods.svg",
"label": "Method Coverage",
"metric": "method"
}
],
"colorThresholds": {
"brightgreen": 80,
"green": 60,
"yellowgreen": 40,
"yellow": 20,
"orange": 10,
"red": 0
},
"_comments": {
"openReport": "If true, generate and open full HTML coverage report (requires reportgenerator tool).",
"paths": {
"testProjects": "Array of relative paths (from this folder) to test project directories or .csproj files. All are run; badge metrics aggregate Cobertura line/branch/method stats.",
"testProject": "Optional legacy single path if testProjects is omitted.",
"badgesDir": "Relative path where SVG coverage badges are written."
},
"badges": "List of output badges. Each entry maps a metric key (line|branch|method) to filename and label.",
"colorThresholds": "Coverage percentage thresholds used to pick badge colors."
}
"description": "Legacy settings file. Use utils/Run-Tests/scriptsettings.json instead.",
"_forwardTo": "..\\Run-Tests\\scriptsettings.json"
}

View File

@ -149,13 +149,21 @@ function Invoke-Plugin {
throw "Each images[] entry must define 'service' and 'dockerfile'."
}
$service = [string]$img.service
$dockerfileRel = [string]$img.dockerfile
$dockerfilePath = [System.IO.Path]::GetFullPath((Join-Path $contextPath $dockerfileRel))
$imgContextPath = $contextPath
if ($img.PSObject.Properties.Name -contains 'contextPath' -and -not [string]::IsNullOrWhiteSpace([string]$img.contextPath)) {
$imgContextPath = [System.IO.Path]::GetFullPath((Join-Path $scriptDir ([string]$img.contextPath)))
if (-not (Test-Path $imgContextPath -PathType Container)) {
throw "Docker context directory not found for image '$service': $imgContextPath"
}
}
$dockerfilePath = [System.IO.Path]::GetFullPath((Join-Path $imgContextPath $dockerfileRel))
if (-not (Test-Path $dockerfilePath -PathType Leaf)) {
throw "Dockerfile not found: $dockerfilePath"
}
$service = [string]$img.service
$baseName = "$registryUrl/$($pluginSettings.projectName)/$service"
$versionEnvFiles = @()
@ -165,7 +173,7 @@ function Invoke-Plugin {
continue
}
$envFilePath = [System.IO.Path]::GetFullPath((Join-Path $contextPath ([string]$relativeEnvFile)))
$envFilePath = [System.IO.Path]::GetFullPath((Join-Path $imgContextPath ([string]$relativeEnvFile)))
if (-not (Test-Path -LiteralPath $envFilePath -PathType Leaf)) {
throw "Configured versionEnvFiles entry not found: $envFilePath"
}
@ -187,7 +195,7 @@ function Invoke-Plugin {
$primaryRef = "${baseName}:$($imageTags[0])"
Write-Log -Level "STEP" -Message "Building $primaryRef ..."
docker build -t $primaryRef -f $dockerfilePath $contextPath
docker build -t $primaryRef -f $dockerfilePath $imgContextPath
if ($LASTEXITCODE -ne 0) {
throw "Docker build failed for $primaryRef"
}

View File

@ -46,6 +46,32 @@ function Get-GitHubRepositoryInternal {
throw "Could not parse GitHub repo from source: $repoSource. Configure plugins[].repository with 'owner/repo' or a GitHub URL."
}
function Get-ChangelogVersionHeaderPatternInternal {
# Keep a Changelog: ## [1.0.0] - 2026-05-24, bare ## 1.0.0 - 2026-05-24, or legacy ## v1.0.0
return '(?m)^##\s+(?:\[(\d+\.\d+\.\d+)\]|v(\d+\.\d+\.\d+)|(\d+\.\d+\.\d+)(?:\s*-\s*\d{4}-\d{2}-\d{2})?\s*$)'
}
function Get-LatestChangelogVersionInternal {
param(
[Parameter(Mandatory = $true)]
[string]$ReleaseNotesContent
)
$match = [regex]::Match($ReleaseNotesContent, (Get-ChangelogVersionHeaderPatternInternal))
if (-not $match.Success) {
return $null
}
foreach ($groupIndex in 1..3) {
$version = $match.Groups[$groupIndex].Value
if (-not [string]::IsNullOrEmpty($version)) {
return $version
}
}
return $null
}
function Get-ReleaseNotesInternal {
param(
[Parameter(Mandatory = $true)]
@ -61,11 +87,11 @@ function Get-ReleaseNotesInternal {
}
$releaseNotesContent = Get-Content $ReleaseNotesFile -Raw
if ($releaseNotesContent -notmatch '##\s+v(\d+\.\d+\.\d+)') {
$releaseNotesVersion = Get-LatestChangelogVersionInternal -ReleaseNotesContent $releaseNotesContent
if ([string]::IsNullOrWhiteSpace($releaseNotesVersion)) {
throw "No version entry found in the configured release notes source."
}
$releaseNotesVersion = $Matches[1]
if ($releaseNotesVersion -ne $Version) {
throw "Project version ($Version) does not match the latest release notes version ($releaseNotesVersion)."
}
@ -73,7 +99,9 @@ function Get-ReleaseNotesInternal {
Write-Log -Level "OK" -Message " Release notes version matches: v$releaseNotesVersion"
Write-Log -Level "STEP" -Message "Extracting release notes..."
$pattern = "(?ms)^##\s+v$([regex]::Escape($Version))\b.*?(?=^##\s+v\d+\.\d+\.\d+|\Z)"
$escapedVersion = [regex]::Escape($Version)
$nextHeaderPattern = '(?m)^##\s+(?:\[\d+\.\d+\.\d+\]|v\d+\.\d+\.\d+|\d+\.\d+\.\d+(?:\s*-\s*\d{4}-\d{2}-\d{2})?\s*$)'
$pattern = "(?ms)^##\s+(?:\[$escapedVersion\]|v$escapedVersion|$escapedVersion(?:\s*-\s*\d{4}-\d{2}-\d{2})?).*?(?=$nextHeaderPattern|\Z)"
$match = [regex]::Match($releaseNotesContent, $pattern)
if (-not $match.Success) {
@ -133,8 +161,17 @@ function Invoke-Plugin {
}
}
$requireReleaseAssets = $true
if ($null -ne $pluginSettings.requireReleaseAssets) {
$requireReleaseAssets = [bool]$pluginSettings.requireReleaseAssets
}
if ($releaseAssetPaths.Count -eq 0 -and $requireReleaseAssets) {
throw "GitHub release requires at least one prepared release asset (set requireReleaseAssets: false for notes-only npm releases)."
}
if ($releaseAssetPaths.Count -eq 0) {
throw "GitHub release requires at least one prepared release asset."
Write-Log -Level "INFO" -Message " Notes-only GitHub release (requireReleaseAssets: false)."
}
$repo = Get-GitHubRepositoryInternal -ConfiguredRepository $configuredRepository

View File

@ -35,7 +35,8 @@ function Invoke-Plugin {
$workspaceRoot = $null
if ($pluginSettings.workspaceRoot) {
$workspaceRoot = (Resolve-RelativePaths -Value $pluginSettings.workspaceRoot -BasePath $shared.scriptDir)[0]
$workspaceRoots = @(Resolve-RelativePaths -Value $pluginSettings.workspaceRoot -BasePath $shared.scriptDir)
$workspaceRoot = $workspaceRoots[0]
}
elseif ($shared.PSObject.Properties['npmWorkspaceRoot'] -and -not [string]::IsNullOrWhiteSpace([string]$shared.npmWorkspaceRoot)) {
$workspaceRoot = [string]$shared.npmWorkspaceRoot

View File

@ -45,7 +45,8 @@ function Invoke-Plugin {
$workspaceRoot = $null
if ($pluginSettings.workspaceRoot) {
$workspaceRoot = (Resolve-RelativePaths -Value $pluginSettings.workspaceRoot -BasePath $shared.scriptDir)[0]
$workspaceRoots = @(Resolve-RelativePaths -Value $pluginSettings.workspaceRoot -BasePath $shared.scriptDir)
$workspaceRoot = $workspaceRoots[0]
}
elseif ($shared.PSObject.Properties['npmWorkspaceRoot'] -and -not [string]::IsNullOrWhiteSpace([string]$shared.npmWorkspaceRoot)) {
$workspaceRoot = [string]$shared.npmWorkspaceRoot

View File

@ -68,12 +68,11 @@ function Invoke-Plugin {
$pluginSettings = $Settings
$shared = $Settings.context
$packageJsonPath = if ($pluginSettings.packageJsonPath) {
(Resolve-RelativePaths -Value $pluginSettings.packageJsonPath -BasePath $shared.scriptDir)[0]
}
else {
$packageJsonPaths = @(Resolve-RelativePaths -Value $pluginSettings.packageJsonPath -BasePath $shared.scriptDir)
if ($packageJsonPaths.Count -eq 0) {
throw "NpmReleaseVersion plugin requires 'packageJsonPath' in scriptsettings.json."
}
$packageJsonPath = $packageJsonPaths[0]
$version = Get-PackageJsonVersionInternal -PackageJsonPath $packageJsonPath
$syncWorkspaceVersions = $false

View File

@ -22,7 +22,7 @@ if (-not (Get-Command Get-PluginStageLabel -ErrorAction SilentlyContinue) -or -n
}
}
if (-not (Get-Command Resolve-DotNetReleaseVersion -ErrorAction SilentlyContinue)) {
if (-not (Get-Command Resolve-ReleaseVersion -ErrorAction SilentlyContinue)) {
$releaseContextModulePath = Join-Path $PSScriptRoot "ReleaseContext.psm1"
if (Test-Path $releaseContextModulePath -PathType Leaf) {
Import-Module $releaseContextModulePath -Force
@ -79,7 +79,9 @@ function New-EngineContext {
[psobject]$Settings
)
$version = (Resolve-DotNetReleaseVersion -Plugins $Plugins -ScriptDir $ScriptDir).version
$resolvedVersion = Resolve-ReleaseVersion -Plugins $Plugins -ScriptDir $ScriptDir
$version = $resolvedVersion.version
$versionSource = $resolvedVersion.source
$artifactsDirectory = [System.IO.Path]::GetFullPath((Join-Path $ScriptDir '..\\..\\release'))
$currentBranch = Get-CurrentBranch
@ -113,7 +115,7 @@ function New-EngineContext {
Assert-WorkingTreeClean
$tag = "v$version"
Write-Log -Level "INFO" -Message " Release tag default from DotNetReleaseVersion: $tag (ReleasePublishGuard may replace from git when publish is allowed)."
Write-Log -Level "INFO" -Message " Release tag default from ${versionSource}: $tag (ReleasePublishGuard may replace from git when publish is allowed)."
return [pscustomobject]@{
scriptDir = $ScriptDir

View File

@ -10,14 +10,14 @@ Canonical source: this folder in **maksit-repoutils**. Product repositories refr
|------|------|
| `Release-Package.ps1` | Loads settings, builds `New-EngineContext`, runs plugins in order. |
| `PluginSupport.psm1` | Plugin discovery, `Invoke-ConfiguredPlugin`; publish plugins honor `skipPublishPlugins` from `ReleasePublishGuard` (no per-plugin `branches` for GitHub/NuGet/Docker/Helm). |
| `ReleaseContext.psm1` | Resolves semver via `Resolve-DotNetReleaseVersion` from the `DotNetReleaseVersion` plugin `projectFiles` (first `.csproj` `<Version>`). |
| `EngineSupport.psm1` | Warn-only dirty-tree preflight; default `context.tag` = `v{version}` from DotNetReleaseVersion; `Initialize-ReleaseStageContext` sets `releaseDir` only. |
| `ReleaseContext.psm1` | Resolves semver via `Resolve-ReleaseVersion` from `DotNetReleaseVersion.projectFiles` (first `.csproj` `<Version>`) or `NpmReleaseVersion.packageJsonPath`. |
| `EngineSupport.psm1` | Warn-only dirty-tree preflight; default `context.tag` = `v{version}` from the configured version plugin; `Initialize-ReleaseStageContext` sets `releaseDir` only. |
## Plugins
`CorePlugins/` — e.g. `DotNetReleaseVersion`, `NpmReleaseVersion`, `NpmBuild`, `NpmPublish`, `DockerPush`, `HelmPush`, `ReleasePublishGuard`. Optional `CustomPlugins/`.
`DotNetPack` and `QualityGate` (when used) can declare their own `projectFiles`; semver still comes only from `DotNetReleaseVersion.projectFiles`.
`DotNetPack` and `QualityGate` (when used) can declare their own `projectFiles`; semver still comes from the configured version plugin (`DotNetReleaseVersion` or `NpmReleaseVersion`).
## `ReleasePublishGuard`
@ -38,7 +38,7 @@ For TypeScript monorepos published to npmjs:
## Helm charts in git
Commit `Chart.yaml` with placeholder `version` and `appVersion` (for example `0.0.0`) so `helm lint` stays valid. `HelmPush` temporarily replaces both with the **bare** release semver from `context.version` (`DotNetReleaseVersion`, e.g. `3.3.4` without a `v` prefix) before packaging and OCI push; if `version` were missing, it would fall back to stripping `v`/`V` from `context.tag`. Then it restores `Chart.yaml`. `DockerPush` tags images with the **bare** semver from `context.version` (e.g. `3.3.4`), also pushes `vX.Y.Z` and `shared.tag` when they differ, and optional `latest` — not from `Chart.yaml`; optionally use per-image `versionEnvFiles` to temporarily set `VITE_APP_VERSION={shared.version}` in frontend `.env` files during docker build, then restore originals.
Commit `Chart.yaml` with placeholder `version` and `appVersion` (for example `0.0.0`) so `helm lint` stays valid. `HelmPush` temporarily replaces both with the **bare** release semver from `context.version` (`DotNetReleaseVersion`, e.g. `3.3.4` without a `v` prefix) before packaging and OCI push; if `version` were missing, it would fall back to stripping `v`/`V` from `context.tag`. Then it restores `Chart.yaml`. `DockerPush` tags images with the **bare** semver from `context.version` (e.g. `3.3.4`), also pushes `vX.Y.Z` and `shared.tag` when they differ, and optional `latest` — not from `Chart.yaml`; optionally use per-image `versionEnvFiles` to temporarily set `VITE_APP_VERSION={shared.version}` in frontend `.env` files during docker build, then restore originals. Each image may override the plugin `contextPath` with its own `contextPath` (paths relative to Release-Package); `dockerfile` and `versionEnvFiles` resolve against that per-image context.
Sample chart: repository `charts/my-service/` (matches the sample `chartPath` in `scriptsettings.json`). Product repos often use `src/helm/` instead.

View File

@ -6,9 +6,9 @@
Helpers to resolve release semver from plugin configuration.
.DESCRIPTION
Used by New-EngineContext and the DotNetReleaseVersion plugin:
- Source: DotNetReleaseVersion plugin -> projectFiles
- Version from first path in projectFiles (SDK-style .csproj <Version>)
Used by New-EngineContext and version plugins:
- DotNetReleaseVersion plugin -> projectFiles (.csproj <Version>)
- NpmReleaseVersion plugin -> packageJsonPath (package.json version)
#>
if (-not (Get-Command Write-Log -ErrorAction SilentlyContinue)) {
@ -136,10 +136,90 @@ function Resolve-DotNetReleaseVersion {
return [pscustomobject]@{
version = $version
source = 'DotNetReleaseVersion'
}
}
Export-ModuleMember -Function Get-CsprojPropertyValue, Get-CsprojVersions, Resolve-RelativePaths, Resolve-DotNetReleaseVersion
function Resolve-NpmReleaseVersion {
param(
[Parameter(Mandatory = $true)]
[object[]]$Plugins,
[Parameter(Mandatory = $true)]
[string]$ScriptDir
)
$releaseVersionPlugin = @($Plugins | Where-Object { $_.name -eq 'NpmReleaseVersion' } | Select-Object -First 1)
if ($releaseVersionPlugin.Count -eq 0 -or $null -eq $releaseVersionPlugin[0]) {
Write-Error "Configure an NpmReleaseVersion plugin in scriptsettings.json with packageJsonPath."
exit 1
}
$releaseVersionSettings = $releaseVersionPlugin[0]
$packageJsonPaths = @(Resolve-RelativePaths -Value $releaseVersionSettings.packageJsonPath -BasePath $ScriptDir)
if ($packageJsonPaths.Count -eq 0) {
Write-Error "Configure release version via NpmReleaseVersion.packageJsonPath."
exit 1
}
$packageJsonPath = $packageJsonPaths[0]
if (-not (Test-Path $packageJsonPath -PathType Leaf)) {
Write-Error "NpmReleaseVersion: package.json not found at: $packageJsonPath"
exit 1
}
Write-Log -Level "INFO" -Message "Reading version from npm package.json (packageJsonPath)..."
$json = Get-Content -Path $packageJsonPath -Raw -Encoding UTF8 | ConvertFrom-Json
$version = [string]$json.version
if ([string]::IsNullOrWhiteSpace($version)) {
Write-Error "NpmReleaseVersion: 'version' is missing in '$packageJsonPath'."
exit 1
}
if ($version -notmatch '^\d+\.\d+\.\d+') {
Write-Error "NpmReleaseVersion: version '$version' in '$packageJsonPath' is not a valid semver."
exit 1
}
Write-Log -Level "OK" -Message " $([System.IO.Path]::GetFileName($packageJsonPath)): $version"
return [pscustomobject]@{
version = $version
source = 'NpmReleaseVersion'
}
}
function Resolve-ReleaseVersion {
param(
[Parameter(Mandatory = $true)]
[object[]]$Plugins,
[Parameter(Mandatory = $true)]
[string]$ScriptDir
)
$dotnetPlugin = @($Plugins | Where-Object { $_.name -eq 'DotNetReleaseVersion' -and $_.enabled -ne $false })
$npmPlugin = @($Plugins | Where-Object { $_.name -eq 'NpmReleaseVersion' -and $_.enabled -ne $false })
if ($dotnetPlugin.Count -gt 0 -and $npmPlugin.Count -gt 0) {
Write-Error "Configure only one release version plugin: DotNetReleaseVersion or NpmReleaseVersion, not both."
exit 1
}
if ($dotnetPlugin.Count -gt 0) {
return Resolve-DotNetReleaseVersion -Plugins $Plugins -ScriptDir $ScriptDir
}
if ($npmPlugin.Count -gt 0) {
return Resolve-NpmReleaseVersion -Plugins $Plugins -ScriptDir $ScriptDir
}
Write-Error "Configure a DotNetReleaseVersion plugin (projectFiles) or NpmReleaseVersion plugin (packageJsonPath) in scriptsettings.json."
exit 1
}
Export-ModuleMember -Function Get-CsprojPropertyValue, Get-CsprojVersions, Resolve-RelativePaths, Resolve-DotNetReleaseVersion, Resolve-NpmReleaseVersion, Resolve-ReleaseVersion

View File

@ -39,7 +39,8 @@
"githubToken": "GITHUB_MAKS_IT_COM",
"repository": "https://github.com/MAKS-IT-COM/maksit-webui",
"releaseNotesFile": "..\\..\\CHANGELOG.md",
"releaseTitlePattern": "Release {version}"
"releaseTitlePattern": "Release {version}",
"requireReleaseAssets": false
},
{
"name": "NpmPublish",
@ -50,9 +51,9 @@
"access": "public",
"workspaceRoot": "..\\..\\src",
"publishOrder": [
"@maksit/webui-contracts",
"@maksit/webui-core",
"@maksit/webui-components"
"@maks-it.com/webui-contracts",
"@maks-it.com/webui-core",
"@maks-it.com/webui-components"
]
}
],

View File

@ -0,0 +1,177 @@
#requires -Version 7.0
#requires -PSEdition Core
<#
.SYNOPSIS
Coverage badge plugin for the Run-Tests engine.
.DESCRIPTION
Reads line/branch/method coverage from shared engine context and writes SVG badges.
#>
if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) {
$pluginSupportModulePath = Join-Path (Split-Path $PSScriptRoot -Parent) "PluginSupport.psm1"
if (Test-Path $pluginSupportModulePath -PathType Leaf) {
Import-Module $pluginSupportModulePath -Force -Global -ErrorAction Stop
}
}
function Get-BadgeColorInternal {
param(
[double]$percentage,
[psobject]$thresholds
)
if ($percentage -ge $thresholds.brightgreen) { return 'brightgreen' }
if ($percentage -ge $thresholds.green) { return 'green' }
if ($percentage -ge $thresholds.yellowgreen) { return 'yellowgreen' }
if ($percentage -ge $thresholds.yellow) { return 'yellow' }
if ($percentage -ge $thresholds.orange) { return 'orange' }
return 'red'
}
function New-BadgeSvgInternal {
param(
[string]$label,
[string]$value,
[string]$color
)
$labelWidth = [math]::Max(($label.Length * 6.5) + 10, 50)
$valueWidth = [math]::Max(($value.Length * 6.5) + 10, 40)
$totalWidth = $labelWidth + $valueWidth
$labelX = $labelWidth / 2
$valueX = $labelWidth + ($valueWidth / 2)
$colorMap = @{
brightgreen = '#4c1'
green = '#97ca00'
yellowgreen = '#a4a61d'
yellow = '#dfb317'
orange = '#fe7d37'
red = '#e05d44'
}
$hexColor = $colorMap[$color]
if (-not $hexColor) { $hexColor = '#9f9f9f' }
return @"
<svg xmlns="http://www.w3.org/2000/svg" width="$totalWidth" height="20" role="img" aria-label="$label`: $value">
<title>$label`: $value</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
</linearGradient>
<clipPath id="r">
<rect width="$totalWidth" height="20" rx="3" fill="#fff"/>
</clipPath>
<g clip-path="url(#r)">
<rect width="$labelWidth" height="20" fill="#555"/>
<rect x="$labelWidth" width="$valueWidth" height="20" fill="$hexColor"/>
<rect width="$totalWidth" height="20" fill="url(#s)"/>
</g>
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
<text aria-hidden="true" x="$labelX" y="15" fill="#010101" fill-opacity=".3">$label</text>
<text x="$labelX" y="14" fill="#fff">$label</text>
<text aria-hidden="true" x="$valueX" y="15" fill="#010101" fill-opacity=".3">$value</text>
<text x="$valueX" y="14" fill="#fff">$value</text>
</g>
</svg>
"@
}
function Get-CoverageMetricsFromSharedContext {
param(
[Parameter(Mandatory = $true)]
$Shared
)
$line = $null
$branch = $null
$method = $null
if ($Shared.PSObject.Properties.Name -contains 'coverageLineRate') {
$line = [double]$Shared.coverageLineRate
}
if ($Shared.PSObject.Properties.Name -contains 'coverageBranchRate') {
$branch = [double]$Shared.coverageBranchRate
}
if ($Shared.PSObject.Properties.Name -contains 'coverageMethodRate') {
$method = [double]$Shared.coverageMethodRate
}
if ($null -eq $line -and $Shared.PSObject.Properties.Name -contains 'testResult' -and $null -ne $Shared.testResult) {
$line = [double]$Shared.testResult.LineRate
$branch = [double]$Shared.testResult.BranchRate
$method = [double]$Shared.testResult.MethodRate
}
if ($null -eq $line) {
throw 'CoverageBadges requires coverage metrics on shared context. Run NpmJestTest or DotNetTest first.'
}
return @{
line = $line
branch = $branch
method = $method
}
}
function Invoke-Plugin {
param(
[Parameter(Mandatory = $true)]
$Settings
)
Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log"
Import-PluginDependency -ModuleName "ReleaseContext" -RequiredCommand "Resolve-RelativePaths"
$pluginSettings = $Settings
$sharedSettings = $Settings.context
$scriptDir = $sharedSettings.scriptDir
$metrics = Get-CoverageMetricsFromSharedContext -Shared $sharedSettings
$badgesDir = $sharedSettings.badgesDir
if ($pluginSettings.badgesDir) {
$badgesDirs = @(Resolve-RelativePaths -Value $pluginSettings.badgesDir -BasePath $scriptDir)
$badgesDir = $badgesDirs[0]
}
if ([string]::IsNullOrWhiteSpace([string]$badgesDir)) {
throw "CoverageBadges requires badgesDir in plugin settings or paths.badgesDir in scriptsettings.json."
}
if (-not (Test-Path $badgesDir)) {
New-Item -ItemType Directory -Path $badgesDir | Out-Null
}
$thresholds = $pluginSettings.colorThresholds
if ($null -eq $thresholds) {
$thresholds = [pscustomobject]@{
brightgreen = 80
green = 60
yellowgreen = 40
yellow = 20
orange = 10
red = 0
}
}
Write-Log -Level "STEP" -Message "Generating coverage badges..."
foreach ($badge in @($pluginSettings.badges)) {
$metricValue = $metrics[[string]$badge.metric]
if ($null -eq $metricValue) {
throw "Unknown or missing coverage metric '$($badge.metric)' for badge '$($badge.name)'."
}
$color = Get-BadgeColorInternal -percentage $metricValue -thresholds $thresholds
$svg = New-BadgeSvgInternal -label $badge.label -value "$metricValue%" -color $color
$path = Join-Path $badgesDir $badge.name
$svg | Out-File -FilePath $path -Encoding utf8NoBOM
Write-Log -Level "OK" -Message "$($badge.name): $($badge.label) = $metricValue%"
}
Write-Log -Level "OK" -Message "Badges generated in: $badgesDir"
Write-Log -Level "STEP" -Message "Commit the badges folder to update README."
}
Export-ModuleMember -Function Invoke-Plugin

View File

@ -0,0 +1,98 @@
#requires -Version 7.0
#requires -PSEdition Core
<#
.SYNOPSIS
.NET test plugin for executing automated tests.
.DESCRIPTION
Resolves one or more .NET test projects (`project` or `projects`), runs tests once
via TestRunner, then publishes metrics on the shared engine context for any later
plugin: `qualityLineCoverage`, `testResult`, `coverageLineRate` / `coverageBranchRate` / `coverageMethodRate`,
method counts, `testResultsDirectory`, `coverageCoberturaPaths`. Quality gates read
those keys generically (not tied to this plugin by name). Cobertura files are removed
after parsing unless TestRunner gains KeepResults.
#>
if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) {
$pluginSupportModulePath = Join-Path (Split-Path $PSScriptRoot -Parent) "PluginSupport.psm1"
if (Test-Path $pluginSupportModulePath -PathType Leaf) {
# Same fallback pattern as the other plugins: use the existing shared module if it is already loaded.
Import-Module $pluginSupportModulePath -Force -Global -ErrorAction Stop
}
}
function Invoke-Plugin {
param(
[Parameter(Mandatory = $true)]
$Settings
)
Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log"
Import-PluginDependency -ModuleName "TestRunner" -RequiredCommand "Invoke-TestsWithCoverage"
$pluginSettings = $Settings
$sharedSettings = $Settings.context
$testResultsDirSetting = $pluginSettings.resultsDir
$scriptDir = $sharedSettings.scriptDir
$testProjectPaths = [System.Collections.Generic.List[string]]::new()
if ($pluginSettings.PSObject.Properties.Name -contains 'projects' -and $pluginSettings.projects) {
foreach ($rel in @($pluginSettings.projects)) {
if ([string]::IsNullOrWhiteSpace([string]$rel)) { continue }
$testProjectPaths.Add([System.IO.Path]::GetFullPath((Join-Path $scriptDir $rel.Trim())))
}
}
if ($testProjectPaths.Count -eq 0 -and $pluginSettings.project) {
$testProjectPaths.Add([System.IO.Path]::GetFullPath((Join-Path $scriptDir $pluginSettings.project)))
}
if ($testProjectPaths.Count -eq 0) {
throw "DotNetTest plugin requires 'project' or 'projects' in scriptsettings.json."
}
$testResultsDir = $null
if (-not [string]::IsNullOrWhiteSpace($testResultsDirSetting)) {
$testResultsDir = [System.IO.Path]::GetFullPath((Join-Path $scriptDir $testResultsDirSetting))
}
elseif ($testProjectPaths.Count -gt 1) {
$testResultsDir = [System.IO.Path]::GetFullPath((Join-Path $scriptDir "TestResults"))
}
Write-Log -Level "STEP" -Message "Running tests..."
# Build a splatted hashtable so optional arguments can be added without duplicating the call site.
$invokeTestParams = @{
TestProjectPath = @($testProjectPaths)
Silent = $true
}
if ($testResultsDir) {
$invokeTestParams.ResultsDirectory = $testResultsDir
}
$testResult = Invoke-TestsWithCoverage @invokeTestParams
if (-not $testResult.Success) {
throw "Tests failed. $($testResult.Error)"
}
$sharedSettings | Add-Member -NotePropertyName testResult -NotePropertyValue $testResult -Force
$sharedSettings | Add-Member -NotePropertyName qualityLineCoverage -NotePropertyValue $testResult.LineRate -Force
$sharedSettings | Add-Member -NotePropertyName coverageLineRate -NotePropertyValue $testResult.LineRate -Force
$sharedSettings | Add-Member -NotePropertyName coverageBranchRate -NotePropertyValue $testResult.BranchRate -Force
$sharedSettings | Add-Member -NotePropertyName coverageMethodRate -NotePropertyValue $testResult.MethodRate -Force
$sharedSettings | Add-Member -NotePropertyName coverageTotalMethods -NotePropertyValue $testResult.TotalMethods -Force
$sharedSettings | Add-Member -NotePropertyName coverageCoveredMethods -NotePropertyValue $testResult.CoveredMethods -Force
if (($testResult.PSObject.Properties.Name -contains 'ResultsDirectory') -and $testResult.ResultsDirectory) {
$sharedSettings | Add-Member -NotePropertyName testResultsDirectory -NotePropertyValue $testResult.ResultsDirectory -Force
}
if ($testResult.CoverageFiles) {
$sharedSettings | Add-Member -NotePropertyName coverageCoberturaPaths -NotePropertyValue @($testResult.CoverageFiles) -Force
}
Write-Log -Level "OK" -Message " All tests passed!"
Write-Log -Level "INFO" -Message " Line Coverage: $($testResult.LineRate)%"
Write-Log -Level "INFO" -Message " Branch Coverage: $($testResult.BranchRate)%"
Write-Log -Level "INFO" -Message " Method Coverage: $($testResult.MethodRate)%"
}
Export-ModuleMember -Function Invoke-Plugin

View File

@ -0,0 +1,82 @@
#requires -Version 7.0
#requires -PSEdition Core
<#
.SYNOPSIS
npm/Jest test plugin for the Run-Tests engine.
.DESCRIPTION
Runs Jest with coverage via TestRunner.Invoke-NpmJestTestsWithCoverage and publishes
normalized metrics on the shared engine context for downstream plugins.
#>
if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) {
$pluginSupportModulePath = Join-Path (Split-Path $PSScriptRoot -Parent) "PluginSupport.psm1"
if (Test-Path $pluginSupportModulePath -PathType Leaf) {
Import-Module $pluginSupportModulePath -Force -Global -ErrorAction Stop
}
}
function Invoke-Plugin {
param(
[Parameter(Mandatory = $true)]
$Settings
)
Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log"
Import-PluginDependency -ModuleName "TestRunner" -RequiredCommand "Invoke-NpmJestTestsWithCoverage"
Import-PluginDependency -ModuleName "ScriptConfig" -RequiredCommand "Assert-Command"
Import-PluginDependency -ModuleName "ReleaseContext" -RequiredCommand "Resolve-RelativePaths"
$pluginSettings = $Settings
$sharedSettings = $Settings.context
$scriptDir = $sharedSettings.scriptDir
Assert-Command npm
if (-not $pluginSettings.workspaceRoot) {
throw "NpmJestTest plugin requires 'workspaceRoot' in scriptsettings.json."
}
$workspaceRoots = @(Resolve-RelativePaths -Value $pluginSettings.workspaceRoot -BasePath $scriptDir)
$workspaceRoot = $workspaceRoots[0]
$testScript = 'test'
if (-not [string]::IsNullOrWhiteSpace([string]$pluginSettings.testScript)) {
$testScript = [string]$pluginSettings.testScript
}
$coverageDirectory = 'coverage'
if (-not [string]::IsNullOrWhiteSpace([string]$pluginSettings.coverageDirectory)) {
$coverageDirectory = [string]$pluginSettings.coverageDirectory
}
$testResult = Invoke-NpmJestTestsWithCoverage -WorkspaceRoot $workspaceRoot -TestScript $testScript -CoverageDirectory $coverageDirectory
if (-not $testResult.Success) {
throw "Tests failed. $($testResult.Error)"
}
$sharedSettings | Add-Member -NotePropertyName npmWorkspaceRoot -NotePropertyValue $workspaceRoot -Force
$sharedSettings | Add-Member -NotePropertyName testResult -NotePropertyValue $testResult -Force
$sharedSettings | Add-Member -NotePropertyName qualityLineCoverage -NotePropertyValue $testResult.LineRate -Force
$sharedSettings | Add-Member -NotePropertyName coverageLineRate -NotePropertyValue $testResult.LineRate -Force
$sharedSettings | Add-Member -NotePropertyName coverageBranchRate -NotePropertyValue $testResult.BranchRate -Force
$sharedSettings | Add-Member -NotePropertyName coverageMethodRate -NotePropertyValue $testResult.MethodRate -Force
$sharedSettings | Add-Member -NotePropertyName coverageTotalMethods -NotePropertyValue $testResult.TotalMethods -Force
$sharedSettings | Add-Member -NotePropertyName coverageCoveredMethods -NotePropertyValue $testResult.CoveredMethods -Force
if (($testResult.PSObject.Properties.Name -contains 'ResultsDirectory') -and $testResult.ResultsDirectory) {
$sharedSettings | Add-Member -NotePropertyName testResultsDirectory -NotePropertyValue $testResult.ResultsDirectory -Force
}
if (($testResult.PSObject.Properties.Name -contains 'CoverageSummaryFile') -and $testResult.CoverageSummaryFile) {
$sharedSettings | Add-Member -NotePropertyName coverageSummaryFile -NotePropertyValue $testResult.CoverageSummaryFile -Force
}
Write-Log -Level "OK" -Message " All tests passed!"
Write-Log -Level "INFO" -Message " Line Coverage: $($testResult.LineRate)%"
Write-Log -Level "INFO" -Message " Branch Coverage: $($testResult.BranchRate)%"
Write-Log -Level "INFO" -Message " Method Coverage: $($testResult.MethodRate)%"
}
Export-ModuleMember -Function Invoke-Plugin

View File

@ -0,0 +1,184 @@
#requires -Version 7.0
#requires -PSEdition Core
<#
.SYNOPSIS
Quality gate plugin (coverage threshold + optional .NET vulnerability scan).
.DESCRIPTION
Does not run tests or collect coverage. It reads whatever prior plugins left on the
shared engine context (same object passed to every plugin as .context).
Line coverage for threshold checks is resolved in order (first present wins):
- qualityLineCoverage (generic; any plugin may set this)
- coverageLineRate (conventional flat metric)
- testResult.LineRate (object from a test plugin; property name is conventional)
Configure coverageThreshold > 0 to require one of those inputs. With coverageThreshold 0
and scanVulnerabilities false, the plugin is a no-op.
When scanVulnerabilities is true, runs dotnet list package --vulnerable on projectFiles.
Use stageLabel "qualityGate" in scriptsettings.json; plugin module: CorePlugins/QualityGate.psm1 (`"name": "QualityGate"`).
#>
if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) {
$pluginSupportModulePath = Join-Path (Split-Path $PSScriptRoot -Parent) "PluginSupport.psm1"
if (Test-Path $pluginSupportModulePath -PathType Leaf) {
Import-Module $pluginSupportModulePath -Force -Global -ErrorAction Stop
}
}
function Test-VulnerablePackagesInternal {
param(
[Parameter(Mandatory = $true)]
[string[]]$ProjectFiles
)
$findings = @()
foreach ($projectPath in $ProjectFiles) {
Write-Log -Level "STEP" -Message "Checking vulnerable packages: $([System.IO.Path]::GetFileName($projectPath))"
$output = & dotnet list $projectPath package --vulnerable --include-transitive 2>&1
if ($LASTEXITCODE -ne 0) {
throw "dotnet list package --vulnerable failed for $projectPath."
}
$outputText = ($output | Out-String)
if ($outputText -match "(?im)\bhas the following vulnerable packages\b" -or $outputText -match "(?im)^\s*>\s+[A-Za-z0-9_.-]+\s") {
$findings += [pscustomobject]@{
Project = $projectPath
Output = $outputText.Trim()
}
}
}
return $findings
}
function Get-LineCoveragePercentFromSharedContext {
param(
[Parameter(Mandatory = $true)]
$Shared
)
foreach ($prop in @('qualityLineCoverage', 'coverageLineRate')) {
if ($Shared.PSObject.Properties.Name -contains $prop) {
$raw = $Shared.$prop
if ($null -eq $raw) { continue }
$asString = [string]$raw
if ([string]::IsNullOrWhiteSpace($asString)) { continue }
return [double]$asString
}
}
if ($Shared.PSObject.Properties.Name -contains 'testResult' -and $null -ne $Shared.testResult) {
$tr = $Shared.testResult
if ($tr.PSObject.Properties.Name -contains 'LineRate') {
return [double]$tr.LineRate
}
}
return $null
}
function Invoke-Plugin {
param(
[Parameter(Mandatory = $true)]
$Settings
)
Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log"
Import-PluginDependency -ModuleName "ScriptConfig" -RequiredCommand "Assert-Command"
Import-PluginDependency -ModuleName "ReleaseContext" -RequiredCommand "Resolve-RelativePaths"
$pluginSettings = $Settings
$sharedSettings = $Settings.context
$scriptDir = $sharedSettings.scriptDir
$coverageThresholdSetting = $pluginSettings.coverageThreshold
$failOnVulnerabilitiesSetting = $pluginSettings.failOnVulnerabilities
$scanVulnerabilities = $true
if ($null -ne $pluginSettings.scanVulnerabilities) {
$scanVulnerabilities = [bool]$pluginSettings.scanVulnerabilities
}
if ($pluginSettings.PSObject.Properties['projectFiles'] -and $null -ne $pluginSettings.projectFiles) {
$projectFiles = @(Resolve-RelativePaths -Value $pluginSettings.projectFiles -BasePath $scriptDir)
}
elseif ($sharedSettings.PSObject.Properties['projectFiles'] -and $null -ne $sharedSettings.projectFiles) {
$projectFiles = @($sharedSettings.projectFiles)
}
else {
$projectFiles = @()
}
$coverageThreshold = 0
if ($null -ne $coverageThresholdSetting) {
$coverageThreshold = [double]$coverageThresholdSetting
}
$needCoverageCheck = $coverageThreshold -gt 0
if (-not $needCoverageCheck -and -not $scanVulnerabilities) {
Write-Log -Level "INFO" -Message " Quality gate: no checks enabled (coverageThreshold 0, scanVulnerabilities false)."
return
}
$lineRate = $null
if ($needCoverageCheck) {
$lineRate = Get-LineCoveragePercentFromSharedContext -Shared $sharedSettings
if ($null -eq $lineRate) {
throw "coverageThreshold is $coverageThreshold but shared context has no line coverage. Set one of: qualityLineCoverage, coverageLineRate, or testResult.LineRate (from an earlier plugin)."
}
Write-Log -Level "STEP" -Message "Checking line coverage threshold against shared context..."
if ($lineRate -lt $coverageThreshold) {
throw "Line coverage $lineRate% is below the configured threshold of $coverageThreshold%."
}
Write-Log -Level "OK" -Message " Coverage threshold met: $lineRate% >= $coverageThreshold%"
}
else {
Write-Log -Level "INFO" -Message " Coverage threshold check not required (coverageThreshold is 0)."
}
if (-not $scanVulnerabilities) {
Write-Log -Level "INFO" -Message " Vulnerability scan skipped (scanVulnerabilities is false)."
return
}
Assert-Command dotnet
$failOnVulnerabilities = $true
if ($null -ne $failOnVulnerabilitiesSetting) {
$failOnVulnerabilities = [bool]$failOnVulnerabilitiesSetting
}
if ($projectFiles.Count -eq 0) {
throw "QualityGate requires projectFiles when scanVulnerabilities is true."
}
$vulnerabilities = Test-VulnerablePackagesInternal -ProjectFiles $projectFiles
if ($vulnerabilities.Count -eq 0) {
Write-Log -Level "OK" -Message " No vulnerable packages detected."
return
}
foreach ($finding in $vulnerabilities) {
Write-Log -Level "WARN" -Message " Vulnerable packages detected in $([System.IO.Path]::GetFileName($finding.Project))"
$finding.Output -split "`r?`n" | ForEach-Object {
if (-not [string]::IsNullOrWhiteSpace($_)) {
Write-Log -Level "WARN" -Message " $_"
}
}
}
if ($failOnVulnerabilities) {
throw "Vulnerable packages were detected and failOnVulnerabilities is enabled."
}
Write-Log -Level "WARN" -Message "Vulnerable packages detected, but failOnVulnerabilities is disabled."
}
Export-ModuleMember -Function Invoke-Plugin

View File

View File

@ -0,0 +1,35 @@
#requires -Version 7.0
#requires -PSEdition Core
if (-not (Get-Command Write-Log -ErrorAction SilentlyContinue)) {
$loggingModulePath = Join-Path (Split-Path $PSScriptRoot -Parent) "Logging.psm1"
if (Test-Path $loggingModulePath -PathType Leaf) {
Import-Module $loggingModulePath -Force
}
}
function New-EngineContext {
param(
[Parameter(Mandatory = $true)]
[string]$ScriptDir,
[Parameter(Mandatory = $true)]
[string]$UtilsDir,
[Parameter(Mandatory = $false)]
[psobject]$Settings
)
$badgesDir = $null
if ($Settings -and $Settings.PSObject.Properties['paths'] -and $Settings.paths.badgesDir) {
$badgesDir = [System.IO.Path]::GetFullPath((Join-Path $ScriptDir ([string]$Settings.paths.badgesDir)))
}
return [pscustomobject]@{
scriptDir = $ScriptDir
utilsDir = $UtilsDir
badgesDir = $badgesDir
}
}
Export-ModuleMember -Function New-EngineContext

View File

@ -0,0 +1,376 @@
#requires -Version 7.0
#requires -PSEdition Core
if (-not (Get-Command Write-Log -ErrorAction SilentlyContinue)) {
$loggingModulePath = Join-Path (Split-Path $PSScriptRoot -Parent) "Logging.psm1"
if (Test-Path $loggingModulePath -PathType Leaf) {
Import-Module $loggingModulePath -Force
}
}
function Import-PluginDependency {
param(
[Parameter(Mandatory = $true)]
[string]$ModuleName,
[Parameter(Mandatory = $true)]
[string]$RequiredCommand
)
if (Get-Command $RequiredCommand -ErrorAction SilentlyContinue) {
return
}
$moduleRoot = Split-Path $PSScriptRoot -Parent
$modulePath = Join-Path $moduleRoot "$ModuleName.psm1"
if (Test-Path $modulePath -PathType Leaf) {
# Import into the global session so the calling plugin can see the exported commands.
# Importing only into this module's scope would make the dependency invisible to the plugin.
Import-Module $modulePath -Force -Global -ErrorAction Stop
}
if (-not (Get-Command $RequiredCommand -ErrorAction SilentlyContinue)) {
throw "Required command '$RequiredCommand' is still unavailable after importing module '$ModuleName'."
}
}
function Get-ConfiguredPlugins {
param(
[Parameter(Mandatory = $true)]
[psobject]$Settings
)
if (-not $Settings.PSObject.Properties['plugins'] -or $null -eq $Settings.plugins) {
return @()
}
# JSON can deserialize a single plugin as one object or multiple plugins as an array.
# Always return an array so the engine can loop without special-case logic.
if ($Settings.plugins -is [System.Collections.IEnumerable] -and -not ($Settings.plugins -is [string])) {
return @($Settings.plugins)
}
return @($Settings.plugins)
}
function Get-PluginStageLabel {
param(
[Parameter(Mandatory = $true)]
$Plugin
)
if (-not $Plugin.PSObject.Properties['stageLabel'] -or [string]::IsNullOrWhiteSpace([string]$Plugin.stageLabel)) {
return 'release'
}
return [string]$Plugin.stageLabel
}
function Get-PluginBranches {
param(
[Parameter(Mandatory = $true)]
$Plugin
)
if (-not $Plugin.PSObject.Properties['branches'] -or $null -eq $Plugin.branches) {
return @()
}
# Strings are also IEnumerable in PowerShell, so exclude them or we would split into characters.
if ($Plugin.branches -is [System.Collections.IEnumerable] -and -not ($Plugin.branches -is [string])) {
return @($Plugin.branches | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
}
if ([string]::IsNullOrWhiteSpace([string]$Plugin.branches)) {
return @()
}
return @([string]$Plugin.branches)
}
function Test-PluginAllowedOnBranch {
param(
[Parameter(Mandatory = $true)]
$Plugin,
[Parameter(Mandatory = $true)]
[string]$CurrentBranch
)
$allowedBranches = Get-PluginBranches -Plugin $Plugin
if ($allowedBranches.Count -eq 0) {
return $true
}
if ($allowedBranches -contains '*') {
return $true
}
return $allowedBranches -contains $CurrentBranch
}
function Test-IsPublishPlugin {
param(
[Parameter(Mandatory = $true)]
$Plugin
)
if ($null -eq $Plugin -or [string]::IsNullOrWhiteSpace([string]$Plugin.name)) {
return $false
}
return @('GitHub', 'DotNetNuGet', 'DockerPush', 'HelmPush', 'NpmPublish') -contains ([string]$Plugin.name)
}
function Get-PluginSettingValue {
param(
[Parameter(Mandatory = $true)]
[object[]]$Plugins,
[Parameter(Mandatory = $true)]
[string]$PropertyName
)
foreach ($plugin in $Plugins) {
if ($null -eq $plugin -or [string]::IsNullOrWhiteSpace($plugin.name)) {
continue
}
if (-not $plugin.PSObject.Properties[$PropertyName]) {
continue
}
$value = $plugin.$PropertyName
if ($null -eq $value) {
continue
}
if ($value -is [string] -and [string]::IsNullOrWhiteSpace($value)) {
continue
}
return $value
}
return $null
}
function Get-PluginPathListSetting {
param(
[Parameter(Mandatory = $true)]
[object[]]$Plugins,
[Parameter(Mandatory = $true)]
[string]$PropertyName,
[Parameter(Mandatory = $true)]
[string]$BasePath
)
$rawPaths = @()
$value = Get-PluginSettingValue -Plugins $Plugins -PropertyName $PropertyName
if ($null -eq $value) {
return @()
}
# Same rule as above: treat a string as one path, not a char-by-char sequence.
if ($value -is [System.Collections.IEnumerable] -and -not ($value -is [string])) {
$rawPaths += $value
}
else {
$rawPaths += $value
}
$resolvedPaths = @()
foreach ($path in $rawPaths) {
if ([string]::IsNullOrWhiteSpace([string]$path)) {
continue
}
$resolvedPaths += [System.IO.Path]::GetFullPath((Join-Path $BasePath ([string]$path)))
}
# Wrap again to stop PowerShell from unrolling a single-item array into a bare string.
return @($resolvedPaths)
}
function Get-PluginPathSetting {
param(
[Parameter(Mandatory = $true)]
[object[]]$Plugins,
[Parameter(Mandatory = $true)]
[string]$PropertyName,
[Parameter(Mandatory = $true)]
[string]$BasePath
)
$value = Get-PluginSettingValue -Plugins $Plugins -PropertyName $PropertyName
if ($null -eq $value -or [string]::IsNullOrWhiteSpace([string]$value)) {
return $null
}
return [System.IO.Path]::GetFullPath((Join-Path $BasePath ([string]$value)))
}
function Get-ArchiveNamePattern {
param(
[Parameter(Mandatory = $true)]
[object[]]$Plugins,
[Parameter(Mandatory = $true)]
[string]$CurrentBranch
)
foreach ($plugin in $Plugins) {
if ($null -eq $plugin -or [string]::IsNullOrWhiteSpace($plugin.name)) {
continue
}
if (-not $plugin.enabled) {
continue
}
if (-not (Test-PluginAllowedOnBranch -Plugin $plugin -CurrentBranch $CurrentBranch)) {
continue
}
if ($plugin.PSObject.Properties['zipNamePattern'] -and -not [string]::IsNullOrWhiteSpace([string]$plugin.zipNamePattern)) {
return [string]$plugin.zipNamePattern
}
}
return "release-{version}.zip"
}
function Resolve-PluginModulePath {
param(
[Parameter(Mandatory = $true)]
$Plugin,
[Parameter(Mandatory = $true)]
[string]$PluginsDirectory
)
$pluginFileName = "{0}.psm1" -f $Plugin.name
$candidatePaths = @(
(Join-Path $PluginsDirectory $pluginFileName),
(Join-Path (Join-Path (Split-Path $PluginsDirectory -Parent) "CustomPlugins") $pluginFileName)
)
foreach ($candidatePath in $candidatePaths) {
if (Test-Path $candidatePath -PathType Leaf) {
return $candidatePath
}
}
return $candidatePaths[0]
}
function Test-PluginRunnable {
param(
[Parameter(Mandatory = $true)]
$Plugin,
[Parameter(Mandatory = $true)]
[psobject]$SharedSettings,
[Parameter(Mandatory = $true)]
[string]$PluginsDirectory,
[Parameter(Mandatory = $false)]
[bool]$WriteLogs = $true
)
if ($null -eq $Plugin -or [string]::IsNullOrWhiteSpace($Plugin.name)) {
if ($WriteLogs) {
Write-Log -Level "WARN" -Message "Skipping plugin entry with no name."
}
return $false
}
if (-not $Plugin.enabled) {
if ($WriteLogs) {
Write-Log -Level "WARN" -Message "Skipping plugin '$($Plugin.name)' (disabled)."
}
return $false
}
$pluginModulePath = Resolve-PluginModulePath -Plugin $Plugin -PluginsDirectory $PluginsDirectory
if (-not (Test-Path $pluginModulePath -PathType Leaf)) {
if ($WriteLogs) {
Write-Log -Level "ERROR" -Message "Plugin module not found: $pluginModulePath"
}
return $false
}
return $true
}
function New-PluginInvocationSettings {
param(
[Parameter(Mandatory = $true)]
$Plugin,
[Parameter(Mandatory = $true)]
[psobject]$SharedSettings
)
$properties = @{}
foreach ($property in $Plugin.PSObject.Properties) {
$properties[$property.Name] = $property.Value
}
# Plugins receive their own config plus shared runtime context.
$properties['context'] = $SharedSettings
return [pscustomobject]$properties
}
function Invoke-ConfiguredPlugin {
param(
[Parameter(Mandatory = $true)]
$Plugin,
[Parameter(Mandatory = $true)]
[psobject]$SharedSettings,
[Parameter(Mandatory = $true)]
[string]$PluginsDirectory,
[Parameter(Mandatory = $false)]
[bool]$ContinueOnError = $true
)
if (-not (Test-PluginRunnable -Plugin $Plugin -SharedSettings $SharedSettings -PluginsDirectory $PluginsDirectory -WriteLogs:$true)) {
return
}
if ((Test-IsPublishPlugin -Plugin $Plugin) -and ($SharedSettings.PSObject.Properties.Name -contains 'skipPublishPlugins') -and $SharedSettings.skipPublishPlugins) {
Write-Log -Level "INFO" -Message "Skipping plugin '$($Plugin.name)' (ReleasePublishGuard suppressed publish)."
return
}
$pluginModulePath = Resolve-PluginModulePath -Plugin $Plugin -PluginsDirectory $PluginsDirectory
Write-Log -Level "STEP" -Message "Running plugin '$($Plugin.name)'..."
try {
$moduleInfo = Import-Module $pluginModulePath -Force -PassThru -ErrorAction Stop
# Resolve Invoke-Plugin from the imported module explicitly so we call the plugin we just loaded,
# not some command with the same name from another module already in session.
$invokeCommand = Get-Command -Name "Invoke-Plugin" -Module $moduleInfo.Name -ErrorAction Stop
$pluginSettings = New-PluginInvocationSettings -Plugin $Plugin -SharedSettings $SharedSettings
& $invokeCommand -Settings $pluginSettings
Write-Log -Level "OK" -Message " Plugin '$($Plugin.name)' completed."
}
catch {
Write-Log -Level "ERROR" -Message " Plugin '$($Plugin.name)' failed: $($_.Exception.Message)"
if (-not $ContinueOnError) {
exit 1
}
}
}
Export-ModuleMember -Function Import-PluginDependency, Get-ConfiguredPlugins, Get-PluginStageLabel, Get-PluginBranches, Test-IsPublishPlugin, Get-PluginSettingValue, Get-PluginPathListSetting, Get-PluginPathSetting, Get-ArchiveNamePattern, Resolve-PluginModulePath, Test-PluginRunnable, New-PluginInvocationSettings, Invoke-ConfiguredPlugin

58
utils/Run-Tests/README.md Normal file
View File

@ -0,0 +1,58 @@
# Run Tests
Plugin-driven test engine (same pattern as `src/Release-Package`).
## Run
```powershell
pwsh -File .\src\Run-Tests\Run-Tests.ps1
```
Or:
```bat
src\Run-Tests\Run-Tests.bat
```
## Core plugins
| Plugin | Role |
|--------|------|
| `DotNetTest` | `dotnet test` + Coverlet Cobertura (`.NET` repos) |
| `NpmJestTest` | `npm test -- --coverage` + Jest `coverage-summary.json` |
| `QualityGate` | Optional line-coverage threshold from shared context |
| `CoverageBadges` | SVG badges for README (`assets/badges/`) |
Configure plugin order and settings in `scriptsettings.json`.
## Shared context
Test plugins publish metrics for downstream plugins:
- `qualityLineCoverage`, `coverageLineRate`, `coverageBranchRate`, `coverageMethodRate`
- `testResult` (full result object from `TestRunner`)
`QualityGate` and `CoverageBadges` read these keys; they do not re-run tests.
## npm/Jest example
Replace `DotNetTest` with:
```json
{
"name": "NpmJestTest",
"stageLabel": "test",
"enabled": true,
"workspaceRoot": "..\\..\\src",
"testScript": "test",
"coverageDirectory": "coverage"
}
```
## Legacy entry point
`src/Generate-CoverageBadges/Generate-CoverageBadges.ps1` forwards to this engine.
## Custom plugins
Add `CustomPlugins/YourPlugin.psm1` with `Invoke-Plugin`, then register it in `scriptsettings.json`.

View File

@ -0,0 +1,3 @@
@echo off
pwsh -NoProfile -ExecutionPolicy Bypass -File "%~dp0Run-Tests.ps1"
pause

View File

@ -0,0 +1,76 @@
#requires -Version 7.0
#requires -PSEdition Core
<#
.SYNOPSIS
Plugin-driven test and coverage engine.
.DESCRIPTION
Loads scriptsettings.json, builds shared execution context, and runs configured
plugins in order. Each plugin implements Invoke-Plugin and receives its own
settings plus shared context on Settings.context.
.USAGE
pwsh -File .\Run-Tests.ps1
#>
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$utilsDir = Split-Path $scriptDir -Parent
$scriptConfigModulePath = Join-Path $utilsDir "ScriptConfig.psm1"
if (-not (Test-Path $scriptConfigModulePath)) {
Write-Error "ScriptConfig module not found at: $scriptConfigModulePath"
exit 1
}
Import-Module $scriptConfigModulePath -Force
$loggingModulePath = Join-Path $utilsDir "Logging.psm1"
if (-not (Test-Path $loggingModulePath)) {
Write-Error "Logging module not found at: $loggingModulePath"
exit 1
}
Import-Module $loggingModulePath -Force
$pluginSupportModulePath = Join-Path $scriptDir "PluginSupport.psm1"
if (-not (Test-Path $pluginSupportModulePath)) {
Write-Error "PluginSupport module not found at: $pluginSupportModulePath"
exit 1
}
Import-Module $pluginSupportModulePath -Force
$engineSupportModulePath = Join-Path $scriptDir "EngineSupport.psm1"
if (-not (Test-Path $engineSupportModulePath)) {
Write-Error "EngineSupport module not found at: $engineSupportModulePath"
exit 1
}
Import-Module $engineSupportModulePath -Force
$releaseContextModulePath = Join-Path $utilsDir "Release-Package\ReleaseContext.psm1"
if (-not (Test-Path $releaseContextModulePath)) {
Write-Error "ReleaseContext module not found at: $releaseContextModulePath"
exit 1
}
Import-Module $releaseContextModulePath -Force
$settings = Get-ScriptSettings -ScriptDir $scriptDir
$configuredPlugins = Get-ConfiguredPlugins -Settings $settings
$pluginsDir = Join-Path $scriptDir "CorePlugins"
Write-Log -Level "STEP" -Message "=================================================="
Write-Log -Level "STEP" -Message "TEST ENGINE"
Write-Log -Level "STEP" -Message "=================================================="
$engineContext = New-EngineContext -ScriptDir $scriptDir -UtilsDir $utilsDir -Settings $settings
if ($configuredPlugins.Count -eq 0) {
Write-Log -Level "WARN" -Message "No plugins configured in scriptsettings.json."
exit 0
}
foreach ($plugin in $configuredPlugins) {
Invoke-ConfiguredPlugin -Plugin $plugin -SharedSettings $engineContext -PluginsDirectory $pluginsDir -ContinueOnError:$false
}
Write-Log -Level "OK" -Message "=================================================="
Write-Log -Level "OK" -Message "TEST RUN COMPLETE"
Write-Log -Level "OK" -Message "=================================================="

View File

@ -0,0 +1,63 @@
{
"$schema": "https://json-schema.org/draft-07/schema",
"title": "Run Tests Script Settings",
"description": "maksit-webui: plugin-driven Jest tests and coverage badges.",
"paths": {
"badgesDir": "..\\..\\assets\\badges"
},
"plugins": [
{
"name": "NpmJestTest",
"stageLabel": "test",
"enabled": true,
"workspaceRoot": "..\\..\\src",
"testScript": "test",
"coverageDirectory": "coverage"
},
{
"name": "QualityGate",
"stageLabel": "qualityGate",
"enabled": true,
"coverageThreshold": 0,
"scanVulnerabilities": false
},
{
"name": "CoverageBadges",
"stageLabel": "report",
"enabled": true,
"badgesDir": "..\\..\\assets\\badges",
"badges": [
{
"name": "coverage-lines.svg",
"label": "Line Coverage",
"metric": "line"
},
{
"name": "coverage-branches.svg",
"label": "Branch Coverage",
"metric": "branch"
},
{
"name": "coverage-methods.svg",
"label": "Method Coverage",
"metric": "method"
}
],
"colorThresholds": {
"brightgreen": 80,
"green": 60,
"yellowgreen": 40,
"yellow": 20,
"orange": 10,
"red": 0
}
}
],
"_comments": {
"plugins": {
"NpmJestTest": "Runs npm test with Jest coverage in workspaceRoot. Publishes coverage metrics on shared context.",
"QualityGate": "Reads shared context metrics; set coverageThreshold > 0 to enforce minimum line coverage.",
"CoverageBadges": "Writes SVG badges from shared context metrics into badgesDir."
}
}
}

View File

@ -283,4 +283,110 @@ function Invoke-TestsWithCoverage {
}
}
Export-ModuleMember -Function Invoke-TestsWithCoverage
function Invoke-NpmJestTestsWithCoverage {
<#
.SYNOPSIS
Runs npm/Jest tests with coverage and returns normalized metrics.
.PARAMETER WorkspaceRoot
npm workspace root (folder containing package.json and jest.config).
.PARAMETER TestScript
npm script name to run (default: test). Coverage flags are appended via `--`.
.PARAMETER CoverageDirectory
Relative path under WorkspaceRoot where Jest writes coverage output.
.PARAMETER Silent
Suppress console output from npm.
.OUTPUTS
Same metric shape as Invoke-TestsWithCoverage, plus CoverageSummaryFile when available.
#>
param(
[Parameter(Mandatory = $true)]
[string]$WorkspaceRoot,
[string]$TestScript = 'test',
[string]$CoverageDirectory = 'coverage',
[switch]$Silent
)
$ErrorActionPreference = 'Stop'
$workspaceFull = [System.IO.Path]::GetFullPath($WorkspaceRoot)
if (-not (Test-Path (Join-Path $workspaceFull 'package.json') -PathType Leaf)) {
return [PSCustomObject]@{
Success = $false
Error = "package.json not found in workspace root: $workspaceFull"
}
}
if (-not $Silent) {
Write-TestRunnerLogInternal -Level 'STEP' -Message 'Running npm/Jest tests with coverage...'
Write-TestRunnerLogInternal -Level 'INFO' -Message "Workspace: $workspaceFull"
}
Push-Location $workspaceFull
try {
$npmArgs = @('run', $TestScript, '--', '--coverage', '--coverageReporters=json-summary', '--coverageReporters=text')
if ($Silent) {
$null = & npm @npmArgs 2>&1
}
else {
& npm @npmArgs
}
if ($LASTEXITCODE -ne 0) {
return [PSCustomObject]@{
Success = $false
Error = "npm run $TestScript failed with exit code $LASTEXITCODE"
}
}
}
finally {
Pop-Location
}
$summaryPath = Join-Path $workspaceFull (Join-Path $CoverageDirectory 'coverage-summary.json')
if (-not (Test-Path $summaryPath -PathType Leaf)) {
return [PSCustomObject]@{
Success = $false
Error = "Jest coverage summary not found at: $summaryPath"
}
}
$summaryJson = Get-Content -LiteralPath $summaryPath -Raw -Encoding UTF8 | ConvertFrom-Json
$total = $summaryJson.total
if ($null -eq $total) {
return [PSCustomObject]@{
Success = $false
Error = "Jest coverage summary is missing 'total' metrics in: $summaryPath"
}
}
$lineRate = [math]::Round([double]$total.lines.pct, 1)
$branchRate = [math]::Round([double]$total.branches.pct, 1)
$methodRate = [math]::Round([double]$total.functions.pct, 1)
$totalMethods = [int]$total.functions.total
$coveredMethods = [int]$total.functions.covered
$resultsDirectory = [System.IO.Path]::GetFullPath((Join-Path $workspaceFull $CoverageDirectory))
if (-not $Silent) {
Write-TestRunnerLogInternal -Level 'OK' -Message "Coverage summary: $summaryPath"
}
return [PSCustomObject]@{
Success = $true
LineRate = $lineRate
BranchRate = $branchRate
MethodRate = $methodRate
TotalMethods = $totalMethods
CoveredMethods = $coveredMethods
CoverageSummaryFile = $summaryPath
ResultsDirectory = $resultsDirectory
}
}
Export-ModuleMember -Function Invoke-TestsWithCoverage, Invoke-NpmJestTestsWithCoverage

View File

@ -9,7 +9,8 @@
"preserveFileName": "scriptsettings.json",
"cloneDepth": 1,
"skippedRelativeDirectories": [
"Release-Package/CustomPlugins"
"Release-Package/CustomPlugins",
"Run-Tests/CustomPlugins"
]
}
}