@monster-orange/den 0.0.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +204 -0
- package/LICENSE +21 -0
- package/README.md +165 -1
- package/bin/den +68 -0
- package/docs/distributions.md +499 -0
- package/make/demo.mk +82 -0
- package/make/den.mk +80 -0
- package/make/journeys.mk +57 -0
- package/make/local_dev.mk +154 -0
- package/make/security.mk +59 -0
- package/make/test.mk +405 -0
- package/package.json +101 -4
- package/scripts/auth-configure.ts +415 -0
- package/scripts/heal-config.ts +92 -0
- package/scripts/jsonl-playwright-reporter.js +70 -0
- package/scripts/manifest-read.cjs +91 -0
- package/scripts/setup-1password.ts +570 -0
- package/scripts/setup-secrets.ts +149 -0
- package/scripts/view-results.js +156 -0
- package/service/install.sh +112 -0
- package/src/pillars/api/fixtures/index.ts +128 -0
- package/src/pillars/api/fixtures/openapi-validator.ts +111 -0
- package/src/pillars/api/global-setup.ts +103 -0
- package/src/pillars/api/playwright.config.ts +105 -0
- package/src/pillars/api/schemas/example-api.json +110 -0
- package/src/pillars/api/tests/health/health.spec.ts +19 -0
- package/src/pillars/api/tests/health/manifest.yml +6 -0
- package/src/pillars/browser/fixtures/index.ts +232 -0
- package/src/pillars/browser/fixtures/package.json +10 -0
- package/src/pillars/browser/global-setup.ts +140 -0
- package/src/pillars/browser/global-teardown.ts +48 -0
- package/src/pillars/browser/playwright.config.ts +212 -0
- package/src/pillars/browser/setup.ts +219 -0
- package/src/pillars/journeys/app-config.ts +237 -0
- package/src/pillars/journeys/discover.ts +160 -0
- package/src/pillars/journeys/hooks/deny-shell.cjs +44 -0
- package/src/pillars/journeys/observability/events.ts +288 -0
- package/src/pillars/journeys/observability/sdk-mapper.ts +306 -0
- package/src/pillars/journeys/report/friction.ts +304 -0
- package/src/pillars/journeys/report/junit.ts +105 -0
- package/src/pillars/journeys/report/render.ts +273 -0
- package/src/pillars/journeys/report/results.ts +323 -0
- package/src/pillars/journeys/run.ts +673 -0
- package/src/pillars/journeys/runner.ts +1025 -0
- package/src/pillars/journeys/sandbox.ts +403 -0
- package/src/pillars/journeys/spec/loader.ts +152 -0
- package/src/pillars/journeys/spec/schema.ts +208 -0
- package/src/pillars/journeys/teardown.ts +98 -0
- package/src/pillars/journeys/tools/browser-session.ts +219 -0
- package/src/pillars/journeys/tools/browser-tool.ts +914 -0
- package/src/pillars/journeys/tools/http-tool.ts +564 -0
- package/src/pillars/journeys/tools/ledger.ts +278 -0
- package/src/pillars/journeys/tools/snapshot-store.ts +117 -0
- package/src/pillars/journeys/verify/assertions.ts +502 -0
- package/src/pillars/journeys/verify/verdict.ts +71 -0
- package/src/pillars/perf/build-report.ts +36 -0
- package/src/pillars/perf/k6.config.js +112 -0
- package/src/pillars/perf/run.ts +345 -0
- package/src/pillars/perf/scenarios/load-profile.js +97 -0
- package/src/pillars/perf/staging.ts +73 -0
- package/src/pillars/security/discover.ts +124 -0
- package/src/pillars/security/findings.ts +151 -0
- package/src/pillars/security/plan.ts +355 -0
- package/src/pillars/security/run.ts +757 -0
- package/src/pillars/smoke/admin-probe.ts +68 -0
- package/src/pillars/smoke/base-url.ts +56 -0
- package/src/pillars/smoke/cleanup.ts +8 -0
- package/src/pillars/smoke/discover.ts +237 -0
- package/src/pillars/smoke/ensure-smoke-teardown.ts +40 -0
- package/src/pillars/smoke/fixture-manager.ts +217 -0
- package/src/pillars/smoke/fixtures.ts +468 -0
- package/src/pillars/smoke/global-setup-all.ts +262 -0
- package/src/pillars/smoke/global-setup-global.ts +262 -0
- package/src/pillars/smoke/global-setup.ts +273 -0
- package/src/pillars/smoke/global-teardown-all.ts +8 -0
- package/src/pillars/smoke/global-teardown-global.ts +8 -0
- package/src/pillars/smoke/global-teardown.ts +139 -0
- package/src/pillars/smoke/playwright-all.config.ts +147 -0
- package/src/pillars/smoke/playwright-global.config.ts +141 -0
- package/src/pillars/smoke/playwright.config.ts +162 -0
- package/src/pillars/smoke/setup-env.ts +132 -0
- package/src/pillars/smoke/setup-global.ts +126 -0
- package/src/pillars/smoke/setup.ts +115 -0
- package/src/pillars/smoke/smoke-repo.ts +16 -0
- package/src/pillars/smoke/staging.ts +49 -0
- package/src/pillars/suites/run.ts +147 -0
- package/src/scripts/configure-aws-profiles.ts +114 -0
- package/src/scripts/register-repos.ts +307 -0
- package/src/scripts/setup-default-workspace.ts +203 -0
- package/src/shared/agent-actor.ts +77 -0
- package/src/shared/applications.ts +138 -0
- package/src/shared/auth/api-token.ts +416 -0
- package/src/shared/auth/auth0-helper.ts +284 -0
- package/src/shared/auth/headless-login.ts +265 -0
- package/src/shared/auth/jwt-helper.ts +186 -0
- package/src/shared/auth/sudo-vault.ts +69 -0
- package/src/shared/auth/test-accounts.ts +98 -0
- package/src/shared/auth/token-injection.ts +184 -0
- package/src/shared/auth/token-providers/auth0.ts +80 -0
- package/src/shared/auth/token-providers/index.ts +182 -0
- package/src/shared/auth/token-refresh.ts +273 -0
- package/src/shared/banner.ts +15 -0
- package/src/shared/brand.ts +79 -0
- package/src/shared/cli-tabs.ts +131 -0
- package/src/shared/constants.ts +128 -0
- package/src/shared/den-config.ts +688 -0
- package/src/shared/dev-metrics/emit.ts +354 -0
- package/src/shared/edge/config.ts +229 -0
- package/src/shared/edge/render.ts +221 -0
- package/src/shared/edge/runtime.ts +268 -0
- package/src/shared/edge/status.ts +74 -0
- package/src/shared/edge/types.ts +62 -0
- package/src/shared/engine.ts +67 -0
- package/src/shared/environment/aws-profiles.ts +49 -0
- package/src/shared/environment/env-selector.ts +161 -0
- package/src/shared/environment/playwright-env.ts +82 -0
- package/src/shared/environment-manager/catalog.ts +77 -0
- package/src/shared/environment-manager/clone-guard.ts +163 -0
- package/src/shared/environment-manager/dag.ts +286 -0
- package/src/shared/environment-manager/default-workspace.ts +182 -0
- package/src/shared/environment-manager/derivation/alias.ts +90 -0
- package/src/shared/environment-manager/derivation/cache.ts +97 -0
- package/src/shared/environment-manager/derivation/derive-graph.ts +421 -0
- package/src/shared/environment-manager/derivation/derived-up.ts +354 -0
- package/src/shared/environment-manager/derivation/load-alias.ts +11 -0
- package/src/shared/environment-manager/derivation/rules-compose.ts +42 -0
- package/src/shared/environment-manager/derivation/rules-events.ts +50 -0
- package/src/shared/environment-manager/derivation/rules-http.ts +247 -0
- package/src/shared/environment-manager/derivation/rules-infra.ts +64 -0
- package/src/shared/environment-manager/derivation/rules-readiness.ts +96 -0
- package/src/shared/environment-manager/derivation/scan.ts +160 -0
- package/src/shared/environment-manager/derivation/text.ts +40 -0
- package/src/shared/environment-manager/derivation/types.ts +69 -0
- package/src/shared/environment-manager/health.ts +635 -0
- package/src/shared/environment-manager/index.ts +6 -0
- package/src/shared/environment-manager/load-applications.ts +108 -0
- package/src/shared/environment-manager/log-tail.ts +213 -0
- package/src/shared/environment-manager/make-targets.ts +78 -0
- package/src/shared/environment-manager/opctl.ts +335 -0
- package/src/shared/environment-manager/readiness.ts +93 -0
- package/src/shared/environment-manager/register-local.ts +72 -0
- package/src/shared/environment-manager/secrets-check.ts +260 -0
- package/src/shared/environment-manager/service-container.ts +47 -0
- package/src/shared/environment-manager/service-env.ts +110 -0
- package/src/shared/environment-manager/service-runner.ts +1603 -0
- package/src/shared/environment-manager/sync-targets.ts +66 -0
- package/src/shared/environment-manager/up-plan.ts +326 -0
- package/src/shared/environment-manager/workspace-manager.ts +905 -0
- package/src/shared/environment-manager/workspaces.ts +860 -0
- package/src/shared/manifest/deps.ts +27 -0
- package/src/shared/manifest/merge-override.ts +75 -0
- package/src/shared/manifest/migrate-manifest.ts +101 -0
- package/src/shared/manifest/placement.ts +321 -0
- package/src/shared/manifest/read-manifest.ts +278 -0
- package/src/shared/manifest/schema.ts +313 -0
- package/src/shared/manifest/service-links.ts +93 -0
- package/src/shared/manifest/validate-layers.ts +229 -0
- package/src/shared/manifest/validate-manifest.ts +200 -0
- package/src/shared/manifest/workspace-manifests.ts +92 -0
- package/src/shared/open-url.ts +122 -0
- package/src/shared/org.ts +44 -0
- package/src/shared/paths.ts +190 -0
- package/src/shared/perf/optional-fields.ts +105 -0
- package/src/shared/perf/report-normalizer.ts +404 -0
- package/src/shared/perf/report-paths.ts +19 -0
- package/src/shared/perf/report-store.ts +110 -0
- package/src/shared/perf/report-types.ts +70 -0
- package/src/shared/perf/test-paths.ts +25 -0
- package/src/shared/process/log-rotation.ts +147 -0
- package/src/shared/process/spawn-streaming.ts +229 -0
- package/src/shared/product.ts +92 -0
- package/src/shared/profile.ts +141 -0
- package/src/shared/redact.ts +107 -0
- package/src/shared/repo-kind.ts +32 -0
- package/src/shared/reporting/jsonl-reporter.ts +131 -0
- package/src/shared/reporting/slack-reporter.ts +340 -0
- package/src/shared/results/result-reader.ts +266 -0
- package/src/shared/results/result-writer.ts +168 -0
- package/src/shared/secrets/doppler.ts +126 -0
- package/src/shared/secrets/env.ts +48 -0
- package/src/shared/secrets/index.ts +197 -0
- package/src/shared/secrets/keys.ts +33 -0
- package/src/shared/secrets/onepassword.ts +75 -0
- package/src/shared/secrets/types.ts +52 -0
- package/src/shared/service-url.ts +119 -0
- package/src/shared/smoke/branch-resolver.ts +141 -0
- package/src/shared/smoke/git-remote.ts +70 -0
- package/src/shared/smoke/repo-cloner.ts +151 -0
- package/src/shared/stacks/config.ts +137 -0
- package/src/shared/stacks/manifest-commands.ts +118 -0
- package/src/shared/stacks/render.ts +253 -0
- package/src/shared/stacks/runtime.ts +334 -0
- package/src/shared/stacks/status.ts +99 -0
- package/src/shared/stacks/types.ts +53 -0
- package/src/shared/terminal/tabs.ts +212 -0
- package/src/shared/test-discovery/module-scanner.ts +569 -0
- package/src/shared/types/index.ts +701 -0
- package/src/shared/ui-marker.ts +19 -0
- package/src/ui/cli/applications-command.ts +148 -0
- package/src/ui/cli/config-command.ts +68 -0
- package/src/ui/cli/create-main.ts +9 -0
- package/src/ui/cli/create.ts +360 -0
- package/src/ui/cli/edge-command.ts +81 -0
- package/src/ui/cli/flag-parser.ts +246 -0
- package/src/ui/cli/graph-command.ts +570 -0
- package/src/ui/cli/health-display.ts +63 -0
- package/src/ui/cli/index.ts +3742 -0
- package/src/ui/cli/init.ts +855 -0
- package/src/ui/cli/invocation-target.ts +175 -0
- package/src/ui/cli/logs-command.ts +104 -0
- package/src/ui/cli/mcp-main.ts +13 -0
- package/src/ui/cli/mcp-stdout-guard.ts +10 -0
- package/src/ui/cli/module-selection.ts +31 -0
- package/src/ui/cli/prompts.ts +767 -0
- package/src/ui/cli/results-viewer.ts +436 -0
- package/src/ui/cli/secrets-preflight.ts +91 -0
- package/src/ui/cli/stack-command.ts +117 -0
- package/src/ui/cli/stalled-setup-recovery.ts +129 -0
- package/src/ui/cli/test-runner.ts +1319 -0
- package/src/ui/cli/web-command.ts +1073 -0
- package/src/ui/mcp/api.ts +284 -0
- package/src/ui/mcp/guards.ts +141 -0
- package/src/ui/mcp/server.ts +147 -0
- package/src/ui/mcp/tools.ts +565 -0
- package/src/ui/server/audit.ts +71 -0
- package/src/ui/server/changelog-parser.ts +105 -0
- package/src/ui/server/daemon-reaper.ts +129 -0
- package/src/ui/server/editor-manager.ts +301 -0
- package/src/ui/server/github-service.ts +223 -0
- package/src/ui/server/graph-prefs.ts +89 -0
- package/src/ui/server/health-row.ts +15 -0
- package/src/ui/server/http-helpers.ts +98 -0
- package/src/ui/server/index.ts +3350 -0
- package/src/ui/server/log-history.ts +453 -0
- package/src/ui/server/perf-report.ts +42 -0
- package/src/ui/server/public/environments.html +496 -0
- package/src/ui/server/public/favicon.ico +0 -0
- package/src/ui/server/public/graph-model.js +416 -0
- package/src/ui/server/public/graph.css +976 -0
- package/src/ui/server/public/graph.js +986 -0
- package/src/ui/server/public/header.js +1373 -0
- package/src/ui/server/public/index.html +2595 -0
- package/src/ui/server/public/local-dev.html +5344 -0
- package/src/ui/server/public/logs.html +1496 -0
- package/src/ui/server/public/panel.js +787 -0
- package/src/ui/server/public/perf-report.js +359 -0
- package/src/ui/server/public/release.html +2111 -0
- package/src/ui/server/public/services.html +1103 -0
- package/src/ui/server/public/workspace-picker.js +69 -0
- package/src/ui/server/readonly-routes.ts +200 -0
- package/src/ui/server/release-deploy-session.ts +246 -0
- package/src/ui/server/release-service.ts +1125 -0
- package/src/ui/server/sudo-store.ts +111 -0
- package/src/ui/server/ui-assets.ts +73 -0
- package/src/ui/server/workspace-routes.ts +2185 -0
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import {
|
|
4
|
+
loadProfile,
|
|
5
|
+
PROFILE_FILE,
|
|
6
|
+
readEnv,
|
|
7
|
+
type ProductProfile,
|
|
8
|
+
} from "./profile.js";
|
|
9
|
+
|
|
10
|
+
/** Den's package name: the engine root is the directory that has it. */
|
|
11
|
+
export const ENGINE_PACKAGE = "@monster-orange/den";
|
|
12
|
+
|
|
13
|
+
/** A distribution root is a directory holding den.yml. */
|
|
14
|
+
function isDistRootAt(dir: string): boolean {
|
|
15
|
+
return fs.existsSync(path.join(dir, PROFILE_FILE));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function isEngineRootAt(dir: string): boolean {
|
|
19
|
+
try {
|
|
20
|
+
const raw = fs.readFileSync(path.join(dir, "package.json"), "utf-8");
|
|
21
|
+
return (JSON.parse(raw) as { name?: unknown }).name === ENGINE_PACKAGE;
|
|
22
|
+
} catch {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function walkUp(start: string, test: (dir: string) => boolean): string | null {
|
|
28
|
+
let dir = path.resolve(start);
|
|
29
|
+
for (;;) {
|
|
30
|
+
if (test(dir)) return dir;
|
|
31
|
+
const parent = path.dirname(dir);
|
|
32
|
+
if (parent === dir) return null;
|
|
33
|
+
dir = parent;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const entryDir = (): string | null =>
|
|
38
|
+
process.argv[1] ? path.dirname(path.resolve(process.argv[1])) : null;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* An explicit root from the environment: DEN_ROOT, or `<PREFIX>_ROOT` where
|
|
42
|
+
* <PREFIX> is the env prefix of the profile found there (ACME_ROOT for a
|
|
43
|
+
* product named acme). Validated rather than trusted: everything downstream (clone
|
|
44
|
+
* destinations, the clone-guard allowlist, config writes) anchors here, so a
|
|
45
|
+
* stale or mistyped value must not re-open the Jun 29 incident.
|
|
46
|
+
*/
|
|
47
|
+
function rootFromEnv(): string | null {
|
|
48
|
+
const den = process.env.DEN_ROOT?.trim();
|
|
49
|
+
if (den) {
|
|
50
|
+
const resolved = path.resolve(den);
|
|
51
|
+
if (!isDistRootAt(resolved))
|
|
52
|
+
throw new Error(
|
|
53
|
+
`DEN_ROOT is set to "${den}" but that directory has no ${PROFILE_FILE}. ` +
|
|
54
|
+
`Point it at your distribution's checkout, or unset it.`,
|
|
55
|
+
);
|
|
56
|
+
return resolved;
|
|
57
|
+
}
|
|
58
|
+
for (const [key, value] of Object.entries(process.env)) {
|
|
59
|
+
if (!/^[A-Z][A-Z0-9_]*_ROOT$/.test(key) || !value?.trim()) continue;
|
|
60
|
+
const resolved = path.resolve(value.trim());
|
|
61
|
+
if (
|
|
62
|
+
isDistRootAt(resolved) &&
|
|
63
|
+
`${loadProfile(resolved).envPrefix}_ROOT` === key
|
|
64
|
+
)
|
|
65
|
+
return resolved;
|
|
66
|
+
}
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The distribution root: where den.yml, config/, manifests/, reports/ and
|
|
72
|
+
* results/ live. Never process.cwd(): on Jun 29 a cwd-derived root caused a
|
|
73
|
+
* bulk clone of 13 repos into an unrelated checkout. Anything the walk can't
|
|
74
|
+
* anchor fails loudly.
|
|
75
|
+
*/
|
|
76
|
+
export function getDistRoot(): string {
|
|
77
|
+
const fromEnv = rootFromEnv();
|
|
78
|
+
if (fromEnv) return fromEnv;
|
|
79
|
+
const start = entryDir();
|
|
80
|
+
const root = start ? walkUp(start, isDistRootAt) : null;
|
|
81
|
+
if (root !== null) {
|
|
82
|
+
// The distribution's own <PREFIX>_ROOT, if set, must have been accepted
|
|
83
|
+
// above: one that points elsewhere is a mistake, never silently ignored.
|
|
84
|
+
const key = `${loadProfile(root).envPrefix}_ROOT`;
|
|
85
|
+
const set = process.env[key]?.trim();
|
|
86
|
+
if (set)
|
|
87
|
+
throw new Error(
|
|
88
|
+
`${key} is set to "${set}" but that directory has no ${PROFILE_FILE} ` +
|
|
89
|
+
`for this product. Point ${key} at your checkout, or unset it.`,
|
|
90
|
+
);
|
|
91
|
+
return root;
|
|
92
|
+
}
|
|
93
|
+
// Name any product root variable that was set but pointed nowhere useful.
|
|
94
|
+
for (const [key, value] of Object.entries(process.env))
|
|
95
|
+
if (/^[A-Z][A-Z0-9_]*_ROOT$/.test(key) && value?.trim()) {
|
|
96
|
+
const resolved = path.resolve(value.trim());
|
|
97
|
+
if (
|
|
98
|
+
fs.existsSync(resolved) &&
|
|
99
|
+
!isDistRootAt(resolved) &&
|
|
100
|
+
isProductRootVar(resolved, key)
|
|
101
|
+
)
|
|
102
|
+
throw new Error(
|
|
103
|
+
`${key} is set to "${value}" but that directory has no ${PROFILE_FILE}. ` +
|
|
104
|
+
`Point ${key} at your checkout, or unset it.`,
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
throw new Error(
|
|
108
|
+
`cannot locate the distribution root: walked up from ` +
|
|
109
|
+
`"${start ?? "(no entry script)"}" without finding ${PROFILE_FILE}. ` +
|
|
110
|
+
`Set DEN_ROOT (or <PRODUCT>_ROOT) to your checkout directory ` +
|
|
111
|
+
`(the current directory, "${process.cwd()}", is never used as an anchor).`,
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Whether `<PREFIX>_ROOT` is a product root variable (rather than some other
|
|
117
|
+
* tool's): the active profile, if one is pinned by DEN_PROFILE, owns it.
|
|
118
|
+
*/
|
|
119
|
+
function isProductRootVar(dir: string, key: string): boolean {
|
|
120
|
+
const pinned = process.env.DEN_PROFILE?.trim();
|
|
121
|
+
if (!pinned) return false;
|
|
122
|
+
return `${loadProfile(dir).envPrefix}_ROOT` === key;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Where Den's own files live (src/, public pages, make/): the directory whose
|
|
127
|
+
* package.json is @monster-orange/den. In a distribution that is
|
|
128
|
+
* node_modules/@monster-orange/den; in Den's own checkout, its root.
|
|
129
|
+
*/
|
|
130
|
+
export function getEngineRoot(): string {
|
|
131
|
+
const env = process.env.DEN_ENGINE_ROOT?.trim();
|
|
132
|
+
if (env) return path.resolve(env);
|
|
133
|
+
for (const start of [entryDir(), process.cwd()]) {
|
|
134
|
+
if (!start) continue;
|
|
135
|
+
const viaWalk = walkUp(start, isEngineRootAt);
|
|
136
|
+
if (viaWalk) return viaWalk;
|
|
137
|
+
const nested = path.join(
|
|
138
|
+
start,
|
|
139
|
+
"node_modules",
|
|
140
|
+
...ENGINE_PACKAGE.split("/"),
|
|
141
|
+
);
|
|
142
|
+
if (isEngineRootAt(nested)) return nested;
|
|
143
|
+
}
|
|
144
|
+
throw new Error(
|
|
145
|
+
`cannot locate Den's own files: no package.json named ${ENGINE_PACKAGE} ` +
|
|
146
|
+
`above the entry script or the current directory. Set DEN_ENGINE_ROOT.`,
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Den's own version (its package.json), or undefined when unreadable. */
|
|
151
|
+
export function engineVersion(): string | undefined {
|
|
152
|
+
try {
|
|
153
|
+
const pkg = JSON.parse(
|
|
154
|
+
fs.readFileSync(path.join(getEngineRoot(), "package.json"), "utf-8"),
|
|
155
|
+
) as { version?: unknown };
|
|
156
|
+
return typeof pkg.version === "string" ? pkg.version : undefined;
|
|
157
|
+
} catch {
|
|
158
|
+
return undefined;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** This distribution's profile. */
|
|
163
|
+
export function getProfile(): ProductProfile {
|
|
164
|
+
return loadProfile(getDistRoot());
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* The caller's cwd, handed over by the CLI wrapper (bin/den) in
|
|
169
|
+
* DEN_INVOKE_CWD, or the product's own `<PREFIX>_INVOKE_CWD`.
|
|
170
|
+
*/
|
|
171
|
+
export function getInvokeCwd(): string {
|
|
172
|
+
const handed = process.env.DEN_INVOKE_CWD?.trim();
|
|
173
|
+
if (handed) return handed;
|
|
174
|
+
try {
|
|
175
|
+
return readEnv(getProfile(), "INVOKE_CWD") ?? process.cwd();
|
|
176
|
+
} catch {
|
|
177
|
+
return process.cwd(); // no distribution root yet: the caller's cwd
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Per-user state directory for this instance (web server, daemons, logs,
|
|
183
|
+
* history): the profile's state_dir (~/.<name>), or `<PREFIX>_HOME`, so two
|
|
184
|
+
* instances on one machine never share or stop each other's processes.
|
|
185
|
+
*/
|
|
186
|
+
export function getStateDir(): string {
|
|
187
|
+
const profile = getProfile();
|
|
188
|
+
const home = readEnv(profile, "HOME")?.trim();
|
|
189
|
+
return home ? path.resolve(home) : profile.stateDir;
|
|
190
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure resolver for declarative per-perf-test "optional launch fields".
|
|
3
|
+
*
|
|
4
|
+
* A perf test declares `PerfOptionalField[]` (in its co-located manifest.yml or
|
|
5
|
+
* `.den.yml`). The web UI renders these generically and collects raw values;
|
|
6
|
+
* this resolver turns (fields, values) into the env-var map that gets injected
|
|
7
|
+
* into THAT test's k6 process. No I/O, no globals — fully unit-tested.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { PerfOptionalField } from "../types/index.js";
|
|
11
|
+
|
|
12
|
+
type FieldValue = string | number | boolean | undefined;
|
|
13
|
+
|
|
14
|
+
/** True for the canonical truthy spellings a boolean field may arrive as. */
|
|
15
|
+
function isTruthy(value: FieldValue): boolean {
|
|
16
|
+
if (typeof value === "boolean") return value;
|
|
17
|
+
if (typeof value === "number") return value !== 0;
|
|
18
|
+
if (typeof value === "string") {
|
|
19
|
+
const v = value.trim().toLowerCase();
|
|
20
|
+
return v === "true" || v === "1" || v === "yes" || v === "on";
|
|
21
|
+
}
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Resolve declared optional fields against user-supplied values into an env map.
|
|
27
|
+
*
|
|
28
|
+
* Rules (applied per field, in declaration order):
|
|
29
|
+
* - `disabledWhen` satisfied by the (possibly defaulted) controlling field's
|
|
30
|
+
* effective value → SKIP (no env emitted).
|
|
31
|
+
* - effective value = values[name] ?? default.
|
|
32
|
+
* - boolean: truthy → env = emit ?? "1"; falsy/undefined → omit.
|
|
33
|
+
* - select: env set only when the value is one of options[].value; else omit.
|
|
34
|
+
* - string/number: env = String(value) when defined and non-empty.
|
|
35
|
+
*/
|
|
36
|
+
export function resolveFieldEnv(
|
|
37
|
+
fields: PerfOptionalField[],
|
|
38
|
+
values: Record<string, FieldValue>,
|
|
39
|
+
): Record<string, string> {
|
|
40
|
+
const effective = (field: PerfOptionalField): FieldValue =>
|
|
41
|
+
values[field.name] ?? field.default;
|
|
42
|
+
|
|
43
|
+
const byName = new Map(fields.map((f) => [f.name, f]));
|
|
44
|
+
|
|
45
|
+
const env: Record<string, string> = {};
|
|
46
|
+
|
|
47
|
+
for (const field of fields) {
|
|
48
|
+
if (field.disabledWhen) {
|
|
49
|
+
const controller = byName.get(field.disabledWhen.field);
|
|
50
|
+
const controllerValue = controller
|
|
51
|
+
? effective(controller)
|
|
52
|
+
: values[field.disabledWhen.field];
|
|
53
|
+
if (matchesEquals(controllerValue, field.disabledWhen.equals)) {
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const value = effective(field);
|
|
59
|
+
|
|
60
|
+
switch (field.type) {
|
|
61
|
+
case "boolean": {
|
|
62
|
+
if (isTruthy(value)) {
|
|
63
|
+
env[field.env] = field.emit ?? "1";
|
|
64
|
+
}
|
|
65
|
+
break;
|
|
66
|
+
}
|
|
67
|
+
case "select": {
|
|
68
|
+
const candidate =
|
|
69
|
+
value === undefined || value === null ? undefined : String(value);
|
|
70
|
+
const allowed = (field.options ?? []).some(
|
|
71
|
+
(opt) => opt.value === candidate,
|
|
72
|
+
);
|
|
73
|
+
if (candidate !== undefined && allowed) {
|
|
74
|
+
env[field.env] = candidate;
|
|
75
|
+
}
|
|
76
|
+
break;
|
|
77
|
+
}
|
|
78
|
+
case "string":
|
|
79
|
+
case "number": {
|
|
80
|
+
if (value !== undefined && value !== null && String(value) !== "") {
|
|
81
|
+
env[field.env] = String(value);
|
|
82
|
+
}
|
|
83
|
+
break;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return env;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Compare a controlling field's value against a `disabledWhen.equals` target.
|
|
93
|
+
* Booleans compare against the field's canonical truthiness so a checkbox that
|
|
94
|
+
* arrives as the string "true" still matches `equals: true`.
|
|
95
|
+
*/
|
|
96
|
+
function matchesEquals(
|
|
97
|
+
value: FieldValue,
|
|
98
|
+
equals: string | number | boolean,
|
|
99
|
+
): boolean {
|
|
100
|
+
if (typeof equals === "boolean") {
|
|
101
|
+
return isTruthy(value) === equals;
|
|
102
|
+
}
|
|
103
|
+
if (value === undefined || value === null) return false;
|
|
104
|
+
return String(value) === String(equals);
|
|
105
|
+
}
|
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
PerfCheckResult,
|
|
3
|
+
PerfMetricSummary,
|
|
4
|
+
PerfModuleRunReport,
|
|
5
|
+
PerfReportValue,
|
|
6
|
+
PerfRunReport,
|
|
7
|
+
PerfScenarioSummary,
|
|
8
|
+
PerfThresholdResult,
|
|
9
|
+
} from "./report-types.js";
|
|
10
|
+
|
|
11
|
+
interface K6Metric {
|
|
12
|
+
type?: string;
|
|
13
|
+
contains?: string;
|
|
14
|
+
values?: Record<string, number>;
|
|
15
|
+
thresholds?: Record<string, boolean | { ok?: boolean }>;
|
|
16
|
+
[key: string]: unknown;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface K6Check {
|
|
20
|
+
name?: string;
|
|
21
|
+
passes?: number;
|
|
22
|
+
fails?: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface K6Summary {
|
|
26
|
+
metrics?: Record<string, K6Metric>;
|
|
27
|
+
root_group?: {
|
|
28
|
+
checks?: K6Check[] | Record<string, Omit<K6Check, "name">>;
|
|
29
|
+
};
|
|
30
|
+
options?: {
|
|
31
|
+
scenarios?: Record<string, Record<string, unknown>>;
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface NormalizeSummaryInput {
|
|
36
|
+
module: string;
|
|
37
|
+
summary: K6Summary;
|
|
38
|
+
artifactPath: string;
|
|
39
|
+
startedAt?: string;
|
|
40
|
+
finishedAt?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface BuildRunReportInput {
|
|
44
|
+
environment: string;
|
|
45
|
+
modules: NormalizeSummaryInput[];
|
|
46
|
+
startedAt?: string;
|
|
47
|
+
finishedAt?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const CORE_METRIC_DEFS: Array<{
|
|
51
|
+
key: string;
|
|
52
|
+
source: string;
|
|
53
|
+
label: string;
|
|
54
|
+
unit?: string;
|
|
55
|
+
}> = [
|
|
56
|
+
{ key: "count", source: "http_reqs", label: "HTTP requests" },
|
|
57
|
+
{ key: "count", source: "iterations", label: "Iterations" },
|
|
58
|
+
{ key: "value", source: "vus", label: "Virtual users" },
|
|
59
|
+
];
|
|
60
|
+
|
|
61
|
+
const LATENCY_METRIC_DEFS: Array<{
|
|
62
|
+
key: string;
|
|
63
|
+
source: string;
|
|
64
|
+
label: string;
|
|
65
|
+
}> = [
|
|
66
|
+
{ key: "avg", source: "http_req_duration", label: "HTTP avg" },
|
|
67
|
+
{ key: "med", source: "http_req_duration", label: "HTTP p50" },
|
|
68
|
+
{ key: "p(95)", source: "http_req_duration", label: "HTTP p95" },
|
|
69
|
+
{ key: "p(99)", source: "http_req_duration", label: "HTTP p99" },
|
|
70
|
+
{ key: "max", source: "http_req_duration", label: "HTTP max" },
|
|
71
|
+
];
|
|
72
|
+
|
|
73
|
+
const THROUGHPUT_METRIC_DEFS: Array<{
|
|
74
|
+
key: string;
|
|
75
|
+
source: string;
|
|
76
|
+
label: string;
|
|
77
|
+
}> = [
|
|
78
|
+
{ key: "rate", source: "http_reqs", label: "Requests / sec" },
|
|
79
|
+
{ key: "rate", source: "iterations", label: "Iterations / sec" },
|
|
80
|
+
{ key: "rate", source: "http_req_failed", label: "Failure rate" },
|
|
81
|
+
];
|
|
82
|
+
|
|
83
|
+
export function normalizeK6Summary(
|
|
84
|
+
input: NormalizeSummaryInput,
|
|
85
|
+
): PerfModuleRunReport {
|
|
86
|
+
const metrics = input.summary.metrics ?? {};
|
|
87
|
+
const thresholds = flattenThresholds(metrics);
|
|
88
|
+
const checks = normalizeChecks(input.summary.root_group?.checks);
|
|
89
|
+
const scenarios = normalizeScenarios(input.summary.options?.scenarios);
|
|
90
|
+
const customMetrics = normalizeCustomMetrics(metrics);
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
module: input.module,
|
|
94
|
+
status: thresholds.some((threshold) => !threshold.passed)
|
|
95
|
+
? "failed"
|
|
96
|
+
: "passed",
|
|
97
|
+
startedAt: input.startedAt,
|
|
98
|
+
finishedAt: input.finishedAt,
|
|
99
|
+
thresholds,
|
|
100
|
+
coreMetrics: selectReportValues(metrics, CORE_METRIC_DEFS),
|
|
101
|
+
latencyMetrics: selectReportValues(metrics, LATENCY_METRIC_DEFS, "ms"),
|
|
102
|
+
throughputMetrics: selectReportValues(metrics, THROUGHPUT_METRIC_DEFS),
|
|
103
|
+
scenarios,
|
|
104
|
+
checks,
|
|
105
|
+
customMetrics,
|
|
106
|
+
rawArtifacts: [
|
|
107
|
+
{
|
|
108
|
+
kind: "k6-summary",
|
|
109
|
+
path: input.artifactPath,
|
|
110
|
+
},
|
|
111
|
+
],
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function buildPerfRunReport(input: BuildRunReportInput): PerfRunReport {
|
|
116
|
+
const modules = input.modules.map((moduleInput) =>
|
|
117
|
+
normalizeK6Summary(moduleInput),
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
pillar: "performance",
|
|
122
|
+
environment: input.environment,
|
|
123
|
+
status: modules.some((module) => module.status === "failed")
|
|
124
|
+
? "failed"
|
|
125
|
+
: "passed",
|
|
126
|
+
startedAt: input.startedAt,
|
|
127
|
+
finishedAt: input.finishedAt,
|
|
128
|
+
modules,
|
|
129
|
+
rawArtifacts: modules.flatMap((module) => module.rawArtifacts),
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function flattenThresholds(
|
|
134
|
+
metrics: Record<string, K6Metric>,
|
|
135
|
+
): PerfThresholdResult[] {
|
|
136
|
+
return Object.entries(metrics).flatMap(([metricKey, metric]) => {
|
|
137
|
+
const thresholdEntries = Object.entries(metric.thresholds ?? {});
|
|
138
|
+
return thresholdEntries.map(([expression, passed]) => {
|
|
139
|
+
const actualValue = deriveThresholdActualValue(metric, expression);
|
|
140
|
+
return {
|
|
141
|
+
metric: metricKey,
|
|
142
|
+
name: `${metricKey}:${expression}`,
|
|
143
|
+
expression,
|
|
144
|
+
passed: normalizeThresholdPassed(actualValue, expression, passed),
|
|
145
|
+
actualValue,
|
|
146
|
+
unit: metric.contains === "time" ? "ms" : undefined,
|
|
147
|
+
};
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function deriveThresholdActualValue(
|
|
153
|
+
metric: K6Metric,
|
|
154
|
+
expression: string,
|
|
155
|
+
): number | string | null {
|
|
156
|
+
const values = getMetricValues(metric);
|
|
157
|
+
const percentile = expression.match(/^p\(([\d.]+)\)/);
|
|
158
|
+
if (percentile) {
|
|
159
|
+
return values[`p(${percentile[1]})`] ?? null;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (expression.startsWith("rate")) {
|
|
163
|
+
return firstDefinedNumber(values.rate, values.value) ?? null;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (expression.startsWith("avg")) {
|
|
167
|
+
return values.avg ?? null;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (expression.startsWith("count")) {
|
|
171
|
+
return values.count ?? null;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return values.value ?? values.avg ?? values.rate ?? values.count ?? null;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function selectReportValues(
|
|
178
|
+
metrics: Record<string, K6Metric>,
|
|
179
|
+
definitions: Array<{ key: string; source: string; label: string }>,
|
|
180
|
+
unit?: string,
|
|
181
|
+
): PerfReportValue[] {
|
|
182
|
+
return definitions.flatMap((definition) => {
|
|
183
|
+
const value = getMetricDisplayValue(
|
|
184
|
+
metrics[definition.source],
|
|
185
|
+
definition.key,
|
|
186
|
+
);
|
|
187
|
+
if (typeof value !== "number") {
|
|
188
|
+
return [];
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return [
|
|
192
|
+
{
|
|
193
|
+
label: definition.label,
|
|
194
|
+
value,
|
|
195
|
+
unit,
|
|
196
|
+
},
|
|
197
|
+
];
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function normalizeChecks(
|
|
202
|
+
checks?: K6Check[] | Record<string, Omit<K6Check, "name">>,
|
|
203
|
+
): PerfCheckResult[] {
|
|
204
|
+
const normalizedChecks = Array.isArray(checks)
|
|
205
|
+
? checks
|
|
206
|
+
: Object.entries(checks ?? {}).map(([name, check]) => ({
|
|
207
|
+
...check,
|
|
208
|
+
name,
|
|
209
|
+
}));
|
|
210
|
+
|
|
211
|
+
return normalizedChecks
|
|
212
|
+
.filter(
|
|
213
|
+
(check): check is Required<Pick<K6Check, "name">> & K6Check =>
|
|
214
|
+
typeof check.name === "string",
|
|
215
|
+
)
|
|
216
|
+
.map((check) => {
|
|
217
|
+
const passes = check.passes ?? 0;
|
|
218
|
+
const fails = check.fails ?? 0;
|
|
219
|
+
const total = passes + fails;
|
|
220
|
+
return {
|
|
221
|
+
name: check.name,
|
|
222
|
+
passes,
|
|
223
|
+
fails,
|
|
224
|
+
passRate: total > 0 ? passes / total : null,
|
|
225
|
+
};
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function normalizeScenarios(
|
|
230
|
+
scenarios?: Record<string, Record<string, unknown>>,
|
|
231
|
+
): PerfScenarioSummary[] {
|
|
232
|
+
return Object.entries(scenarios ?? {}).map(([name, scenario]) => ({
|
|
233
|
+
name,
|
|
234
|
+
executor:
|
|
235
|
+
typeof scenario.executor === "string" ? scenario.executor : undefined,
|
|
236
|
+
startTime:
|
|
237
|
+
typeof scenario.startTime === "string" ? scenario.startTime : undefined,
|
|
238
|
+
gracefulStop:
|
|
239
|
+
typeof scenario.gracefulStop === "string"
|
|
240
|
+
? scenario.gracefulStop
|
|
241
|
+
: undefined,
|
|
242
|
+
options: scenario,
|
|
243
|
+
}));
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function normalizeCustomMetrics(
|
|
247
|
+
metrics: Record<string, K6Metric>,
|
|
248
|
+
): PerfMetricSummary[] {
|
|
249
|
+
const builtInMetrics = new Set([
|
|
250
|
+
"http_reqs",
|
|
251
|
+
"http_req_failed",
|
|
252
|
+
"http_req_duration",
|
|
253
|
+
"http_req_blocked",
|
|
254
|
+
"http_req_connecting",
|
|
255
|
+
"http_req_receiving",
|
|
256
|
+
"http_req_sending",
|
|
257
|
+
"http_req_tls_handshaking",
|
|
258
|
+
"http_req_waiting",
|
|
259
|
+
"iteration_duration",
|
|
260
|
+
"iterations",
|
|
261
|
+
"vus",
|
|
262
|
+
"vus_max",
|
|
263
|
+
"checks",
|
|
264
|
+
"data_received",
|
|
265
|
+
"data_sent",
|
|
266
|
+
"group_duration",
|
|
267
|
+
]);
|
|
268
|
+
|
|
269
|
+
return Object.entries(metrics)
|
|
270
|
+
.filter(([metricKey]) => {
|
|
271
|
+
const baseKey = metricKey.replace(/\{.*\}$/, "");
|
|
272
|
+
return !builtInMetrics.has(baseKey);
|
|
273
|
+
})
|
|
274
|
+
.map(([metricKey, metric]) => ({
|
|
275
|
+
key: metricKey,
|
|
276
|
+
label: metricKey.replace(/_/g, " "),
|
|
277
|
+
type: metric.type,
|
|
278
|
+
contains: metric.contains,
|
|
279
|
+
values: normalizeMetricValues(getMetricValues(metric)),
|
|
280
|
+
thresholds: flattenThresholds({ [metricKey]: metric }),
|
|
281
|
+
isCustom: true,
|
|
282
|
+
}));
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function normalizeMetricValues(
|
|
286
|
+
values?: Record<string, number>,
|
|
287
|
+
): Record<string, number | null> {
|
|
288
|
+
return Object.fromEntries(
|
|
289
|
+
Object.entries(values ?? {}).map(([key, value]) => [
|
|
290
|
+
key,
|
|
291
|
+
typeof value === "number" ? value : null,
|
|
292
|
+
]),
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function getMetricValues(metric?: K6Metric): Record<string, number> {
|
|
297
|
+
if (!metric) {
|
|
298
|
+
return {};
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
if (metric.values && Object.keys(metric.values).length > 0) {
|
|
302
|
+
return normalizeNumericEntries(metric.values);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const topLevelEntries = Object.entries(metric).filter(
|
|
306
|
+
(entry): entry is [string, number] => {
|
|
307
|
+
const [key, value] = entry;
|
|
308
|
+
if (key === "type" || key === "contains" || key === "thresholds") {
|
|
309
|
+
return false;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
return typeof value === "number";
|
|
313
|
+
},
|
|
314
|
+
);
|
|
315
|
+
|
|
316
|
+
return Object.fromEntries(topLevelEntries);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function getMetricDisplayValue(
|
|
320
|
+
metric: K6Metric | undefined,
|
|
321
|
+
preferredKey: string,
|
|
322
|
+
): number | undefined {
|
|
323
|
+
const values = getMetricValues(metric);
|
|
324
|
+
|
|
325
|
+
if (metric?.type === "gauge" && preferredKey === "value") {
|
|
326
|
+
return firstDefinedNumber(values.max, values.value, values.min);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
if (preferredKey === "rate") {
|
|
330
|
+
return firstDefinedNumber(values.rate, values.value);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
return values[preferredKey];
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function firstDefinedNumber(
|
|
337
|
+
...candidates: Array<number | undefined>
|
|
338
|
+
): number | undefined {
|
|
339
|
+
return candidates.find((candidate) => typeof candidate === "number");
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function normalizeNumericEntries(
|
|
343
|
+
values: Record<string, number>,
|
|
344
|
+
): Record<string, number> {
|
|
345
|
+
return Object.fromEntries(
|
|
346
|
+
Object.entries(values).filter(
|
|
347
|
+
(entry): entry is [string, number] => typeof entry[1] === "number",
|
|
348
|
+
),
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function normalizeThresholdPassed(
|
|
353
|
+
actualValue: number | string | null,
|
|
354
|
+
expression: string,
|
|
355
|
+
value: boolean | { ok?: boolean },
|
|
356
|
+
): boolean {
|
|
357
|
+
const evaluated = evaluateThresholdExpression(actualValue, expression);
|
|
358
|
+
if (typeof evaluated === "boolean") {
|
|
359
|
+
return evaluated;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
if (typeof value === "boolean") {
|
|
363
|
+
return value;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
return value.ok ?? false;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function evaluateThresholdExpression(
|
|
370
|
+
actualValue: number | string | null,
|
|
371
|
+
expression: string,
|
|
372
|
+
): boolean | undefined {
|
|
373
|
+
if (typeof actualValue !== "number") {
|
|
374
|
+
return undefined;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const match = expression.match(
|
|
378
|
+
/^[a-zA-Z0-9_.()]+\s*(<=|>=|<|>|==)\s*(-?\d+(?:\.\d+)?)$/,
|
|
379
|
+
);
|
|
380
|
+
if (!match) {
|
|
381
|
+
return undefined;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const [, operator, targetRaw] = match;
|
|
385
|
+
const target = Number(targetRaw);
|
|
386
|
+
if (!Number.isFinite(target)) {
|
|
387
|
+
return undefined;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
switch (operator) {
|
|
391
|
+
case "<":
|
|
392
|
+
return actualValue < target;
|
|
393
|
+
case "<=":
|
|
394
|
+
return actualValue <= target;
|
|
395
|
+
case ">":
|
|
396
|
+
return actualValue > target;
|
|
397
|
+
case ">=":
|
|
398
|
+
return actualValue >= target;
|
|
399
|
+
case "==":
|
|
400
|
+
return actualValue === target;
|
|
401
|
+
default:
|
|
402
|
+
return undefined;
|
|
403
|
+
}
|
|
404
|
+
}
|