mirror of
https://github.com/MAKS-IT-COM/maksit-webui.git
synced 2026-07-01 20:36:41 +02:00
(feature): packages updates and tests
This commit is contained in:
parent
2c18605699
commit
977201ecae
4
.vscode/settings.json
vendored
Normal file
4
.vscode/settings.json
vendored
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"typescript.tsdk": "src/node_modules/typescript/lib",
|
||||||
|
"typescript.enablePromptUseWorkspaceTsdk": true
|
||||||
|
}
|
||||||
23
CHANGELOG.md
23
CHANGELOG.md
@ -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).
|
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
|
### 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.
|
||||||
|
|||||||
15
README.md
15
README.md
@ -1,4 +1,6 @@
|
|||||||
# maksit-webui
|
# MaksIT.WebUI
|
||||||
|
|
||||||
|
  
|
||||||
|
|
||||||
Shared React UI library for **maksit-certs-ui** and **maksit-vault** WebUI apps.
|
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 |
|
| npm package | Description |
|
||||||
|-------------|-------------|
|
|-------------|-------------|
|
||||||
| `@maksit/webui-contracts` | Shared TypeScript contracts (paging, gallery types, patch ops, scopes) |
|
| `@maks-it.com/webui-contracts` | Shared TypeScript contracts (paging, gallery types, patch ops, scopes) |
|
||||||
| `@maksit/webui-core` | Utilities (`deepDelta`, enum helpers, ACL parsers) and `useFormState` |
|
| `@maks-it.com/webui-core` | Utilities (`deepDelta`, enum helpers, ACL parsers) and `useFormState` |
|
||||||
| `@maksit/webui-components` | React components, layout, editors, DataTable, auth shell |
|
| `@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)).
|
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
|
cd src
|
||||||
npm install
|
npm install
|
||||||
npm run build
|
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
|
## Release to npmjs
|
||||||
|
|
||||||
1. Set **`NPMJS_MAKS_IT`** to your npm automation token (same pattern as `NUGET_MAKS_IT` for NuGet).
|
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
|
## Consume in product repos
|
||||||
|
|
||||||
```bash
|
```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).
|
Wrap the app with `WebUiProvider` and pass axios/redux adapters — see [assets/docs/NPM_CONSUMPTION.md](assets/docs/NPM_CONSUMPTION.md).
|
||||||
|
|||||||
21
assets/badges/coverage-branches.svg
Normal file
21
assets/badges/coverage-branches.svg
Normal 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 |
21
assets/badges/coverage-lines.svg
Normal file
21
assets/badges/coverage-lines.svg
Normal 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 |
21
assets/badges/coverage-methods.svg
Normal file
21
assets/badges/coverage-methods.svg
Normal 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 |
@ -1,15 +1,15 @@
|
|||||||
# Consuming @maksit/webui-* in Certs UI / Vault
|
# Consuming @maks-it.com/webui-* in Certs UI / Vault
|
||||||
|
|
||||||
Install:
|
Install:
|
||||||
|
|
||||||
```bash
|
```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:
|
Wrap the app:
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import { WebUiProvider, Loader, Authorization } from '@maksit/webui-components'
|
import { WebUiProvider, Loader, Authorization } from '@maks-it.com/webui-components'
|
||||||
|
|
||||||
<WebUiProvider
|
<WebUiProvider
|
||||||
api={{
|
api={{
|
||||||
@ -43,5 +43,5 @@ import { WebUiProvider, Loader, Authorization } from '@maksit/webui-components'
|
|||||||
|
|
||||||
- `RemoteSelectBoxComponent`: use `searchRoute` (absolute API path string) instead of `apiRoute: ApiRoutes`.
|
- `RemoteSelectBoxComponent`: use `searchRoute` (absolute API path string) instead of `apiRoute: ApiRoutes`.
|
||||||
- `SecretComponent`: pass `generateSecretRoute` when `enableGenerate` is true.
|
- `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`).
|
- 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 `@maksit/webui-contracts`.
|
- Identity request types and Zod schemas (`LoginRequest` + `LoginRequestSchema`, `LogoutRequest` + `LogoutRequestSchema`, `RefreshTokenRequest` + `RefreshTokenRequestSchema`) live in `@maks-it.com/webui-contracts`.
|
||||||
|
|||||||
@ -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:
|
Published packages:
|
||||||
|
|
||||||
| Package | npm |
|
| Package | npm |
|
||||||
|---------|-----|
|
|---------|-----|
|
||||||
| `@maksit/webui-contracts` | https://www.npmjs.com/package/@maksit/webui-contracts |
|
| `@maks-it.com/webui-contracts` | https://www.npmjs.com/package/@maks-it.com/webui-contracts |
|
||||||
| `@maksit/webui-core` | https://www.npmjs.com/package/@maksit/webui-core |
|
| `@maks-it.com/webui-core` | https://www.npmjs.com/package/@maks-it.com/webui-core |
|
||||||
| `@maksit/webui-components` | https://www.npmjs.com/package/@maksit/webui-components |
|
| `@maks-it.com/webui-components` | https://www.npmjs.com/package/@maks-it.com/webui-components |
|
||||||
|
|
||||||
## One-time npm setup
|
## One-time npm setup
|
||||||
|
|
||||||
1. Sign in at https://www.npmjs.com/ with the **maks-it.com** org account.
|
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.
|
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 `@maksit/*`:
|
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
|
- https://www.npmjs.com/settings/maks-it.com/tokens
|
||||||
4. Store the token for release tooling:
|
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`).
|
- **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
|
cd src
|
||||||
npm ci
|
npm ci
|
||||||
npm run build
|
npm run build
|
||||||
npm publish -w @maksit/webui-contracts --access public
|
npm publish -w @maks-it.com/webui-contracts --access public
|
||||||
npm publish -w @maksit/webui-core --access public
|
npm publish -w @maks-it.com/webui-core --access public
|
||||||
npm publish -w @maksit/webui-components --access public
|
npm publish -w @maks-it.com/webui-components --access public
|
||||||
```
|
```
|
||||||
|
|
||||||
Order matters: **contracts → core → components**.
|
Order matters: **contracts → core → components**.
|
||||||
|
|
||||||
Or use the helper script:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
.\scripts\publish-npm.ps1
|
|
||||||
```
|
|
||||||
|
|
||||||
Verify:
|
Verify:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
npm view @maksit/webui-contracts version
|
npm view @maks-it.com/webui-contracts version
|
||||||
npm view @maksit/webui-core version
|
npm view @maks-it.com/webui-core version
|
||||||
npm view @maksit/webui-components version
|
npm view @maks-it.com/webui-components version
|
||||||
```
|
```
|
||||||
|
|
||||||
## Release pipeline (recommended)
|
## 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`).
|
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`.
|
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 `Release-Package.ps1`.
|
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
|
## After publish — Certs UI / Vault
|
||||||
|
|
||||||
In each WebUI app (`MaksIT.WebUI/package.json`):
|
In each WebUI app (`MaksIT.WebUI/package.json`):
|
||||||
|
|
||||||
```json
|
```json
|
||||||
"@maksit/webui-contracts": "^0.1.0",
|
"@maks-it.com/webui-contracts": "^0.1.0",
|
||||||
"@maksit/webui-core": "^0.1.0",
|
"@maks-it.com/webui-core": "^0.1.0",
|
||||||
"@maksit/webui-components": "^0.1.0"
|
"@maks-it.com/webui-components": "^0.1.0"
|
||||||
```
|
```
|
||||||
|
|
||||||
Then refresh the lockfile:
|
Then refresh the lockfile:
|
||||||
|
|||||||
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
61
src/coverage/coverage-summary.json
Normal file
61
src/coverage/coverage-summary.json
Normal 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
26
src/jest.config.cjs
Normal 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
4136
src/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "maksit-webui",
|
"name": "maksit-webui",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"description": "Shared React UI library for MaksIT Certs UI and Vault WebUI",
|
"description": "Shared React UI library for MaksIT Certs UI and Vault WebUI",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"packages/*"
|
"packages/*"
|
||||||
@ -10,13 +10,22 @@
|
|||||||
"node": ">=20"
|
"node": ">=20"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.4.3",
|
||||||
|
"glob": "^13.0.6",
|
||||||
|
"test-exclude": "^8.0.0"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"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",
|
"typecheck": "npm run typecheck --workspaces --if-present",
|
||||||
"clean": "npm run clean --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",
|
"author": "MaksIT",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
}
|
}
|
||||||
|
|||||||
66
src/packages/components/README.md
Normal file
66
src/packages/components/README.md
Normal 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
|
||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@maksit/webui-components",
|
"name": "@maks-it.com/webui-components",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"description": "Shared React components for MaksIT WebUI apps",
|
"description": "Shared React components for MaksIT WebUI apps",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./dist/index.cjs",
|
"main": "./dist/index.cjs",
|
||||||
@ -18,7 +18,7 @@
|
|||||||
"README.md"
|
"README.md"
|
||||||
],
|
],
|
||||||
"scripts": {
|
"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",
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||||
"clean": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\"",
|
"clean": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\"",
|
||||||
"prepublishOnly": "npm run build"
|
"prepublishOnly": "npm run build"
|
||||||
@ -33,34 +33,33 @@
|
|||||||
"directory": "src/packages/components"
|
"directory": "src/packages/components"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@maksit/webui-contracts": "^0.1.0",
|
"@maks-it.com/webui-contracts": "^0.2.0",
|
||||||
"@maksit/webui-core": "^0.1.0",
|
"@maks-it.com/webui-core": "^0.2.0",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.3.0",
|
||||||
"lodash": "^4.17.23",
|
"lodash": "^4.18.1"
|
||||||
"uuid": "^13.0.0"
|
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@tanstack/react-table": "^8.0.0",
|
"@tanstack/react-table": "^8.0.0",
|
||||||
"lucide-react": "^0.500.0",
|
"lucide-react": "^1.0.0",
|
||||||
"react": "^18.0.0 || ^19.0.0",
|
"react": "^18.0.0 || ^19.0.0",
|
||||||
"react-dom": "^18.0.0 || ^19.0.0",
|
"react-dom": "^18.0.0 || ^19.0.0",
|
||||||
"react-router-dom": "^7.0.0",
|
"react-router-dom": "^7.0.0",
|
||||||
"react-virtualized": "^9.22.0",
|
"react-virtualized": "^9.22.0",
|
||||||
"zod": "^4.0.0"
|
"zod": "^4.4.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"@types/lodash": "^4.17.24",
|
"@types/lodash": "^4.17.24",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.15",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@types/react-virtualized": "^9.22.3",
|
"@types/react-virtualized": "^9.22.3",
|
||||||
"lucide-react": "^0.576.0",
|
"lucide-react": "^1.16.0",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.6",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.6",
|
||||||
"react-router-dom": "^7.13.1",
|
"react-router-dom": "^7.15.1",
|
||||||
"react-virtualized": "^9.22.6",
|
"react-virtualized": "^9.22.6",
|
||||||
"tsup": "^8.5.0",
|
"tsup": "^8.5.1",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^6.0.3",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.4.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,9 +1,9 @@
|
|||||||
import React, { useState, useMemo, useRef, useEffect } from 'react'
|
import React, { useState, useMemo, useRef, useEffect } from 'react'
|
||||||
import { AutoSizer, MultiGrid, GridCellProps } from 'react-virtualized'
|
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 { Plus, Trash2, Edit } from 'lucide-react'
|
||||||
import { debounce } from 'lodash'
|
import debounce from 'lodash/debounce'
|
||||||
|
|
||||||
|
|
||||||
interface FilterProps {
|
interface FilterProps {
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { useMemo, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
import { debounce } from 'lodash'
|
import debounce from 'lodash/debounce'
|
||||||
|
|
||||||
interface FilterPropsBase {
|
interface FilterPropsBase {
|
||||||
filterId?: string
|
filterId?: string
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { formatISODateString } from '@maksit/webui-core'
|
import { formatISODateString } from '@maks-it.com/webui-core'
|
||||||
|
|
||||||
interface NormalLabelProps {
|
interface NormalLabelProps {
|
||||||
type: 'normal'
|
type: 'normal'
|
||||||
|
|||||||
@ -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> {
|
export interface EntityScopesSummaryProps<TScopeEntityType extends number = number> {
|
||||||
entries: SearchEntityScopeEntry<TScopeEntityType>[]
|
entries: SearchEntityScopeEntry<TScopeEntityType>[]
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
import { useState, useEffect, FC } from 'react'
|
import { useState, useEffect, FC } from 'react'
|
||||||
import { v4 as uuidv4 } from 'uuid'
|
|
||||||
|
|
||||||
// Define types for a toast
|
// Define types for a toast
|
||||||
interface Toast {
|
interface Toast {
|
||||||
@ -9,6 +8,8 @@ interface Toast {
|
|||||||
duration?: number;
|
duration?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const createToastId = (): string => crypto.randomUUID()
|
||||||
|
|
||||||
const Toast: FC = () => {
|
const Toast: FC = () => {
|
||||||
const [toasts, setToasts] = useState<Toast[]>([])
|
const [toasts, setToasts] = useState<Toast[]>([])
|
||||||
|
|
||||||
@ -17,7 +18,7 @@ const Toast: FC = () => {
|
|||||||
const { message, type, duration } = event.detail
|
const { message, type, duration } = event.detail
|
||||||
|
|
||||||
// Add the new toast, avoiding duplicates with same message & type
|
// Add the new toast, avoiding duplicates with same message & type
|
||||||
const id = uuidv4()
|
const id = createToastId()
|
||||||
setToasts(prev => {
|
setToasts(prev => {
|
||||||
const hasDuplicate = prev.some(t => t.message === message && t.type === type)
|
const hasDuplicate = prev.some(t => t.message === message && t.type === type)
|
||||||
if (hasDuplicate) return prev
|
if (hasDuplicate) return prev
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import React, { useRef, useState } from 'react'
|
import React, { useRef, useState } from 'react'
|
||||||
import { ButtonComponent } from './ButtonComponent'
|
import { ButtonComponent } from './ButtonComponent'
|
||||||
import { TrashIcon } from 'lucide-react'
|
import { Trash2 } from 'lucide-react'
|
||||||
|
|
||||||
interface FileUploadComponentProps {
|
interface FileUploadComponentProps {
|
||||||
label?: string
|
label?: string
|
||||||
@ -163,7 +163,7 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
|||||||
disabled={disabled || displayFiles.length === 0}
|
disabled={disabled || displayFiles.length === 0}
|
||||||
colspan={1}
|
colspan={1}
|
||||||
>
|
>
|
||||||
<TrashIcon />
|
<Trash2 />
|
||||||
</ButtonComponent>
|
</ButtonComponent>
|
||||||
|
|
||||||
{/* Select files button */}
|
{/* Select files button */}
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { useState, useCallback, ChangeEvent, useEffect, useRef } from 'react'
|
import { useState, useCallback, ChangeEvent, useEffect, useRef } from 'react'
|
||||||
import type { PagedRequest } from '@maksit/webui-contracts'
|
import type { PagedRequest } from '@maks-it.com/webui-contracts'
|
||||||
import type { SearchResponseBase } from '@maksit/webui-contracts'
|
import type { SearchResponseBase } from '@maks-it.com/webui-contracts'
|
||||||
import { deepEqual } from '@maksit/webui-core'
|
import { deepEqual } from '@maks-it.com/webui-core'
|
||||||
import { SelectBoxComponent } from './SelectBoxComponent'
|
import { SelectBoxComponent } from './SelectBoxComponent'
|
||||||
|
|
||||||
export type RemoteSelectSearchDataSource<TRequest extends PagedRequest> = (
|
export type RemoteSelectSearchDataSource<TRequest extends PagedRequest> = (
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { debounce } from 'lodash'
|
import debounce from 'lodash/debounce'
|
||||||
import { CircleX } from 'lucide-react'
|
import { CircleX } from 'lucide-react'
|
||||||
import { ChangeEvent, FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import { ChangeEvent, FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { FieldContainer } from './FieldContainer'
|
import { FieldContainer } from './FieldContainer'
|
||||||
|
|||||||
6
src/packages/components/tsconfig.build.json
Normal file
6
src/packages/components/tsconfig.build.json
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"ignoreDeprecations": "6.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
48
src/packages/contracts/README.md
Normal file
48
src/packages/contracts/README.md
Normal 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
|
||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@maksit/webui-contracts",
|
"name": "@maks-it.com/webui-contracts",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"description": "Shared TypeScript contracts for MaksIT WebUI apps",
|
"description": "Shared TypeScript contracts for MaksIT WebUI apps",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./dist/index.cjs",
|
"main": "./dist/index.cjs",
|
||||||
@ -18,7 +18,8 @@
|
|||||||
"README.md"
|
"README.md"
|
||||||
],
|
],
|
||||||
"scripts": {
|
"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",
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||||
"clean": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\"",
|
"clean": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\"",
|
||||||
"prepublishOnly": "npm run build"
|
"prepublishOnly": "npm run build"
|
||||||
@ -33,11 +34,11 @@
|
|||||||
"directory": "src/packages/contracts"
|
"directory": "src/packages/contracts"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"zod": "^4.0.0"
|
"zod": "^4.4.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"tsup": "^8.5.0",
|
"tsup": "^8.5.1",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^6.0.3",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.4.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 type { RequestModelBase } from './RequestModelBase'
|
||||||
import { RequestModelBaseSchema } from './RequestModelBase'
|
import { RequestModelBaseSchema } from './RequestModelBase'
|
||||||
|
|
||||||
@ -11,7 +11,8 @@ export interface PagedRequest extends RequestModelBase {
|
|||||||
isAscending?: boolean
|
isAscending?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PagedRequestSchema: ZodType<PagedRequest> = RequestModelBaseSchema.and(
|
export const PagedRequestSchema: ZodType<PagedRequest> = intersection(
|
||||||
|
RequestModelBaseSchema,
|
||||||
object({
|
object({
|
||||||
pageSize: number().optional(),
|
pageSize: number().optional(),
|
||||||
pageNumber: number().optional(),
|
pageNumber: number().optional(),
|
||||||
|
|||||||
@ -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 { PatchOperation } from './PatchOperation'
|
||||||
import { RequestModelBase, RequestModelBaseSchema } from './RequestModelBase'
|
import { RequestModelBase, RequestModelBaseSchema } from './RequestModelBase'
|
||||||
|
|
||||||
@ -8,7 +8,8 @@ export interface PatchRequestModelBase extends RequestModelBase {
|
|||||||
[key: string]: unknown
|
[key: string]: unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PatchRequestModelBaseSchema: ZodType<PatchRequestModelBase> = RequestModelBaseSchema.and(
|
export const PatchRequestModelBaseSchema: ZodType<PatchRequestModelBase> = intersection(
|
||||||
|
RequestModelBaseSchema,
|
||||||
object({
|
object({
|
||||||
operations: record(string(), z.enum(PatchOperation)).optional(),
|
operations: record(string(), z.enum(PatchOperation)).optional(),
|
||||||
})
|
})
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { object, RefinementCtx, string, ZodIssueCode, type ZodType } from 'zod'
|
import { object, RefinementCtx, string, type ZodType } from 'zod'
|
||||||
|
|
||||||
export interface LoginRequest {
|
export interface LoginRequest {
|
||||||
username: string
|
username: string
|
||||||
@ -10,7 +10,7 @@ export interface LoginRequest {
|
|||||||
const loginRequestSchemaRefine = (data: LoginRequest, ctx: RefinementCtx) => {
|
const loginRequestSchemaRefine = (data: LoginRequest, ctx: RefinementCtx) => {
|
||||||
if (data.username === '') {
|
if (data.username === '') {
|
||||||
ctx.addIssue({
|
ctx.addIssue({
|
||||||
code: ZodIssueCode.custom,
|
code: 'custom',
|
||||||
message: 'Username cannot be empty',
|
message: 'Username cannot be empty',
|
||||||
path: ['username'],
|
path: ['username'],
|
||||||
})
|
})
|
||||||
@ -18,7 +18,7 @@ const loginRequestSchemaRefine = (data: LoginRequest, ctx: RefinementCtx) => {
|
|||||||
|
|
||||||
if (data.password === '') {
|
if (data.password === '') {
|
||||||
ctx.addIssue({
|
ctx.addIssue({
|
||||||
code: ZodIssueCode.custom,
|
code: 'custom',
|
||||||
message: 'Password cannot be empty',
|
message: 'Password cannot be empty',
|
||||||
path: ['password'],
|
path: ['password'],
|
||||||
})
|
})
|
||||||
@ -26,7 +26,7 @@ const loginRequestSchemaRefine = (data: LoginRequest, ctx: RefinementCtx) => {
|
|||||||
|
|
||||||
if (data.twoFactorCode && data.twoFactorRecoveryCode) {
|
if (data.twoFactorCode && data.twoFactorRecoveryCode) {
|
||||||
ctx.addIssue({
|
ctx.addIssue({
|
||||||
code: ZodIssueCode.custom,
|
code: 'custom',
|
||||||
message: 'Cannot have both twoFactorCode and twoFactorRecoveryCode',
|
message: 'Cannot have both twoFactorCode and twoFactorRecoveryCode',
|
||||||
path: ['twoFactorCode', 'twoFactorRecoveryCode'],
|
path: ['twoFactorCode', 'twoFactorRecoveryCode'],
|
||||||
})
|
})
|
||||||
|
|||||||
73
src/packages/contracts/src/schemas.test.ts
Normal file
73
src/packages/contracts/src/schemas.test.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
6
src/packages/contracts/tsconfig.build.json
Normal file
6
src/packages/contracts/tsconfig.build.json
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"ignoreDeprecations": "6.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -4,5 +4,6 @@
|
|||||||
"rootDir": "src",
|
"rootDir": "src",
|
||||||
"outDir": "dist"
|
"outDir": "dist"
|
||||||
},
|
},
|
||||||
"include": ["src"]
|
"include": ["src"],
|
||||||
|
"exclude": ["src/**/*.test.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
60
src/packages/core/README.md
Normal file
60
src/packages/core/README.md
Normal 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
|
||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@maksit/webui-core",
|
"name": "@maks-it.com/webui-core",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"description": "Shared utilities and hooks for MaksIT WebUI apps",
|
"description": "Shared utilities and hooks for MaksIT WebUI apps",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./dist/index.cjs",
|
"main": "./dist/index.cjs",
|
||||||
@ -18,7 +18,8 @@
|
|||||||
"README.md"
|
"README.md"
|
||||||
],
|
],
|
||||||
"scripts": {
|
"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",
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||||
"clean": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\"",
|
"clean": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\"",
|
||||||
"prepublishOnly": "npm run build"
|
"prepublishOnly": "npm run build"
|
||||||
@ -33,20 +34,20 @@
|
|||||||
"directory": "src/packages/core"
|
"directory": "src/packages/core"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@maksit/webui-contracts": "^0.1.0",
|
"@maks-it.com/webui-contracts": "^0.2.0",
|
||||||
"date-fns": "^4.1.0"
|
"date-fns": "^4.3.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"axios": "^1.7.0",
|
"axios": "^1.16.0",
|
||||||
"react": "^18.0.0 || ^19.0.0",
|
"react": "^18.0.0 || ^19.0.0",
|
||||||
"zod": "^4.0.0"
|
"zod": "^4.4.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.15",
|
||||||
"axios": "^1.13.2",
|
"axios": "^1.16.1",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.6",
|
||||||
"tsup": "^8.5.0",
|
"tsup": "^8.5.1",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^6.0.3",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.4.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
31
src/packages/core/src/functions/acl/parseAclEntry.test.ts
Normal file
31
src/packages/core/src/functions/acl/parseAclEntry.test.ts
Normal 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 },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -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,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -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.
|
* Virtualized DataTable view model used by client paging and search helpers.
|
||||||
|
|||||||
@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
114
src/packages/core/src/functions/deep/deepDelta.test.ts
Normal file
114
src/packages/core/src/functions/deep/deepDelta.test.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -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 { deepCopy } from './deepCopy'
|
||||||
import { deepEqual } from './deepEqual'
|
import { deepEqual } from './deepEqual'
|
||||||
|
|
||||||
|
|||||||
39
src/packages/core/src/functions/deep/deepEqual.test.ts
Normal file
39
src/packages/core/src/functions/deep/deepEqual.test.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
32
src/packages/core/src/functions/enum/flags.test.ts
Normal file
32
src/packages/core/src/functions/enum/flags.test.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
15
src/packages/core/src/functions/guid/isGuid.test.ts
Normal file
15
src/packages/core/src/functions/guid/isGuid.test.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -1,5 +1,5 @@
|
|||||||
import type { AxiosError } from 'axios'
|
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'
|
import { formatProblemDetailsMessage } from './problemDetails'
|
||||||
|
|
||||||
/** Shows toast(s) for problem+json and 401 responses. */
|
/** Shows toast(s) for problem+json and 401 responses. */
|
||||||
|
|||||||
26
src/packages/core/src/http/problemDetails.test.ts
Normal file
26
src/packages/core/src/http/problemDetails.test.ts
Normal 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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -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. */
|
/** Builds a user-facing message from RFC 7807 problem details. */
|
||||||
export function formatProblemDetailsMessage(problem: ProblemDetails): string {
|
export function formatProblemDetailsMessage(problem: ProblemDetails): string {
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import type { LoginResponse } from '@maksit/webui-contracts'
|
import type { LoginResponse } from '@maks-it.com/webui-contracts'
|
||||||
|
|
||||||
const readIdentity = () => {
|
const readIdentity = () => {
|
||||||
const json = localStorage.getItem('identity')
|
const json = localStorage.getItem('identity')
|
||||||
|
|||||||
@ -3,5 +3,5 @@ export type {
|
|||||||
FormValidationResult,
|
FormValidationResult,
|
||||||
FormValidationSchema,
|
FormValidationSchema,
|
||||||
} from './FormValidationSchema'
|
} from './FormValidationSchema'
|
||||||
export type { PagedResponse } from '@maksit/webui-contracts'
|
export type { PagedResponse } from '@maks-it.com/webui-contracts'
|
||||||
export { ScopePermission } from './ScopePermissions'
|
export { ScopePermission } from './ScopePermissions'
|
||||||
|
|||||||
6
src/packages/core/tsconfig.build.json
Normal file
6
src/packages/core/tsconfig.build.json
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"ignoreDeprecations": "6.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -4,5 +4,6 @@
|
|||||||
"rootDir": "src",
|
"rootDir": "src",
|
||||||
"outDir": "dist"
|
"outDir": "dist"
|
||||||
},
|
},
|
||||||
"include": ["src"]
|
"include": ["src"],
|
||||||
|
"exclude": ["src/**/*.test.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
11
src/tsconfig.jest.json
Normal file
11
src/tsconfig.jest.json
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"noEmit": true,
|
||||||
|
"types": ["jest", "node"]
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"packages/core/src/**/*.ts",
|
||||||
|
"packages/contracts/src/**/*.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
@ -3,263 +3,20 @@
|
|||||||
|
|
||||||
<#
|
<#
|
||||||
.SYNOPSIS
|
.SYNOPSIS
|
||||||
Generates SVG coverage badges for README.
|
Legacy entry point — forwards to the Run-Tests plugin engine.
|
||||||
|
|
||||||
.DESCRIPTION
|
.DESCRIPTION
|
||||||
This script runs unit tests via TestRunner.psm1, then generates shields.io-style
|
Generate-CoverageBadges.ps1 is kept for backward compatibility.
|
||||||
SVG badges for line, branch, and method coverage.
|
Configure plugins in src/Run-Tests/scriptsettings.json.
|
||||||
|
|
||||||
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)
|
|
||||||
#>
|
#>
|
||||||
|
|
||||||
$ErrorActionPreference = "Stop"
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
# Get the directory of the current script (for loading settings and relative paths)
|
$runTestsScript = Join-Path (Split-Path $PSScriptRoot -Parent) "Run-Tests\Run-Tests.ps1"
|
||||||
$ScriptDir = $PSScriptRoot
|
if (-not (Test-Path $runTestsScript -PathType Leaf)) {
|
||||||
$UtilsDir = Split-Path $ScriptDir -Parent
|
Write-Error "Run-Tests engine not found at: $runTestsScript"
|
||||||
|
|
||||||
#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."
|
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
$BadgesDir = [System.IO.Path]::GetFullPath((Join-Path $ScriptDir $Settings.paths.badgesDir))
|
& pwsh -NoProfile -ExecutionPolicy Bypass -File $runTestsScript
|
||||||
|
exit $LASTEXITCODE
|
||||||
# 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
|
|
||||||
|
|||||||
@ -1,47 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft-07/schema",
|
"$schema": "https://json-schema.org/draft-07/schema",
|
||||||
"title": "Generate Coverage Badges Script Settings",
|
"title": "Generate Coverage Badges Script Settings",
|
||||||
"description": "Configuration for Generate-CoverageBadges.ps1 script",
|
"description": "Legacy settings file. Use utils/Run-Tests/scriptsettings.json instead.",
|
||||||
"openReport": false,
|
"_forwardTo": "..\\Run-Tests\\scriptsettings.json"
|
||||||
"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."
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -149,13 +149,21 @@ function Invoke-Plugin {
|
|||||||
throw "Each images[] entry must define 'service' and 'dockerfile'."
|
throw "Each images[] entry must define 'service' and 'dockerfile'."
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$service = [string]$img.service
|
||||||
$dockerfileRel = [string]$img.dockerfile
|
$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)) {
|
if (-not (Test-Path $dockerfilePath -PathType Leaf)) {
|
||||||
throw "Dockerfile not found: $dockerfilePath"
|
throw "Dockerfile not found: $dockerfilePath"
|
||||||
}
|
}
|
||||||
|
|
||||||
$service = [string]$img.service
|
|
||||||
$baseName = "$registryUrl/$($pluginSettings.projectName)/$service"
|
$baseName = "$registryUrl/$($pluginSettings.projectName)/$service"
|
||||||
|
|
||||||
$versionEnvFiles = @()
|
$versionEnvFiles = @()
|
||||||
@ -165,7 +173,7 @@ function Invoke-Plugin {
|
|||||||
continue
|
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)) {
|
if (-not (Test-Path -LiteralPath $envFilePath -PathType Leaf)) {
|
||||||
throw "Configured versionEnvFiles entry not found: $envFilePath"
|
throw "Configured versionEnvFiles entry not found: $envFilePath"
|
||||||
}
|
}
|
||||||
@ -187,7 +195,7 @@ function Invoke-Plugin {
|
|||||||
|
|
||||||
$primaryRef = "${baseName}:$($imageTags[0])"
|
$primaryRef = "${baseName}:$($imageTags[0])"
|
||||||
Write-Log -Level "STEP" -Message "Building $primaryRef ..."
|
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) {
|
if ($LASTEXITCODE -ne 0) {
|
||||||
throw "Docker build failed for $primaryRef"
|
throw "Docker build failed for $primaryRef"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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."
|
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 {
|
function Get-ReleaseNotesInternal {
|
||||||
param(
|
param(
|
||||||
[Parameter(Mandatory = $true)]
|
[Parameter(Mandatory = $true)]
|
||||||
@ -61,11 +87,11 @@ function Get-ReleaseNotesInternal {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$releaseNotesContent = Get-Content $ReleaseNotesFile -Raw
|
$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."
|
throw "No version entry found in the configured release notes source."
|
||||||
}
|
}
|
||||||
|
|
||||||
$releaseNotesVersion = $Matches[1]
|
|
||||||
if ($releaseNotesVersion -ne $Version) {
|
if ($releaseNotesVersion -ne $Version) {
|
||||||
throw "Project version ($Version) does not match the latest release notes version ($releaseNotesVersion)."
|
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 "OK" -Message " Release notes version matches: v$releaseNotesVersion"
|
||||||
|
|
||||||
Write-Log -Level "STEP" -Message "Extracting release notes..."
|
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)
|
$match = [regex]::Match($releaseNotesContent, $pattern)
|
||||||
|
|
||||||
if (-not $match.Success) {
|
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) {
|
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
|
$repo = Get-GitHubRepositoryInternal -ConfiguredRepository $configuredRepository
|
||||||
|
|||||||
@ -35,7 +35,8 @@ function Invoke-Plugin {
|
|||||||
|
|
||||||
$workspaceRoot = $null
|
$workspaceRoot = $null
|
||||||
if ($pluginSettings.workspaceRoot) {
|
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)) {
|
elseif ($shared.PSObject.Properties['npmWorkspaceRoot'] -and -not [string]::IsNullOrWhiteSpace([string]$shared.npmWorkspaceRoot)) {
|
||||||
$workspaceRoot = [string]$shared.npmWorkspaceRoot
|
$workspaceRoot = [string]$shared.npmWorkspaceRoot
|
||||||
|
|||||||
@ -45,7 +45,8 @@ function Invoke-Plugin {
|
|||||||
|
|
||||||
$workspaceRoot = $null
|
$workspaceRoot = $null
|
||||||
if ($pluginSettings.workspaceRoot) {
|
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)) {
|
elseif ($shared.PSObject.Properties['npmWorkspaceRoot'] -and -not [string]::IsNullOrWhiteSpace([string]$shared.npmWorkspaceRoot)) {
|
||||||
$workspaceRoot = [string]$shared.npmWorkspaceRoot
|
$workspaceRoot = [string]$shared.npmWorkspaceRoot
|
||||||
|
|||||||
@ -68,12 +68,11 @@ function Invoke-Plugin {
|
|||||||
$pluginSettings = $Settings
|
$pluginSettings = $Settings
|
||||||
$shared = $Settings.context
|
$shared = $Settings.context
|
||||||
|
|
||||||
$packageJsonPath = if ($pluginSettings.packageJsonPath) {
|
$packageJsonPaths = @(Resolve-RelativePaths -Value $pluginSettings.packageJsonPath -BasePath $shared.scriptDir)
|
||||||
(Resolve-RelativePaths -Value $pluginSettings.packageJsonPath -BasePath $shared.scriptDir)[0]
|
if ($packageJsonPaths.Count -eq 0) {
|
||||||
}
|
|
||||||
else {
|
|
||||||
throw "NpmReleaseVersion plugin requires 'packageJsonPath' in scriptsettings.json."
|
throw "NpmReleaseVersion plugin requires 'packageJsonPath' in scriptsettings.json."
|
||||||
}
|
}
|
||||||
|
$packageJsonPath = $packageJsonPaths[0]
|
||||||
|
|
||||||
$version = Get-PackageJsonVersionInternal -PackageJsonPath $packageJsonPath
|
$version = Get-PackageJsonVersionInternal -PackageJsonPath $packageJsonPath
|
||||||
$syncWorkspaceVersions = $false
|
$syncWorkspaceVersions = $false
|
||||||
|
|||||||
@ -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"
|
$releaseContextModulePath = Join-Path $PSScriptRoot "ReleaseContext.psm1"
|
||||||
if (Test-Path $releaseContextModulePath -PathType Leaf) {
|
if (Test-Path $releaseContextModulePath -PathType Leaf) {
|
||||||
Import-Module $releaseContextModulePath -Force
|
Import-Module $releaseContextModulePath -Force
|
||||||
@ -79,7 +79,9 @@ function New-EngineContext {
|
|||||||
[psobject]$Settings
|
[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'))
|
$artifactsDirectory = [System.IO.Path]::GetFullPath((Join-Path $ScriptDir '..\\..\\release'))
|
||||||
|
|
||||||
$currentBranch = Get-CurrentBranch
|
$currentBranch = Get-CurrentBranch
|
||||||
@ -113,7 +115,7 @@ function New-EngineContext {
|
|||||||
Assert-WorkingTreeClean
|
Assert-WorkingTreeClean
|
||||||
|
|
||||||
$tag = "v$version"
|
$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]@{
|
return [pscustomobject]@{
|
||||||
scriptDir = $ScriptDir
|
scriptDir = $ScriptDir
|
||||||
|
|||||||
@ -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. |
|
| `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). |
|
| `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>`). |
|
| `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 DotNetReleaseVersion; `Initialize-ReleaseStageContext` sets `releaseDir` only. |
|
| `EngineSupport.psm1` | Warn-only dirty-tree preflight; default `context.tag` = `v{version}` from the configured version plugin; `Initialize-ReleaseStageContext` sets `releaseDir` only. |
|
||||||
|
|
||||||
## Plugins
|
## Plugins
|
||||||
|
|
||||||
`CorePlugins/` — e.g. `DotNetReleaseVersion`, `NpmReleaseVersion`, `NpmBuild`, `NpmPublish`, `DockerPush`, `HelmPush`, `ReleasePublishGuard`. Optional `CustomPlugins/`.
|
`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`
|
## `ReleasePublishGuard`
|
||||||
|
|
||||||
@ -38,7 +38,7 @@ For TypeScript monorepos published to npmjs:
|
|||||||
|
|
||||||
## Helm charts in git
|
## 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.
|
Sample chart: repository `charts/my-service/` (matches the sample `chartPath` in `scriptsettings.json`). Product repos often use `src/helm/` instead.
|
||||||
|
|
||||||
|
|||||||
@ -6,9 +6,9 @@
|
|||||||
Helpers to resolve release semver from plugin configuration.
|
Helpers to resolve release semver from plugin configuration.
|
||||||
|
|
||||||
.DESCRIPTION
|
.DESCRIPTION
|
||||||
Used by New-EngineContext and the DotNetReleaseVersion plugin:
|
Used by New-EngineContext and version plugins:
|
||||||
- Source: DotNetReleaseVersion plugin -> projectFiles
|
- DotNetReleaseVersion plugin -> projectFiles (.csproj <Version>)
|
||||||
- Version from first path in projectFiles (SDK-style .csproj <Version>)
|
- NpmReleaseVersion plugin -> packageJsonPath (package.json version)
|
||||||
#>
|
#>
|
||||||
|
|
||||||
if (-not (Get-Command Write-Log -ErrorAction SilentlyContinue)) {
|
if (-not (Get-Command Write-Log -ErrorAction SilentlyContinue)) {
|
||||||
@ -136,10 +136,90 @@ function Resolve-DotNetReleaseVersion {
|
|||||||
|
|
||||||
return [pscustomobject]@{
|
return [pscustomobject]@{
|
||||||
version = $version
|
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
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -39,7 +39,8 @@
|
|||||||
"githubToken": "GITHUB_MAKS_IT_COM",
|
"githubToken": "GITHUB_MAKS_IT_COM",
|
||||||
"repository": "https://github.com/MAKS-IT-COM/maksit-webui",
|
"repository": "https://github.com/MAKS-IT-COM/maksit-webui",
|
||||||
"releaseNotesFile": "..\\..\\CHANGELOG.md",
|
"releaseNotesFile": "..\\..\\CHANGELOG.md",
|
||||||
"releaseTitlePattern": "Release {version}"
|
"releaseTitlePattern": "Release {version}",
|
||||||
|
"requireReleaseAssets": false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "NpmPublish",
|
"name": "NpmPublish",
|
||||||
@ -50,9 +51,9 @@
|
|||||||
"access": "public",
|
"access": "public",
|
||||||
"workspaceRoot": "..\\..\\src",
|
"workspaceRoot": "..\\..\\src",
|
||||||
"publishOrder": [
|
"publishOrder": [
|
||||||
"@maksit/webui-contracts",
|
"@maks-it.com/webui-contracts",
|
||||||
"@maksit/webui-core",
|
"@maks-it.com/webui-core",
|
||||||
"@maksit/webui-components"
|
"@maks-it.com/webui-components"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|||||||
177
utils/Run-Tests/CorePlugins/CoverageBadges.psm1
Normal file
177
utils/Run-Tests/CorePlugins/CoverageBadges.psm1
Normal 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
|
||||||
98
utils/Run-Tests/CorePlugins/DotNetTest.psm1
Normal file
98
utils/Run-Tests/CorePlugins/DotNetTest.psm1
Normal 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
|
||||||
82
utils/Run-Tests/CorePlugins/NpmJestTest.psm1
Normal file
82
utils/Run-Tests/CorePlugins/NpmJestTest.psm1
Normal 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
|
||||||
184
utils/Run-Tests/CorePlugins/QualityGate.psm1
Normal file
184
utils/Run-Tests/CorePlugins/QualityGate.psm1
Normal 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
|
||||||
0
utils/Run-Tests/CustomPlugins/.gitkeep
Normal file
0
utils/Run-Tests/CustomPlugins/.gitkeep
Normal file
35
utils/Run-Tests/EngineSupport.psm1
Normal file
35
utils/Run-Tests/EngineSupport.psm1
Normal 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
|
||||||
376
utils/Run-Tests/PluginSupport.psm1
Normal file
376
utils/Run-Tests/PluginSupport.psm1
Normal 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
58
utils/Run-Tests/README.md
Normal 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`.
|
||||||
3
utils/Run-Tests/Run-Tests.bat
Normal file
3
utils/Run-Tests/Run-Tests.bat
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
@echo off
|
||||||
|
pwsh -NoProfile -ExecutionPolicy Bypass -File "%~dp0Run-Tests.ps1"
|
||||||
|
pause
|
||||||
76
utils/Run-Tests/Run-Tests.ps1
Normal file
76
utils/Run-Tests/Run-Tests.ps1
Normal 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 "=================================================="
|
||||||
63
utils/Run-Tests/scriptsettings.json
Normal file
63
utils/Run-Tests/scriptsettings.json
Normal 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."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
|||||||
@ -9,7 +9,8 @@
|
|||||||
"preserveFileName": "scriptsettings.json",
|
"preserveFileName": "scriptsettings.json",
|
||||||
"cloneDepth": 1,
|
"cloneDepth": 1,
|
||||||
"skippedRelativeDirectories": [
|
"skippedRelativeDirectories": [
|
||||||
"Release-Package/CustomPlugins"
|
"Release-Package/CustomPlugins",
|
||||||
|
"Run-Tests/CustomPlugins"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user