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).
|
||||
|
||||
## [Unreleased]
|
||||
## [v0.2.0] - 2026-05-24
|
||||
|
||||
### Added
|
||||
|
||||
- Initial `@maksit/webui-contracts`, `@maksit/webui-core`, and `@maksit/webui-components` packages extracted from Certs UI and Vault WebUI.
|
||||
- Jest test suite (50 tests) covering `@maks-it.com/webui-core` utilities and `@maks-it.com/webui-contracts` schemas.
|
||||
- Root `npm test` script and per-package build tsconfigs (`tsconfig.build.json`) for TypeScript 6 declaration emit.
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated dependencies to current majors: TypeScript 6, Jest 30, Zod 4.4, lucide-react 1.x, axios 1.16, and React 19.2.
|
||||
- Migrated Zod schemas to v4 APIs: `intersection()` replaces `.and()`, custom refinements use `'custom'` issue codes.
|
||||
- `@maks-it.com/webui-components` Toast IDs now use `crypto.randomUUID()`; lodash imports use `lodash/debounce` subpaths.
|
||||
- Peer dependency ranges: `zod` ^4.4, `axios` ^1.16, `lucide-react` ^1.0.
|
||||
|
||||
### Removed
|
||||
|
||||
- `uuid` runtime dependency from `@maks-it.com/webui-components`.
|
||||
|
||||
## [v0.1.0] - 2026-05-24
|
||||
|
||||
### Added
|
||||
|
||||
- Initial `@maks-it.com/webui-contracts`, `@maks-it.com/webui-core`, and `@maks-it.com/webui-components` packages extracted from Certs UI and Vault WebUI.
|
||||
- npm publish under the `@maks-it.com` org scope on registry.npmjs.org.
|
||||
|
||||
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.
|
||||
|
||||
@ -6,9 +8,9 @@ Shared React UI library for **maksit-certs-ui** and **maksit-vault** WebUI apps.
|
||||
|
||||
| npm package | Description |
|
||||
|-------------|-------------|
|
||||
| `@maksit/webui-contracts` | Shared TypeScript contracts (paging, gallery types, patch ops, scopes) |
|
||||
| `@maksit/webui-core` | Utilities (`deepDelta`, enum helpers, ACL parsers) and `useFormState` |
|
||||
| `@maksit/webui-components` | React components, layout, editors, DataTable, auth shell |
|
||||
| `@maks-it.com/webui-contracts` | Shared TypeScript contracts (paging, gallery types, patch ops, scopes) |
|
||||
| `@maks-it.com/webui-core` | Utilities (`deepDelta`, enum helpers, ACL parsers) and `useFormState` |
|
||||
| `@maks-it.com/webui-components` | React components, layout, editors, DataTable, auth shell |
|
||||
|
||||
Source lives under `src/` (npm workspaces). Release automation lives under `utils/` (from [maksit-repoutils](https://github.com/MAKS-IT-COM/maksit-repoutils)).
|
||||
|
||||
@ -18,8 +20,11 @@ Source lives under `src/` (npm workspaces). Release automation lives under `util
|
||||
cd src
|
||||
npm install
|
||||
npm run build
|
||||
npm test
|
||||
```
|
||||
|
||||
Tests and coverage badges: **`utils/Run-Tests/Run-Tests.bat`** (plugin config in `utils/Run-Tests/scriptsettings.json`; uses `NpmJestTest`).
|
||||
|
||||
## Release to npmjs
|
||||
|
||||
1. Set **`NPMJS_MAKS_IT`** to your npm automation token (same pattern as `NUGET_MAKS_IT` for NuGet).
|
||||
@ -41,7 +46,7 @@ Refresh shared utils from repoutils: **`utils/Update-RepoUtils/Update-RepoUtils.
|
||||
## Consume in product repos
|
||||
|
||||
```bash
|
||||
npm install @maksit/webui-contracts @maksit/webui-core @maksit/webui-components
|
||||
npm install @maks-it.com/webui-contracts @maks-it.com/webui-core @maks-it.com/webui-components
|
||||
```
|
||||
|
||||
Wrap the app with `WebUiProvider` and pass axios/redux adapters — see [assets/docs/NPM_CONSUMPTION.md](assets/docs/NPM_CONSUMPTION.md).
|
||||
|
||||
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:
|
||||
|
||||
```bash
|
||||
npm install @maksit/webui-contracts @maksit/webui-core @maksit/webui-components
|
||||
npm install @maks-it.com/webui-contracts @maks-it.com/webui-core @maks-it.com/webui-components
|
||||
```
|
||||
|
||||
Wrap the app:
|
||||
|
||||
```tsx
|
||||
import { WebUiProvider, Loader, Authorization } from '@maksit/webui-components'
|
||||
import { WebUiProvider, Loader, Authorization } from '@maks-it.com/webui-components'
|
||||
|
||||
<WebUiProvider
|
||||
api={{
|
||||
@ -43,5 +43,5 @@ import { WebUiProvider, Loader, Authorization } from '@maksit/webui-components'
|
||||
|
||||
- `RemoteSelectBoxComponent`: use `searchRoute` (absolute API path string) instead of `apiRoute: ApiRoutes`.
|
||||
- `SecretComponent`: pass `generateSecretRoute` when `enableGenerate` is true.
|
||||
- ACL: generic `parseAclEntry` / `parseAclEntries` from `@maksit/webui-core`; per-app entity maps and `parse*AclEntries` live in each WebUI project (`models/acl.ts`).
|
||||
- Identity request types and Zod schemas (`LoginRequest` + `LoginRequestSchema`, `LogoutRequest` + `LogoutRequestSchema`, `RefreshTokenRequest` + `RefreshTokenRequestSchema`) live in `@maksit/webui-contracts`.
|
||||
- ACL: generic `parseAclEntry` / `parseAclEntries` from `@maks-it.com/webui-core`; per-app entity maps and `parse*AclEntries` live in each WebUI project (`models/acl.ts`).
|
||||
- Identity request types and Zod schemas (`LoginRequest` + `LoginRequestSchema`, `LogoutRequest` + `LogoutRequestSchema`, `RefreshTokenRequest` + `RefreshTokenRequestSchema`) live in `@maks-it.com/webui-contracts`.
|
||||
|
||||
@ -1,20 +1,20 @@
|
||||
# Publishing `@maksit/webui-*` to npm
|
||||
# Publishing `@maks-it.com/webui-*` to npm
|
||||
|
||||
Packages are published under the **`@maksit` scope** to [registry.npmjs.org](https://registry.npmjs.org), managed from the [maks-it.com npm org](https://www.npmjs.com/settings/maks-it.com/packages).
|
||||
Packages are published under the **`@maks-it.com` scope** to [registry.npmjs.org](https://registry.npmjs.org), managed from the [maks-it.com npm org](https://www.npmjs.com/settings/maks-it.com/packages).
|
||||
|
||||
Published packages:
|
||||
|
||||
| Package | npm |
|
||||
|---------|-----|
|
||||
| `@maksit/webui-contracts` | https://www.npmjs.com/package/@maksit/webui-contracts |
|
||||
| `@maksit/webui-core` | https://www.npmjs.com/package/@maksit/webui-core |
|
||||
| `@maksit/webui-components` | https://www.npmjs.com/package/@maksit/webui-components |
|
||||
| `@maks-it.com/webui-contracts` | https://www.npmjs.com/package/@maks-it.com/webui-contracts |
|
||||
| `@maks-it.com/webui-core` | https://www.npmjs.com/package/@maks-it.com/webui-core |
|
||||
| `@maks-it.com/webui-components` | https://www.npmjs.com/package/@maks-it.com/webui-components |
|
||||
|
||||
## One-time npm setup
|
||||
|
||||
1. Sign in at https://www.npmjs.com/ with the **maks-it.com** org account.
|
||||
2. Confirm the **`@maksit` scope** exists under [Packages](https://www.npmjs.com/settings/maks-it.com/packages). Create the org/scope on npm if this is the first `@maksit/*` publish.
|
||||
3. Create an **Automation** token (recommended) or Granular Access token with **Publish** on `@maksit/*`:
|
||||
2. Confirm the **`@maks-it.com` scope** exists under [Packages](https://www.npmjs.com/settings/maks-it.com/packages). The org name `maks-it.com` is the npm scope for scoped packages.
|
||||
3. Create an **Automation** token (recommended) or Granular Access token with **Publish** on `@maks-it.com/*`:
|
||||
- https://www.npmjs.com/settings/maks-it.com/tokens
|
||||
4. Store the token for release tooling:
|
||||
- **CI / Release-Package:** set env var `NPMJS_MAKS_IT` to the token value (same pattern as `NUGET_MAKS_IT`).
|
||||
@ -33,45 +33,39 @@ From the repo root:
|
||||
cd src
|
||||
npm ci
|
||||
npm run build
|
||||
npm publish -w @maksit/webui-contracts --access public
|
||||
npm publish -w @maksit/webui-core --access public
|
||||
npm publish -w @maksit/webui-components --access public
|
||||
npm publish -w @maks-it.com/webui-contracts --access public
|
||||
npm publish -w @maks-it.com/webui-core --access public
|
||||
npm publish -w @maks-it.com/webui-components --access public
|
||||
```
|
||||
|
||||
Order matters: **contracts → core → components**.
|
||||
|
||||
Or use the helper script:
|
||||
|
||||
```powershell
|
||||
.\scripts\publish-npm.ps1
|
||||
```
|
||||
|
||||
Verify:
|
||||
|
||||
```powershell
|
||||
npm view @maksit/webui-contracts version
|
||||
npm view @maksit/webui-core version
|
||||
npm view @maksit/webui-components version
|
||||
npm view @maks-it.com/webui-contracts version
|
||||
npm view @maks-it.com/webui-core version
|
||||
npm view @maks-it.com/webui-components version
|
||||
```
|
||||
|
||||
## Release pipeline (recommended)
|
||||
|
||||
From `utils/Release-Package/`:
|
||||
Use **`utils/Release-Package/Release-Package.bat`** (or `pwsh utils/Release-Package/Release-Package.ps1`):
|
||||
|
||||
1. Bump version in `src/package.json` (or tag drives `NpmReleaseVersion`).
|
||||
2. Tag `HEAD` with exact semver, e.g. `git tag v0.1.0 && git push origin v0.1.0`.
|
||||
3. Set `NPMJS_MAKS_IT` and run `Release-Package.ps1`.
|
||||
2. Tag `HEAD` with exact semver, e.g. `git tag v0.2.0 && git push origin v0.2.0`.
|
||||
3. Set `NPMJS_MAKS_IT` and run the release engine.
|
||||
|
||||
`scriptsettings.json` runs `NpmBuild` then `NpmPublish` in dependency order.
|
||||
`utils/Release-Package/scriptsettings.json` runs `NpmReleaseVersion`, `NpmBuild`, `ReleasePublishGuard`, optional `GitHub`, then `NpmPublish` in dependency order.
|
||||
|
||||
## After publish — Certs UI / Vault
|
||||
|
||||
In each WebUI app (`MaksIT.WebUI/package.json`):
|
||||
|
||||
```json
|
||||
"@maksit/webui-contracts": "^0.1.0",
|
||||
"@maksit/webui-core": "^0.1.0",
|
||||
"@maksit/webui-components": "^0.1.0"
|
||||
"@maks-it.com/webui-contracts": "^0.1.0",
|
||||
"@maks-it.com/webui-core": "^0.1.0",
|
||||
"@maks-it.com/webui-components": "^0.1.0"
|
||||
```
|
||||
|
||||
Then refresh the lockfile:
|
||||
|
||||
@ -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",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"description": "Shared React UI library for MaksIT Certs UI and Vault WebUI",
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
@ -10,13 +10,22 @@
|
||||
"node": ">=20"
|
||||
},
|
||||
"overrides": {
|
||||
"zod": "^4.3.6"
|
||||
"zod": "^4.4.3",
|
||||
"glob": "^13.0.6",
|
||||
"test-exclude": "^8.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "npm run build -w @maksit/webui-contracts && npm run build -w @maksit/webui-core && npm run build -w @maksit/webui-components",
|
||||
"build": "npm run build -w @maks-it.com/webui-contracts && npm run build -w @maks-it.com/webui-core && npm run build -w @maks-it.com/webui-components",
|
||||
"test": "jest --config jest.config.cjs",
|
||||
"test:coverage": "jest --config jest.config.cjs --coverage",
|
||||
"typecheck": "npm run typecheck --workspaces --if-present",
|
||||
"clean": "npm run clean --workspaces --if-present"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jest": "^30.0.0",
|
||||
"jest": "^30.4.2",
|
||||
"ts-jest": "^29.4.11"
|
||||
},
|
||||
"author": "MaksIT",
|
||||
"license": "MIT"
|
||||
}
|
||||
|
||||
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",
|
||||
"version": "0.1.0",
|
||||
"name": "@maks-it.com/webui-components",
|
||||
"version": "0.2.0",
|
||||
"description": "Shared React components for MaksIT WebUI apps",
|
||||
"type": "module",
|
||||
"main": "./dist/index.cjs",
|
||||
@ -18,7 +18,7 @@
|
||||
"README.md"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsup src/index.ts --format esm,cjs --dts --clean --external react --external react-dom --external react-router-dom --external lucide-react --external @tanstack/react-table --external react-virtualized",
|
||||
"build": "tsup src/index.ts --format esm,cjs --dts --clean --tsconfig tsconfig.build.json --external react --external react-dom --external react-router-dom --external lucide-react --external @tanstack/react-table --external react-virtualized",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||
"clean": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\"",
|
||||
"prepublishOnly": "npm run build"
|
||||
@ -33,34 +33,33 @@
|
||||
"directory": "src/packages/components"
|
||||
},
|
||||
"dependencies": {
|
||||
"@maksit/webui-contracts": "^0.1.0",
|
||||
"@maksit/webui-core": "^0.1.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"lodash": "^4.17.23",
|
||||
"uuid": "^13.0.0"
|
||||
"@maks-it.com/webui-contracts": "^0.2.0",
|
||||
"@maks-it.com/webui-core": "^0.2.0",
|
||||
"date-fns": "^4.3.0",
|
||||
"lodash": "^4.18.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tanstack/react-table": "^8.0.0",
|
||||
"lucide-react": "^0.500.0",
|
||||
"lucide-react": "^1.0.0",
|
||||
"react": "^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^18.0.0 || ^19.0.0",
|
||||
"react-router-dom": "^7.0.0",
|
||||
"react-virtualized": "^9.22.0",
|
||||
"zod": "^4.0.0"
|
||||
"zod": "^4.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@types/lodash": "^4.17.24",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react": "^19.2.15",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/react-virtualized": "^9.22.3",
|
||||
"lucide-react": "^0.576.0",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-router-dom": "^7.13.1",
|
||||
"lucide-react": "^1.16.0",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"react-router-dom": "^7.15.1",
|
||||
"react-virtualized": "^9.22.6",
|
||||
"tsup": "^8.5.0",
|
||||
"typescript": "^5.9.3",
|
||||
"zod": "^4.3.6"
|
||||
"tsup": "^8.5.1",
|
||||
"typescript": "^6.0.3",
|
||||
"zod": "^4.4.3"
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
import React, { useState, useMemo, useRef, useEffect } from 'react'
|
||||
import { AutoSizer, MultiGrid, GridCellProps } from 'react-virtualized'
|
||||
|
||||
import { mapPagedToDataTable, type DataTablePageView, type PagedResponse } from '@maksit/webui-core'
|
||||
import { mapPagedToDataTable, type DataTablePageView, type PagedResponse } from '@maks-it.com/webui-core'
|
||||
import { Plus, Trash2, Edit } from 'lucide-react'
|
||||
import { debounce } from 'lodash'
|
||||
import debounce from 'lodash/debounce'
|
||||
|
||||
|
||||
interface FilterProps {
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { debounce } from 'lodash'
|
||||
import debounce from 'lodash/debounce'
|
||||
|
||||
interface FilterPropsBase {
|
||||
filterId?: string
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { formatISODateString } from '@maksit/webui-core'
|
||||
import { formatISODateString } from '@maks-it.com/webui-core'
|
||||
|
||||
interface NormalLabelProps {
|
||||
type: 'normal'
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import type { SearchEntityScopeEntry } from '@maksit/webui-contracts'
|
||||
import type { SearchEntityScopeEntry } from '@maks-it.com/webui-contracts'
|
||||
|
||||
export interface EntityScopesSummaryProps<TScopeEntityType extends number = number> {
|
||||
entries: SearchEntityScopeEntry<TScopeEntityType>[]
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
import { useState, useEffect, FC } from 'react'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
|
||||
// Define types for a toast
|
||||
interface Toast {
|
||||
@ -9,6 +8,8 @@ interface Toast {
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
const createToastId = (): string => crypto.randomUUID()
|
||||
|
||||
const Toast: FC = () => {
|
||||
const [toasts, setToasts] = useState<Toast[]>([])
|
||||
|
||||
@ -17,7 +18,7 @@ const Toast: FC = () => {
|
||||
const { message, type, duration } = event.detail
|
||||
|
||||
// Add the new toast, avoiding duplicates with same message & type
|
||||
const id = uuidv4()
|
||||
const id = createToastId()
|
||||
setToasts(prev => {
|
||||
const hasDuplicate = prev.some(t => t.message === message && t.type === type)
|
||||
if (hasDuplicate) return prev
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import React, { useRef, useState } from 'react'
|
||||
import { ButtonComponent } from './ButtonComponent'
|
||||
import { TrashIcon } from 'lucide-react'
|
||||
import { Trash2 } from 'lucide-react'
|
||||
|
||||
interface FileUploadComponentProps {
|
||||
label?: string
|
||||
@ -163,7 +163,7 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
||||
disabled={disabled || displayFiles.length === 0}
|
||||
colspan={1}
|
||||
>
|
||||
<TrashIcon />
|
||||
<Trash2 />
|
||||
</ButtonComponent>
|
||||
|
||||
{/* Select files button */}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { useState, useCallback, ChangeEvent, useEffect, useRef } from 'react'
|
||||
import type { PagedRequest } from '@maksit/webui-contracts'
|
||||
import type { SearchResponseBase } from '@maksit/webui-contracts'
|
||||
import { deepEqual } from '@maksit/webui-core'
|
||||
import type { PagedRequest } from '@maks-it.com/webui-contracts'
|
||||
import type { SearchResponseBase } from '@maks-it.com/webui-contracts'
|
||||
import { deepEqual } from '@maks-it.com/webui-core'
|
||||
import { SelectBoxComponent } from './SelectBoxComponent'
|
||||
|
||||
export type RemoteSelectSearchDataSource<TRequest extends PagedRequest> = (
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { debounce } from 'lodash'
|
||||
import debounce from 'lodash/debounce'
|
||||
import { CircleX } from 'lucide-react'
|
||||
import { ChangeEvent, FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { FieldContainer } from './FieldContainer'
|
||||
|
||||
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",
|
||||
"version": "0.1.0",
|
||||
"name": "@maks-it.com/webui-contracts",
|
||||
"version": "0.2.0",
|
||||
"description": "Shared TypeScript contracts for MaksIT WebUI apps",
|
||||
"type": "module",
|
||||
"main": "./dist/index.cjs",
|
||||
@ -18,7 +18,8 @@
|
||||
"README.md"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsup src/index.ts --format esm,cjs --dts --clean",
|
||||
"build": "tsup src/index.ts --format esm,cjs --dts --clean --tsconfig tsconfig.build.json",
|
||||
"test": "jest --config ../../jest.config.cjs --testPathPatterns packages/contracts",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||
"clean": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\"",
|
||||
"prepublishOnly": "npm run build"
|
||||
@ -33,11 +34,11 @@
|
||||
"directory": "src/packages/contracts"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"zod": "^4.0.0"
|
||||
"zod": "^4.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"tsup": "^8.5.0",
|
||||
"typescript": "^5.9.3",
|
||||
"zod": "^4.3.6"
|
||||
"tsup": "^8.5.1",
|
||||
"typescript": "^6.0.3",
|
||||
"zod": "^4.4.3"
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { boolean, number, object, record, string, type ZodType } from 'zod'
|
||||
import { boolean, intersection, number, object, record, string, type ZodType } from 'zod'
|
||||
import type { RequestModelBase } from './RequestModelBase'
|
||||
import { RequestModelBaseSchema } from './RequestModelBase'
|
||||
|
||||
@ -11,7 +11,8 @@ export interface PagedRequest extends RequestModelBase {
|
||||
isAscending?: boolean
|
||||
}
|
||||
|
||||
export const PagedRequestSchema: ZodType<PagedRequest> = RequestModelBaseSchema.and(
|
||||
export const PagedRequestSchema: ZodType<PagedRequest> = intersection(
|
||||
RequestModelBaseSchema,
|
||||
object({
|
||||
pageSize: number().optional(),
|
||||
pageNumber: number().optional(),
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import z, { object, record, string, type ZodType } from 'zod'
|
||||
import z, { intersection, object, record, string, type ZodType } from 'zod'
|
||||
import { PatchOperation } from './PatchOperation'
|
||||
import { RequestModelBase, RequestModelBaseSchema } from './RequestModelBase'
|
||||
|
||||
@ -8,7 +8,8 @@ export interface PatchRequestModelBase extends RequestModelBase {
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export const PatchRequestModelBaseSchema: ZodType<PatchRequestModelBase> = RequestModelBaseSchema.and(
|
||||
export const PatchRequestModelBaseSchema: ZodType<PatchRequestModelBase> = intersection(
|
||||
RequestModelBaseSchema,
|
||||
object({
|
||||
operations: record(string(), z.enum(PatchOperation)).optional(),
|
||||
})
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { object, RefinementCtx, string, ZodIssueCode, type ZodType } from 'zod'
|
||||
import { object, RefinementCtx, string, type ZodType } from 'zod'
|
||||
|
||||
export interface LoginRequest {
|
||||
username: string
|
||||
@ -10,7 +10,7 @@ export interface LoginRequest {
|
||||
const loginRequestSchemaRefine = (data: LoginRequest, ctx: RefinementCtx) => {
|
||||
if (data.username === '') {
|
||||
ctx.addIssue({
|
||||
code: ZodIssueCode.custom,
|
||||
code: 'custom',
|
||||
message: 'Username cannot be empty',
|
||||
path: ['username'],
|
||||
})
|
||||
@ -18,7 +18,7 @@ const loginRequestSchemaRefine = (data: LoginRequest, ctx: RefinementCtx) => {
|
||||
|
||||
if (data.password === '') {
|
||||
ctx.addIssue({
|
||||
code: ZodIssueCode.custom,
|
||||
code: 'custom',
|
||||
message: 'Password cannot be empty',
|
||||
path: ['password'],
|
||||
})
|
||||
@ -26,7 +26,7 @@ const loginRequestSchemaRefine = (data: LoginRequest, ctx: RefinementCtx) => {
|
||||
|
||||
if (data.twoFactorCode && data.twoFactorRecoveryCode) {
|
||||
ctx.addIssue({
|
||||
code: ZodIssueCode.custom,
|
||||
code: 'custom',
|
||||
message: 'Cannot have both twoFactorCode and twoFactorRecoveryCode',
|
||||
path: ['twoFactorCode', 'twoFactorRecoveryCode'],
|
||||
})
|
||||
|
||||
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",
|
||||
"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",
|
||||
"version": "0.1.0",
|
||||
"name": "@maks-it.com/webui-core",
|
||||
"version": "0.2.0",
|
||||
"description": "Shared utilities and hooks for MaksIT WebUI apps",
|
||||
"type": "module",
|
||||
"main": "./dist/index.cjs",
|
||||
@ -18,7 +18,8 @@
|
||||
"README.md"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsup src/index.ts --format esm,cjs --dts --clean",
|
||||
"build": "tsup src/index.ts --format esm,cjs --dts --clean --tsconfig tsconfig.build.json",
|
||||
"test": "jest --config ../../jest.config.cjs",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||
"clean": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\"",
|
||||
"prepublishOnly": "npm run build"
|
||||
@ -33,20 +34,20 @@
|
||||
"directory": "src/packages/core"
|
||||
},
|
||||
"dependencies": {
|
||||
"@maksit/webui-contracts": "^0.1.0",
|
||||
"date-fns": "^4.1.0"
|
||||
"@maks-it.com/webui-contracts": "^0.2.0",
|
||||
"date-fns": "^4.3.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"axios": "^1.7.0",
|
||||
"axios": "^1.16.0",
|
||||
"react": "^18.0.0 || ^19.0.0",
|
||||
"zod": "^4.0.0"
|
||||
"zod": "^4.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.2.14",
|
||||
"axios": "^1.13.2",
|
||||
"react": "^19.2.4",
|
||||
"tsup": "^8.5.0",
|
||||
"typescript": "^5.9.3",
|
||||
"zod": "^4.3.6"
|
||||
"@types/react": "^19.2.15",
|
||||
"axios": "^1.16.1",
|
||||
"react": "^19.2.6",
|
||||
"tsup": "^8.5.1",
|
||||
"typescript": "^6.0.3",
|
||||
"zod": "^4.4.3"
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
|
||||
@ -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 { 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 { ProblemDetails } from '@maksit/webui-contracts'
|
||||
import type { ProblemDetails } from '@maks-it.com/webui-contracts'
|
||||
import { formatProblemDetailsMessage } from './problemDetails'
|
||||
|
||||
/** Shows toast(s) for problem+json and 401 responses. */
|
||||
|
||||
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. */
|
||||
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 json = localStorage.getItem('identity')
|
||||
|
||||
@ -3,5 +3,5 @@ export type {
|
||||
FormValidationResult,
|
||||
FormValidationSchema,
|
||||
} from './FormValidationSchema'
|
||||
export type { PagedResponse } from '@maksit/webui-contracts'
|
||||
export type { PagedResponse } from '@maks-it.com/webui-contracts'
|
||||
export { ScopePermission } from './ScopePermissions'
|
||||
|
||||
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",
|
||||
"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
|
||||
Generates SVG coverage badges for README.
|
||||
Legacy entry point — forwards to the Run-Tests plugin engine.
|
||||
|
||||
.DESCRIPTION
|
||||
This script runs unit tests via TestRunner.psm1, then generates shields.io-style
|
||||
SVG badges for line, branch, and method coverage.
|
||||
|
||||
Configuration is stored in scriptsettings.json:
|
||||
- openReport : Generate and open full HTML report (true/false)
|
||||
- paths.testProjects : Array of relative paths to test projects (preferred)
|
||||
- paths.testProject : Single test project path (legacy; use testProjects)
|
||||
- paths.badgesDir : Relative path to badges output directory
|
||||
- badges : Array of badges to generate (name, label, metric)
|
||||
- colorThresholds : Coverage percentages for badge colors
|
||||
|
||||
Badge colors based on coverage:
|
||||
- brightgreen (>=80%), green (>=60%), yellowgreen (>=40%)
|
||||
- yellow (>=20%), orange (>=10%), red (<10%)
|
||||
If openReport is true, ReportGenerator is required:
|
||||
dotnet tool install -g dotnet-reportgenerator-globaltool
|
||||
|
||||
.EXAMPLE
|
||||
pwsh -File .\Generate-CoverageBadges.ps1
|
||||
Runs tests and generates coverage badges (and optionally HTML report if configured).
|
||||
|
||||
.OUTPUTS
|
||||
SVG badge files in the configured badges directory.
|
||||
|
||||
.NOTES
|
||||
Author: MaksIT
|
||||
Requires: .NET SDK, Coverlet (included in test project)
|
||||
Generate-CoverageBadges.ps1 is kept for backward compatibility.
|
||||
Configure plugins in src/Run-Tests/scriptsettings.json.
|
||||
#>
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
# Get the directory of the current script (for loading settings and relative paths)
|
||||
$ScriptDir = $PSScriptRoot
|
||||
$UtilsDir = Split-Path $ScriptDir -Parent
|
||||
|
||||
#region Import Modules
|
||||
|
||||
# Import TestRunner module (executes tests and collects coverage metrics)
|
||||
$testRunnerModulePath = Join-Path $UtilsDir "TestRunner.psm1"
|
||||
if (-not (Test-Path $testRunnerModulePath)) {
|
||||
Write-Error "TestRunner module not found at: $testRunnerModulePath"
|
||||
exit 1
|
||||
}
|
||||
Import-Module $testRunnerModulePath -Force
|
||||
|
||||
# Import shared ScriptConfig module (settings + command validation helpers)
|
||||
$scriptConfigModulePath = Join-Path $UtilsDir "ScriptConfig.psm1"
|
||||
if (-not (Test-Path $scriptConfigModulePath)) {
|
||||
Write-Error "ScriptConfig module not found at: $scriptConfigModulePath"
|
||||
exit 1
|
||||
}
|
||||
Import-Module $scriptConfigModulePath -Force
|
||||
|
||||
# Import shared Logging module (timestamped/aligned output)
|
||||
$loggingModulePath = Join-Path $UtilsDir "Logging.psm1"
|
||||
if (-not (Test-Path $loggingModulePath)) {
|
||||
Write-Error "Logging module not found at: $loggingModulePath"
|
||||
exit 1
|
||||
}
|
||||
Import-Module $loggingModulePath -Force
|
||||
|
||||
#endregion
|
||||
|
||||
#region Load Settings
|
||||
|
||||
$Settings = Get-ScriptSettings -ScriptDir $ScriptDir
|
||||
|
||||
$thresholds = $Settings.colorThresholds
|
||||
|
||||
#endregion
|
||||
|
||||
#region Configuration
|
||||
|
||||
# Runtime options from settings
|
||||
$OpenReport = if ($null -ne $Settings.openReport) { [bool]$Settings.openReport } else { $false }
|
||||
|
||||
# Resolve configured paths to absolute paths (one or more test projects)
|
||||
$testProjectPaths = [System.Collections.Generic.List[string]]::new()
|
||||
$pathsNode = $Settings.paths
|
||||
if ($pathsNode.PSObject.Properties.Name -contains 'testProjects' -and $pathsNode.testProjects) {
|
||||
foreach ($rel in @($pathsNode.testProjects)) {
|
||||
if ([string]::IsNullOrWhiteSpace([string]$rel)) { continue }
|
||||
$testProjectPaths.Add([System.IO.Path]::GetFullPath((Join-Path $ScriptDir $rel.Trim())))
|
||||
}
|
||||
}
|
||||
if ($testProjectPaths.Count -eq 0 -and $pathsNode.testProject) {
|
||||
$testProjectPaths.Add([System.IO.Path]::GetFullPath((Join-Path $ScriptDir $pathsNode.testProject)))
|
||||
}
|
||||
if ($testProjectPaths.Count -eq 0) {
|
||||
Write-Error "Configure paths.testProjects (array of relative paths) or paths.testProject (single path) in scriptsettings.json."
|
||||
$runTestsScript = Join-Path (Split-Path $PSScriptRoot -Parent) "Run-Tests\Run-Tests.ps1"
|
||||
if (-not (Test-Path $runTestsScript -PathType Leaf)) {
|
||||
Write-Error "Run-Tests engine not found at: $runTestsScript"
|
||||
exit 1
|
||||
}
|
||||
|
||||
$BadgesDir = [System.IO.Path]::GetFullPath((Join-Path $ScriptDir $Settings.paths.badgesDir))
|
||||
|
||||
# Ensure badges directory exists
|
||||
if (-not (Test-Path $BadgesDir)) {
|
||||
New-Item -ItemType Directory -Path $BadgesDir | Out-Null
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helpers
|
||||
|
||||
# Maps a coverage percentage to a shields.io color using configured thresholds.
|
||||
function Get-BadgeColor {
|
||||
param([double]$percentage)
|
||||
|
||||
if ($percentage -ge $thresholds.brightgreen) { return "brightgreen" }
|
||||
if ($percentage -ge $thresholds.green) { return "green" }
|
||||
if ($percentage -ge $thresholds.yellowgreen) { return "yellowgreen" }
|
||||
if ($percentage -ge $thresholds.yellow) { return "yellow" }
|
||||
if ($percentage -ge $thresholds.orange) { return "orange" }
|
||||
return "red"
|
||||
}
|
||||
|
||||
# Builds a shields.io-like SVG badge string for one metric.
|
||||
function New-Badge {
|
||||
param(
|
||||
[string]$label,
|
||||
[string]$value,
|
||||
[string]$color
|
||||
)
|
||||
|
||||
# Calculate widths (approximate character width of 6.5px for the font)
|
||||
$labelWidth = [math]::Max(($label.Length * 6.5) + 10, 50)
|
||||
$valueWidth = [math]::Max(($value.Length * 6.5) + 10, 40)
|
||||
$totalWidth = $labelWidth + $valueWidth
|
||||
$labelX = $labelWidth / 2
|
||||
$valueX = $labelWidth + ($valueWidth / 2)
|
||||
|
||||
$colorMap = @{
|
||||
"brightgreen" = "#4c1"
|
||||
"green" = "#97ca00"
|
||||
"yellowgreen" = "#a4a61d"
|
||||
"yellow" = "#dfb317"
|
||||
"orange" = "#fe7d37"
|
||||
"red" = "#e05d44"
|
||||
}
|
||||
$hexColor = $colorMap[$color]
|
||||
if (-not $hexColor) { $hexColor = "#9f9f9f" }
|
||||
|
||||
return @"
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="$totalWidth" height="20" role="img" aria-label="$label`: $value">
|
||||
<title>$label`: $value</title>
|
||||
<linearGradient id="s" x2="0" y2="100%">
|
||||
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
||||
<stop offset="1" stop-opacity=".1"/>
|
||||
</linearGradient>
|
||||
<clipPath id="r">
|
||||
<rect width="$totalWidth" height="20" rx="3" fill="#fff"/>
|
||||
</clipPath>
|
||||
<g clip-path="url(#r)">
|
||||
<rect width="$labelWidth" height="20" fill="#555"/>
|
||||
<rect x="$labelWidth" width="$valueWidth" height="20" fill="$hexColor"/>
|
||||
<rect width="$totalWidth" height="20" fill="url(#s)"/>
|
||||
</g>
|
||||
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
|
||||
<text aria-hidden="true" x="$labelX" y="15" fill="#010101" fill-opacity=".3">$label</text>
|
||||
<text x="$labelX" y="14" fill="#fff">$label</text>
|
||||
<text aria-hidden="true" x="$valueX" y="15" fill="#010101" fill-opacity=".3">$value</text>
|
||||
<text x="$valueX" y="14" fill="#fff">$value</text>
|
||||
</g>
|
||||
</svg>
|
||||
"@
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Main
|
||||
|
||||
#region Test And Coverage
|
||||
|
||||
$invokeCoverageParams = @{
|
||||
TestProjectPath = @($testProjectPaths)
|
||||
KeepResults = $OpenReport
|
||||
}
|
||||
# Keep single-project results next to that project; for several projects use a shared folder under this script.
|
||||
if ($testProjectPaths.Count -gt 1) {
|
||||
$invokeCoverageParams.ResultsDirectory = Join-Path $ScriptDir "TestResults"
|
||||
}
|
||||
$coverage = Invoke-TestsWithCoverage @invokeCoverageParams
|
||||
if (-not $coverage.Success) {
|
||||
Write-Error "Tests failed: $($coverage.Error)"
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Log -Level "OK" -Message "Tests passed!"
|
||||
|
||||
$metrics = @{
|
||||
"line" = $coverage.LineRate
|
||||
"branch" = $coverage.BranchRate
|
||||
"method" = $coverage.MethodRate
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Generate Badges
|
||||
|
||||
Write-LogStep -Message "Generating coverage badges..."
|
||||
|
||||
foreach ($badge in $Settings.badges) {
|
||||
$metricValue = $metrics[$badge.metric]
|
||||
$color = Get-BadgeColor $metricValue
|
||||
$svg = New-Badge -label $badge.label -value "$metricValue%" -color $color
|
||||
$path = Join-Path $BadgesDir $badge.name
|
||||
$svg | Out-File -FilePath $path -Encoding utf8NoBOM
|
||||
Write-Log -Level "OK" -Message "$($badge.name): $($badge.label) = $metricValue%"
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Summary
|
||||
|
||||
Write-Log -Level "INFO" -Message "Coverage Summary:"
|
||||
Write-Log -Level "INFO" -Message "Line Coverage: $($coverage.LineRate)%"
|
||||
Write-Log -Level "INFO" -Message "Branch Coverage: $($coverage.BranchRate)%"
|
||||
Write-Log -Level "INFO" -Message "Method Coverage: $($coverage.MethodRate)% ($($coverage.CoveredMethods) of $($coverage.TotalMethods) methods)"
|
||||
Write-Log -Level "OK" -Message "Badges generated in: $BadgesDir"
|
||||
Write-Log -Level "STEP" -Message "Commit the badges/ folder to update README."
|
||||
|
||||
#endregion
|
||||
|
||||
#region Optional Html Report
|
||||
|
||||
if ($OpenReport -and $coverage.CoverageFile) {
|
||||
Write-LogStep -Message "Generating HTML report..."
|
||||
Assert-Command reportgenerator
|
||||
|
||||
# Cobertura file(s): single path, or semicolon-separated list from merged runs.
|
||||
$firstCobertura = if ($coverage.CoverageFiles -and $coverage.CoverageFiles.Count -gt 0) {
|
||||
$coverage.CoverageFiles[0]
|
||||
}
|
||||
else {
|
||||
($coverage.CoverageFile -split ';')[0].Trim()
|
||||
}
|
||||
$ResultsDir = Split-Path (Split-Path $firstCobertura -Parent) -Parent
|
||||
$ReportDir = Join-Path $ResultsDir "report"
|
||||
|
||||
$reportGenArgs = @(
|
||||
"-reports:$($coverage.CoverageFile)"
|
||||
"-targetdir:$ReportDir"
|
||||
"-reporttypes:Html"
|
||||
)
|
||||
& reportgenerator @reportGenArgs
|
||||
|
||||
$IndexFile = Join-Path $ReportDir "index.html"
|
||||
if (Test-Path $IndexFile) {
|
||||
Start-Process $IndexFile
|
||||
}
|
||||
|
||||
Write-Log -Level "INFO" -Message "TestResults kept for HTML report viewing."
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#endregion
|
||||
& pwsh -NoProfile -ExecutionPolicy Bypass -File $runTestsScript
|
||||
exit $LASTEXITCODE
|
||||
|
||||
@ -1,47 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft-07/schema",
|
||||
"title": "Generate Coverage Badges Script Settings",
|
||||
"description": "Configuration for Generate-CoverageBadges.ps1 script",
|
||||
"openReport": false,
|
||||
"paths": {
|
||||
"testProjects": [
|
||||
"..\\..\\src\\MaksIT.Core.Tests"
|
||||
],
|
||||
"badgesDir": "..\\..\\assets\\badges"
|
||||
},
|
||||
"badges": [
|
||||
{
|
||||
"name": "coverage-lines.svg",
|
||||
"label": "Line Coverage",
|
||||
"metric": "line"
|
||||
},
|
||||
{
|
||||
"name": "coverage-branches.svg",
|
||||
"label": "Branch Coverage",
|
||||
"metric": "branch"
|
||||
},
|
||||
{
|
||||
"name": "coverage-methods.svg",
|
||||
"label": "Method Coverage",
|
||||
"metric": "method"
|
||||
}
|
||||
],
|
||||
"colorThresholds": {
|
||||
"brightgreen": 80,
|
||||
"green": 60,
|
||||
"yellowgreen": 40,
|
||||
"yellow": 20,
|
||||
"orange": 10,
|
||||
"red": 0
|
||||
},
|
||||
"_comments": {
|
||||
"openReport": "If true, generate and open full HTML coverage report (requires reportgenerator tool).",
|
||||
"paths": {
|
||||
"testProjects": "Array of relative paths (from this folder) to test project directories or .csproj files. All are run; badge metrics aggregate Cobertura line/branch/method stats.",
|
||||
"testProject": "Optional legacy single path if testProjects is omitted.",
|
||||
"badgesDir": "Relative path where SVG coverage badges are written."
|
||||
},
|
||||
"badges": "List of output badges. Each entry maps a metric key (line|branch|method) to filename and label.",
|
||||
"colorThresholds": "Coverage percentage thresholds used to pick badge colors."
|
||||
}
|
||||
"description": "Legacy settings file. Use utils/Run-Tests/scriptsettings.json instead.",
|
||||
"_forwardTo": "..\\Run-Tests\\scriptsettings.json"
|
||||
}
|
||||
|
||||
@ -149,13 +149,21 @@ function Invoke-Plugin {
|
||||
throw "Each images[] entry must define 'service' and 'dockerfile'."
|
||||
}
|
||||
|
||||
$service = [string]$img.service
|
||||
$dockerfileRel = [string]$img.dockerfile
|
||||
$dockerfilePath = [System.IO.Path]::GetFullPath((Join-Path $contextPath $dockerfileRel))
|
||||
|
||||
$imgContextPath = $contextPath
|
||||
if ($img.PSObject.Properties.Name -contains 'contextPath' -and -not [string]::IsNullOrWhiteSpace([string]$img.contextPath)) {
|
||||
$imgContextPath = [System.IO.Path]::GetFullPath((Join-Path $scriptDir ([string]$img.contextPath)))
|
||||
if (-not (Test-Path $imgContextPath -PathType Container)) {
|
||||
throw "Docker context directory not found for image '$service': $imgContextPath"
|
||||
}
|
||||
}
|
||||
|
||||
$dockerfilePath = [System.IO.Path]::GetFullPath((Join-Path $imgContextPath $dockerfileRel))
|
||||
if (-not (Test-Path $dockerfilePath -PathType Leaf)) {
|
||||
throw "Dockerfile not found: $dockerfilePath"
|
||||
}
|
||||
|
||||
$service = [string]$img.service
|
||||
$baseName = "$registryUrl/$($pluginSettings.projectName)/$service"
|
||||
|
||||
$versionEnvFiles = @()
|
||||
@ -165,7 +173,7 @@ function Invoke-Plugin {
|
||||
continue
|
||||
}
|
||||
|
||||
$envFilePath = [System.IO.Path]::GetFullPath((Join-Path $contextPath ([string]$relativeEnvFile)))
|
||||
$envFilePath = [System.IO.Path]::GetFullPath((Join-Path $imgContextPath ([string]$relativeEnvFile)))
|
||||
if (-not (Test-Path -LiteralPath $envFilePath -PathType Leaf)) {
|
||||
throw "Configured versionEnvFiles entry not found: $envFilePath"
|
||||
}
|
||||
@ -187,7 +195,7 @@ function Invoke-Plugin {
|
||||
|
||||
$primaryRef = "${baseName}:$($imageTags[0])"
|
||||
Write-Log -Level "STEP" -Message "Building $primaryRef ..."
|
||||
docker build -t $primaryRef -f $dockerfilePath $contextPath
|
||||
docker build -t $primaryRef -f $dockerfilePath $imgContextPath
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Docker build failed for $primaryRef"
|
||||
}
|
||||
|
||||
@ -46,6 +46,32 @@ function Get-GitHubRepositoryInternal {
|
||||
throw "Could not parse GitHub repo from source: $repoSource. Configure plugins[].repository with 'owner/repo' or a GitHub URL."
|
||||
}
|
||||
|
||||
function Get-ChangelogVersionHeaderPatternInternal {
|
||||
# Keep a Changelog: ## [1.0.0] - 2026-05-24, bare ## 1.0.0 - 2026-05-24, or legacy ## v1.0.0
|
||||
return '(?m)^##\s+(?:\[(\d+\.\d+\.\d+)\]|v(\d+\.\d+\.\d+)|(\d+\.\d+\.\d+)(?:\s*-\s*\d{4}-\d{2}-\d{2})?\s*$)'
|
||||
}
|
||||
|
||||
function Get-LatestChangelogVersionInternal {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$ReleaseNotesContent
|
||||
)
|
||||
|
||||
$match = [regex]::Match($ReleaseNotesContent, (Get-ChangelogVersionHeaderPatternInternal))
|
||||
if (-not $match.Success) {
|
||||
return $null
|
||||
}
|
||||
|
||||
foreach ($groupIndex in 1..3) {
|
||||
$version = $match.Groups[$groupIndex].Value
|
||||
if (-not [string]::IsNullOrEmpty($version)) {
|
||||
return $version
|
||||
}
|
||||
}
|
||||
|
||||
return $null
|
||||
}
|
||||
|
||||
function Get-ReleaseNotesInternal {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
@ -61,11 +87,11 @@ function Get-ReleaseNotesInternal {
|
||||
}
|
||||
|
||||
$releaseNotesContent = Get-Content $ReleaseNotesFile -Raw
|
||||
if ($releaseNotesContent -notmatch '##\s+v(\d+\.\d+\.\d+)') {
|
||||
$releaseNotesVersion = Get-LatestChangelogVersionInternal -ReleaseNotesContent $releaseNotesContent
|
||||
if ([string]::IsNullOrWhiteSpace($releaseNotesVersion)) {
|
||||
throw "No version entry found in the configured release notes source."
|
||||
}
|
||||
|
||||
$releaseNotesVersion = $Matches[1]
|
||||
if ($releaseNotesVersion -ne $Version) {
|
||||
throw "Project version ($Version) does not match the latest release notes version ($releaseNotesVersion)."
|
||||
}
|
||||
@ -73,7 +99,9 @@ function Get-ReleaseNotesInternal {
|
||||
Write-Log -Level "OK" -Message " Release notes version matches: v$releaseNotesVersion"
|
||||
|
||||
Write-Log -Level "STEP" -Message "Extracting release notes..."
|
||||
$pattern = "(?ms)^##\s+v$([regex]::Escape($Version))\b.*?(?=^##\s+v\d+\.\d+\.\d+|\Z)"
|
||||
$escapedVersion = [regex]::Escape($Version)
|
||||
$nextHeaderPattern = '(?m)^##\s+(?:\[\d+\.\d+\.\d+\]|v\d+\.\d+\.\d+|\d+\.\d+\.\d+(?:\s*-\s*\d{4}-\d{2}-\d{2})?\s*$)'
|
||||
$pattern = "(?ms)^##\s+(?:\[$escapedVersion\]|v$escapedVersion|$escapedVersion(?:\s*-\s*\d{4}-\d{2}-\d{2})?).*?(?=$nextHeaderPattern|\Z)"
|
||||
$match = [regex]::Match($releaseNotesContent, $pattern)
|
||||
|
||||
if (-not $match.Success) {
|
||||
@ -133,8 +161,17 @@ function Invoke-Plugin {
|
||||
}
|
||||
}
|
||||
|
||||
$requireReleaseAssets = $true
|
||||
if ($null -ne $pluginSettings.requireReleaseAssets) {
|
||||
$requireReleaseAssets = [bool]$pluginSettings.requireReleaseAssets
|
||||
}
|
||||
|
||||
if ($releaseAssetPaths.Count -eq 0 -and $requireReleaseAssets) {
|
||||
throw "GitHub release requires at least one prepared release asset (set requireReleaseAssets: false for notes-only npm releases)."
|
||||
}
|
||||
|
||||
if ($releaseAssetPaths.Count -eq 0) {
|
||||
throw "GitHub release requires at least one prepared release asset."
|
||||
Write-Log -Level "INFO" -Message " Notes-only GitHub release (requireReleaseAssets: false)."
|
||||
}
|
||||
|
||||
$repo = Get-GitHubRepositoryInternal -ConfiguredRepository $configuredRepository
|
||||
|
||||
@ -35,7 +35,8 @@ function Invoke-Plugin {
|
||||
|
||||
$workspaceRoot = $null
|
||||
if ($pluginSettings.workspaceRoot) {
|
||||
$workspaceRoot = (Resolve-RelativePaths -Value $pluginSettings.workspaceRoot -BasePath $shared.scriptDir)[0]
|
||||
$workspaceRoots = @(Resolve-RelativePaths -Value $pluginSettings.workspaceRoot -BasePath $shared.scriptDir)
|
||||
$workspaceRoot = $workspaceRoots[0]
|
||||
}
|
||||
elseif ($shared.PSObject.Properties['npmWorkspaceRoot'] -and -not [string]::IsNullOrWhiteSpace([string]$shared.npmWorkspaceRoot)) {
|
||||
$workspaceRoot = [string]$shared.npmWorkspaceRoot
|
||||
|
||||
@ -45,7 +45,8 @@ function Invoke-Plugin {
|
||||
|
||||
$workspaceRoot = $null
|
||||
if ($pluginSettings.workspaceRoot) {
|
||||
$workspaceRoot = (Resolve-RelativePaths -Value $pluginSettings.workspaceRoot -BasePath $shared.scriptDir)[0]
|
||||
$workspaceRoots = @(Resolve-RelativePaths -Value $pluginSettings.workspaceRoot -BasePath $shared.scriptDir)
|
||||
$workspaceRoot = $workspaceRoots[0]
|
||||
}
|
||||
elseif ($shared.PSObject.Properties['npmWorkspaceRoot'] -and -not [string]::IsNullOrWhiteSpace([string]$shared.npmWorkspaceRoot)) {
|
||||
$workspaceRoot = [string]$shared.npmWorkspaceRoot
|
||||
|
||||
@ -68,12 +68,11 @@ function Invoke-Plugin {
|
||||
$pluginSettings = $Settings
|
||||
$shared = $Settings.context
|
||||
|
||||
$packageJsonPath = if ($pluginSettings.packageJsonPath) {
|
||||
(Resolve-RelativePaths -Value $pluginSettings.packageJsonPath -BasePath $shared.scriptDir)[0]
|
||||
}
|
||||
else {
|
||||
$packageJsonPaths = @(Resolve-RelativePaths -Value $pluginSettings.packageJsonPath -BasePath $shared.scriptDir)
|
||||
if ($packageJsonPaths.Count -eq 0) {
|
||||
throw "NpmReleaseVersion plugin requires 'packageJsonPath' in scriptsettings.json."
|
||||
}
|
||||
$packageJsonPath = $packageJsonPaths[0]
|
||||
|
||||
$version = Get-PackageJsonVersionInternal -PackageJsonPath $packageJsonPath
|
||||
$syncWorkspaceVersions = $false
|
||||
|
||||
@ -22,7 +22,7 @@ if (-not (Get-Command Get-PluginStageLabel -ErrorAction SilentlyContinue) -or -n
|
||||
}
|
||||
}
|
||||
|
||||
if (-not (Get-Command Resolve-DotNetReleaseVersion -ErrorAction SilentlyContinue)) {
|
||||
if (-not (Get-Command Resolve-ReleaseVersion -ErrorAction SilentlyContinue)) {
|
||||
$releaseContextModulePath = Join-Path $PSScriptRoot "ReleaseContext.psm1"
|
||||
if (Test-Path $releaseContextModulePath -PathType Leaf) {
|
||||
Import-Module $releaseContextModulePath -Force
|
||||
@ -79,7 +79,9 @@ function New-EngineContext {
|
||||
[psobject]$Settings
|
||||
)
|
||||
|
||||
$version = (Resolve-DotNetReleaseVersion -Plugins $Plugins -ScriptDir $ScriptDir).version
|
||||
$resolvedVersion = Resolve-ReleaseVersion -Plugins $Plugins -ScriptDir $ScriptDir
|
||||
$version = $resolvedVersion.version
|
||||
$versionSource = $resolvedVersion.source
|
||||
$artifactsDirectory = [System.IO.Path]::GetFullPath((Join-Path $ScriptDir '..\\..\\release'))
|
||||
|
||||
$currentBranch = Get-CurrentBranch
|
||||
@ -113,7 +115,7 @@ function New-EngineContext {
|
||||
Assert-WorkingTreeClean
|
||||
|
||||
$tag = "v$version"
|
||||
Write-Log -Level "INFO" -Message " Release tag default from DotNetReleaseVersion: $tag (ReleasePublishGuard may replace from git when publish is allowed)."
|
||||
Write-Log -Level "INFO" -Message " Release tag default from ${versionSource}: $tag (ReleasePublishGuard may replace from git when publish is allowed)."
|
||||
|
||||
return [pscustomobject]@{
|
||||
scriptDir = $ScriptDir
|
||||
|
||||
@ -10,14 +10,14 @@ Canonical source: this folder in **maksit-repoutils**. Product repositories refr
|
||||
|------|------|
|
||||
| `Release-Package.ps1` | Loads settings, builds `New-EngineContext`, runs plugins in order. |
|
||||
| `PluginSupport.psm1` | Plugin discovery, `Invoke-ConfiguredPlugin`; publish plugins honor `skipPublishPlugins` from `ReleasePublishGuard` (no per-plugin `branches` for GitHub/NuGet/Docker/Helm). |
|
||||
| `ReleaseContext.psm1` | Resolves semver via `Resolve-DotNetReleaseVersion` from the `DotNetReleaseVersion` plugin `projectFiles` (first `.csproj` `<Version>`). |
|
||||
| `EngineSupport.psm1` | Warn-only dirty-tree preflight; default `context.tag` = `v{version}` from DotNetReleaseVersion; `Initialize-ReleaseStageContext` sets `releaseDir` only. |
|
||||
| `ReleaseContext.psm1` | Resolves semver via `Resolve-ReleaseVersion` from `DotNetReleaseVersion.projectFiles` (first `.csproj` `<Version>`) or `NpmReleaseVersion.packageJsonPath`. |
|
||||
| `EngineSupport.psm1` | Warn-only dirty-tree preflight; default `context.tag` = `v{version}` from the configured version plugin; `Initialize-ReleaseStageContext` sets `releaseDir` only. |
|
||||
|
||||
## Plugins
|
||||
|
||||
`CorePlugins/` — e.g. `DotNetReleaseVersion`, `NpmReleaseVersion`, `NpmBuild`, `NpmPublish`, `DockerPush`, `HelmPush`, `ReleasePublishGuard`. Optional `CustomPlugins/`.
|
||||
|
||||
`DotNetPack` and `QualityGate` (when used) can declare their own `projectFiles`; semver still comes only from `DotNetReleaseVersion.projectFiles`.
|
||||
`DotNetPack` and `QualityGate` (when used) can declare their own `projectFiles`; semver still comes from the configured version plugin (`DotNetReleaseVersion` or `NpmReleaseVersion`).
|
||||
|
||||
## `ReleasePublishGuard`
|
||||
|
||||
@ -38,7 +38,7 @@ For TypeScript monorepos published to npmjs:
|
||||
|
||||
## Helm charts in git
|
||||
|
||||
Commit `Chart.yaml` with placeholder `version` and `appVersion` (for example `0.0.0`) so `helm lint` stays valid. `HelmPush` temporarily replaces both with the **bare** release semver from `context.version` (`DotNetReleaseVersion`, e.g. `3.3.4` without a `v` prefix) before packaging and OCI push; if `version` were missing, it would fall back to stripping `v`/`V` from `context.tag`. Then it restores `Chart.yaml`. `DockerPush` tags images with the **bare** semver from `context.version` (e.g. `3.3.4`), also pushes `vX.Y.Z` and `shared.tag` when they differ, and optional `latest` — not from `Chart.yaml`; optionally use per-image `versionEnvFiles` to temporarily set `VITE_APP_VERSION={shared.version}` in frontend `.env` files during docker build, then restore originals.
|
||||
Commit `Chart.yaml` with placeholder `version` and `appVersion` (for example `0.0.0`) so `helm lint` stays valid. `HelmPush` temporarily replaces both with the **bare** release semver from `context.version` (`DotNetReleaseVersion`, e.g. `3.3.4` without a `v` prefix) before packaging and OCI push; if `version` were missing, it would fall back to stripping `v`/`V` from `context.tag`. Then it restores `Chart.yaml`. `DockerPush` tags images with the **bare** semver from `context.version` (e.g. `3.3.4`), also pushes `vX.Y.Z` and `shared.tag` when they differ, and optional `latest` — not from `Chart.yaml`; optionally use per-image `versionEnvFiles` to temporarily set `VITE_APP_VERSION={shared.version}` in frontend `.env` files during docker build, then restore originals. Each image may override the plugin `contextPath` with its own `contextPath` (paths relative to Release-Package); `dockerfile` and `versionEnvFiles` resolve against that per-image context.
|
||||
|
||||
Sample chart: repository `charts/my-service/` (matches the sample `chartPath` in `scriptsettings.json`). Product repos often use `src/helm/` instead.
|
||||
|
||||
|
||||
@ -6,9 +6,9 @@
|
||||
Helpers to resolve release semver from plugin configuration.
|
||||
|
||||
.DESCRIPTION
|
||||
Used by New-EngineContext and the DotNetReleaseVersion plugin:
|
||||
- Source: DotNetReleaseVersion plugin -> projectFiles
|
||||
- Version from first path in projectFiles (SDK-style .csproj <Version>)
|
||||
Used by New-EngineContext and version plugins:
|
||||
- DotNetReleaseVersion plugin -> projectFiles (.csproj <Version>)
|
||||
- NpmReleaseVersion plugin -> packageJsonPath (package.json version)
|
||||
#>
|
||||
|
||||
if (-not (Get-Command Write-Log -ErrorAction SilentlyContinue)) {
|
||||
@ -136,10 +136,90 @@ function Resolve-DotNetReleaseVersion {
|
||||
|
||||
return [pscustomobject]@{
|
||||
version = $version
|
||||
source = 'DotNetReleaseVersion'
|
||||
}
|
||||
}
|
||||
|
||||
Export-ModuleMember -Function Get-CsprojPropertyValue, Get-CsprojVersions, Resolve-RelativePaths, Resolve-DotNetReleaseVersion
|
||||
function Resolve-NpmReleaseVersion {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[object[]]$Plugins,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$ScriptDir
|
||||
)
|
||||
|
||||
$releaseVersionPlugin = @($Plugins | Where-Object { $_.name -eq 'NpmReleaseVersion' } | Select-Object -First 1)
|
||||
if ($releaseVersionPlugin.Count -eq 0 -or $null -eq $releaseVersionPlugin[0]) {
|
||||
Write-Error "Configure an NpmReleaseVersion plugin in scriptsettings.json with packageJsonPath."
|
||||
exit 1
|
||||
}
|
||||
|
||||
$releaseVersionSettings = $releaseVersionPlugin[0]
|
||||
$packageJsonPaths = @(Resolve-RelativePaths -Value $releaseVersionSettings.packageJsonPath -BasePath $ScriptDir)
|
||||
|
||||
if ($packageJsonPaths.Count -eq 0) {
|
||||
Write-Error "Configure release version via NpmReleaseVersion.packageJsonPath."
|
||||
exit 1
|
||||
}
|
||||
|
||||
$packageJsonPath = $packageJsonPaths[0]
|
||||
if (-not (Test-Path $packageJsonPath -PathType Leaf)) {
|
||||
Write-Error "NpmReleaseVersion: package.json not found at: $packageJsonPath"
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Log -Level "INFO" -Message "Reading version from npm package.json (packageJsonPath)..."
|
||||
$json = Get-Content -Path $packageJsonPath -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
$version = [string]$json.version
|
||||
if ([string]::IsNullOrWhiteSpace($version)) {
|
||||
Write-Error "NpmReleaseVersion: 'version' is missing in '$packageJsonPath'."
|
||||
exit 1
|
||||
}
|
||||
|
||||
if ($version -notmatch '^\d+\.\d+\.\d+') {
|
||||
Write-Error "NpmReleaseVersion: version '$version' in '$packageJsonPath' is not a valid semver."
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Log -Level "OK" -Message " $([System.IO.Path]::GetFileName($packageJsonPath)): $version"
|
||||
|
||||
return [pscustomobject]@{
|
||||
version = $version
|
||||
source = 'NpmReleaseVersion'
|
||||
}
|
||||
}
|
||||
|
||||
function Resolve-ReleaseVersion {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[object[]]$Plugins,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$ScriptDir
|
||||
)
|
||||
|
||||
$dotnetPlugin = @($Plugins | Where-Object { $_.name -eq 'DotNetReleaseVersion' -and $_.enabled -ne $false })
|
||||
$npmPlugin = @($Plugins | Where-Object { $_.name -eq 'NpmReleaseVersion' -and $_.enabled -ne $false })
|
||||
|
||||
if ($dotnetPlugin.Count -gt 0 -and $npmPlugin.Count -gt 0) {
|
||||
Write-Error "Configure only one release version plugin: DotNetReleaseVersion or NpmReleaseVersion, not both."
|
||||
exit 1
|
||||
}
|
||||
|
||||
if ($dotnetPlugin.Count -gt 0) {
|
||||
return Resolve-DotNetReleaseVersion -Plugins $Plugins -ScriptDir $ScriptDir
|
||||
}
|
||||
|
||||
if ($npmPlugin.Count -gt 0) {
|
||||
return Resolve-NpmReleaseVersion -Plugins $Plugins -ScriptDir $ScriptDir
|
||||
}
|
||||
|
||||
Write-Error "Configure a DotNetReleaseVersion plugin (projectFiles) or NpmReleaseVersion plugin (packageJsonPath) in scriptsettings.json."
|
||||
exit 1
|
||||
}
|
||||
|
||||
Export-ModuleMember -Function Get-CsprojPropertyValue, Get-CsprojVersions, Resolve-RelativePaths, Resolve-DotNetReleaseVersion, Resolve-NpmReleaseVersion, Resolve-ReleaseVersion
|
||||
|
||||
|
||||
|
||||
|
||||
@ -39,7 +39,8 @@
|
||||
"githubToken": "GITHUB_MAKS_IT_COM",
|
||||
"repository": "https://github.com/MAKS-IT-COM/maksit-webui",
|
||||
"releaseNotesFile": "..\\..\\CHANGELOG.md",
|
||||
"releaseTitlePattern": "Release {version}"
|
||||
"releaseTitlePattern": "Release {version}",
|
||||
"requireReleaseAssets": false
|
||||
},
|
||||
{
|
||||
"name": "NpmPublish",
|
||||
@ -50,9 +51,9 @@
|
||||
"access": "public",
|
||||
"workspaceRoot": "..\\..\\src",
|
||||
"publishOrder": [
|
||||
"@maksit/webui-contracts",
|
||||
"@maksit/webui-core",
|
||||
"@maksit/webui-components"
|
||||
"@maks-it.com/webui-contracts",
|
||||
"@maks-it.com/webui-core",
|
||||
"@maks-it.com/webui-components"
|
||||
]
|
||||
}
|
||||
],
|
||||
|
||||
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",
|
||||
"cloneDepth": 1,
|
||||
"skippedRelativeDirectories": [
|
||||
"Release-Package/CustomPlugins"
|
||||
"Release-Package/CustomPlugins",
|
||||
"Run-Tests/CustomPlugins"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user